diff --git a/README.md b/README.md index 6a3dcb7..26a8435 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ see [Screen capture](#screen-capture). move the file to the Recycle Bin or to delete it permanently, overwriting it with random bytes first; "Don't ask again" keeps the choice until the app restarts. Only the `.mcap` goes: the `.snagx` it was converted from is untouched. +- **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then + **Ctrl-C** copies its image, as the Capture window shows it (annotations and all; a video's + frame), and **Ctrl-Shift-C** its full path. Neither acts while you type in the search box. They + are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config`. Copying images is Windows + only so far. - **Files added or deleted outside the app** (in Explorer, by another program) appear in the grid or leave it on their own, folders added, renamed or deleted too: the library is watched. **Refresh** looks at the whole folder again, should the watcher have missed something. diff --git a/assets/data/default_hotkeys.config b/assets/data/default_hotkeys.config index 34c479c..06059d1 100644 --- a/assets/data/default_hotkeys.config +++ b/assets/data/default_hotkeys.config @@ -18,6 +18,10 @@ Ctrl-Equal increase_font_size Ctrl-Alt-Space reset_window Ctrl-Shift-Alt-R reset_layout +# Library: the selected capture +Ctrl-C copy_image +Ctrl-Shift-C copy_path + # Debug Ctrl-Shift-D toggle_debug_panel Ctrl-Shift-L toggle_logger_panel diff --git a/assets/data/default_hotkeys_macos.config b/assets/data/default_hotkeys_macos.config index 2b7fb02..6823966 100644 --- a/assets/data/default_hotkeys_macos.config +++ b/assets/data/default_hotkeys_macos.config @@ -18,6 +18,10 @@ Cmd-Equal increase_font_size Cmd-Opt-Shift-Space reset_window Cmd-Shift-Opt-R reset_layout +# Library: the selected capture +Cmd-C copy_image +Cmd-Shift-C copy_path + # Debug Cmd-Shift-D toggle_debug_panel Cmd-Shift-L toggle_logger_panel diff --git a/check_targets.jai b/check_targets.jai index 06c3a2d..dce7ad4 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -47,7 +47,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; GIT_BRANCH :: "check"; GIT_HASH :: "-"; -VERSION :: "0.1.15"; +VERSION :: "0.1.16"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index cdee0a5..02ce52a 100644 --- a/first.jai +++ b/first.jai @@ -8,7 +8,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; -VERSION :: "0.1.15"; +VERSION :: "0.1.16"; // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // builds never write over the ones you run by hand. build_directory := "bin"; diff --git a/src/app/viewer.jai b/src/app/viewer.jai index 255bd52..9aea35e 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -156,6 +156,14 @@ viewer: struct { open_name: string; open_result: string; // the file written, or ""; heap, the open thread's until it ends open_error: string; + + // The selected capture's image, decoded off the main thread for the clipboard (viewer_copy_image). + copy_thread: Thread; + copy_started: bool; + copy_busy: bool; // set by the main thread, cleared by the copy thread when it ends + copy_source: string; // the .mcap; heap, main thread's until the thread starts + copy_name: string; + copy_done: Decoded; // the copy thread's until it ends } // ---------------------------------------------------------------- startup and shutdown @@ -229,6 +237,7 @@ viewer_shutdown :: () { for 1..viewer.workers_started signal(*viewer.wake); for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1); if viewer.open_started thread_is_done(*viewer.open_thread, -1); + if viewer.copy_started thread_is_done(*viewer.copy_thread, -1); background_shutdown(); catalog_cache_save_at_shutdown(); // the workers have stopped: nothing changes the entries now viewer_watch_stop(); @@ -451,6 +460,7 @@ viewer_frame_gpu_work :: () { viewer_apply_catalog_updates(); catalog_cache_frame(); // saves the catalog cache once it is complete and has changed (app/catalog.jai) viewer_open_frame(); + viewer_copy_frame(); segment_overlay_frame(); // the Capture window's segments: what arrived, and its texture (app/segment_ui.jai) background_frame(); // takes back search results, and starts a search when visible_dirty (app/background.jai) } @@ -1215,6 +1225,77 @@ viewer_open_frame :: () { else toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error); } +// ---------------------------------------------------------------- copying the selected capture + +// The selected capture: the one outlined in the grid and shown in the Capture window. -1 when none. +viewer_selected :: () -> s32 { + index := viewer.full_index; + if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed return -1; + return index; +} + +// Ctrl-Shift-C (copy_path in [hotkeys]): the selected capture's path, as the system writes paths. +viewer_copy_path :: () { + index := viewer_selected(); + if index < 0 { toast_show(.Error, "Select a capture to copy its path"); return; } + capture := *viewer.captures[index]; + path := capture.path; + #if OS == .WINDOWS path = replace(path, "/", "\\",, temp); + clipboard_set_text(path); + toast_show(.Success, "Copied the path of %", capture.name); +} + +// Ctrl-C (copy_image in [hotkeys]): the selected capture's image, as the Capture window shows it +// (annotations and all; a video's frame), decoded on a thread of its own and put on the clipboard +// by viewer_copy_frame. +viewer_copy_image :: () { + index := viewer_selected(); + if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; } + capture := *viewer.captures[index]; + #if OS != .WINDOWS { + toast_show(.Error, "Copying images is not available on this system yet"); + return; + } + if viewer.copy_started { toast_show(.Error, "Still copying %", viewer.copy_name); return; } + push_allocator(context.default_allocator); + free(viewer.copy_source); free(viewer.copy_name); + viewer.copy_source = copy_string(capture.path); + viewer.copy_name = copy_string(capture.name); + viewer.copy_done = .{}; + viewer.copy_busy = true; + // Reading an image takes a few MB of temporary storage, as on the viewer's workers. + viewer.copy_started = thread_init(*viewer.copy_thread, viewer_copy_proc, temporary_storage_size = 1024 * 1024, name = "copy image"); + if !viewer.copy_started { viewer.copy_busy = false; toast_show(.Error, "Could not copy %", capture.name); return; } + thread_start(*viewer.copy_thread); +} + +viewer_copy_proc :: (thread: *Thread) -> s64 { + context.allocator = context.default_allocator; + defer atomic_swap(*viewer.copy_busy, false); + viewer.copy_done = viewer_decode_full(viewer.copy_source); + return 0; +} + +// Each frame: puts what viewer_copy_proc decoded on the clipboard, once it is done. +viewer_copy_frame :: () { + if !viewer.copy_started || viewer.copy_busy return; + thread_is_done(*viewer.copy_thread, -1); + thread_deinit(*viewer.copy_thread); + viewer.copy_started = false; + done := *viewer.copy_done; + defer viewer_free_pixels(done); + if done.failed { toast_show(.Error, "Could not read the image of %; see the Event Log", viewer.copy_name); return; } + pixels := []u8.{cast(s64) done.width * done.height * 4, done.pixels}; + if !platform_copy_image_to_clipboard(pixels, done.width, done.height) { + log_error("[viewer] Could not put the image of % on the clipboard.", viewer.copy_source); + toast_show(.Error, "Could not put % on the clipboard", viewer.copy_name); + return; + } + scaled := ifx done.width != done.source_width || done.height != done.source_height then tprint(", scaled down from % x %", done.source_width, done.source_height) else ""; + log("[viewer] Copied the image of % (% x %%).", viewer.copy_source, done.width, done.height, scaled); + toast_show(.Success, "Copied % (% x %%)", viewer.copy_name, done.width, done.height, scaled); +} + viewer_grid :: () { using ImGui; style := GetStyle(); cell := viewer.thumb_size; diff --git a/src/ui/imgui_keyboard.jai b/src/ui/imgui_keyboard.jai index 3edf8f5..ef5376b 100644 --- a/src/ui/imgui_keyboard.jai +++ b/src/ui/imgui_keyboard.jai @@ -148,8 +148,9 @@ to_string_all_bindings :: (hotkey: Hotkey) -> string { // The bindings are read from settings.config, or from DEFAULT_HOTKEYS_DATA when the file has // no [hotkeys] section; see hotkeys_install. once and ignore_when_text_field_active belong to the // action and are set here. To add an action: declare its Hotkey here, add it to -// hotkey_actions_build and app_check_key_combinations, bind it in both assets/data/default_hotkeys -// files, and raise APP_USER_SETTINGS_VERSION so existing settings files get its default. +// hotkey_actions_build and app_check_key_combinations, and bind it in both +// assets/data/default_hotkeys files. Settings files at APP_USER_SETTINGS_VERSION get its default +// binding at the next start (hotkeys_missing_lines); raising the version would give them "none". program_exit_hotkey: Hotkey; minimize_to_tray_hotkey: Hotkey; toggle_debug_panel_hotkey: Hotkey; @@ -162,6 +163,10 @@ increase_font_size_hotkey: Hotkey; reset_window_hotkey: Hotkey; reset_layout_hotkey: Hotkey; +// The selected capture (app/viewer.jai). Not while typing, so Ctrl-C in the search box copies its text. +copy_image_hotkey: Hotkey = .{ignore_when_text_field_active = true}; +copy_path_hotkey: Hotkey = .{ignore_when_text_field_active = true}; + Hotkey_Action :: struct { name: string; // as written in [hotkeys] hotkey: *Hotkey; @@ -182,6 +187,8 @@ hotkey_actions_build :: (out: *[..] Hotkey_Action) { add(out, "increase_font_size", *increase_font_size_hotkey); add(out, "reset_window", *reset_window_hotkey); add(out, "reset_layout", *reset_layout_hotkey); + add(out, "copy_image", *copy_image_hotkey); + add(out, "copy_path", *copy_path_hotkey); } hotkey_actions: [..] Hotkey_Action; @@ -627,6 +634,9 @@ app_check_key_combinations :: () { else toast_show(.Error, "Could not reset the window"); } if check_key_combination(*reset_layout_hotkey) imgui_reset_layout_requested = true; // applied between frames + // The viewer is the shutdown thread's once shutdown has started. + if check_key_combination(*copy_image_hotkey) && !app_exit_shutdown_started() viewer_copy_image(); + if check_key_combination(*copy_path_hotkey) && !app_exit_shutdown_started() viewer_copy_path(); if should_exit_this_frame { log("Calling exit hotkey... exiting program!");