From 03f23f7122a2a84f2e2f5715cc1745d1b8941e38 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Tue, 13 Oct 2026 07:36:52 +0100 Subject: [PATCH] [0.1.16] Ctrl-C copies the selected capture's image, Ctrl-Shift-C its full path The selected capture is the one outlined in the grid and shown in the Capture window. Its image is copied as the Capture window shows it, decoded on a thread of its own and put on the clipboard as screen captures are (Windows only so far); its path is copied as the system writes paths. Both are actions in [hotkeys] (copy_image, copy_path), which do not act while typing in the search box. Settings files at the current version get their default bindings at the next start; the comment saying to raise APP_USER_SETTINGS_VERSION for a new action had it backwards. --- README.md | 5 ++ assets/data/default_hotkeys.config | 4 ++ assets/data/default_hotkeys_macos.config | 4 ++ check_targets.jai | 2 +- first.jai | 2 +- src/app/viewer.jai | 81 ++++++++++++++++++++++++ src/ui/imgui_keyboard.jai | 14 +++- 7 files changed, 108 insertions(+), 4 deletions(-) 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!");