From c351ca92c0bc081182b3cfb6d7703ccb671f8c64 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Thu, 15 Oct 2026 17:49:03 -0400 Subject: [PATCH] [0.1.23] Export a capture's original to a folder: [export] folder lines, a dropdown or Custom... --- README.md | 5 +- check_targets.jai | 2 +- first.jai | 2 +- src/app/viewer.jai | 195 +++++++++++++++++++++++++++++--- src/test/logger_tests.jai | 13 +++ src/ui/imgui_theme_registry.jai | 30 ++++- 6 files changed, 228 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6cef45b..e5addc0 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,10 @@ What becomes of each file: The date is the phone's (Apple's creation date), else one in its name, else the video's own creation time, else the file's time; the phone and its location are kept too. The thumbnail shows a play mark and how long it runs; **Play in the default player** in the Capture window writes the - video out to the temporary folder and opens it. + video out to the temporary folder and opens it. **Export...** (any capture) writes the file as it + was imported to a folder it asks for: one of the `folder:` lines under `[export]` in the settings + file (the first is the default; none: the Downloads folder), or **Custom...**, which can be added to + them. A file of that name already there, and different, is not overwritten. - **Snagit `.snagx`:** the same `.mcap` as `snagit-converter mcap` makes, byte for byte, named as the `.snagx` is. - **Duplicates:** an image already in the library, in any of its folders, is not stored again; the diff --git a/check_targets.jai b/check_targets.jai index 814b49d..375e1c8 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.22"; +VERSION :: "0.1.23"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index 31e44c7..2f94aa8 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.22"; +VERSION :: "0.1.23"; // 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 44f518b..228dbeb 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -149,15 +149,24 @@ viewer: struct { full_fit := Viewer_Fit.WINDOW; full_zoom: float = 100; // percent of the size it was captured at; set by the fit, unless OFF - // A video or animation's original, written out to open in the default player (viewer_open_original). + // A capture's original, written out to open in the default player (viewer_open_original), or + // exported to a folder (viewer_export_original). open_thread: Thread; open_started: bool; open_busy: bool; // set by the main thread, cleared by the open thread when it ends open_source: string; // the .mcap; heap, main thread's until the thread starts open_name: string; + open_folder: string; // where to export it; "" to write it to the temporary folder and open it open_result: string; // the file written, or ""; heap, the open thread's until it ends open_error: string; + // The export being asked about (viewer_export_popup). + export_index: s32 = -1; // the capture to export, while the popup is up + export_choice: s64; // which of viewer_export_choices, or -1 for Custom... + export_buffer: [1024] u8; // the folder typed in for Custom... + export_add: bool; // Custom...: add the folder to [export] + export_make_default: bool; + // The selected capture's image, decoded off the main thread for the clipboard (viewer_copy_image). copy_thread: Thread; copy_started: bool; @@ -1135,6 +1144,7 @@ viewer_ui :: () { using ImGui; viewer_grid(); EndChild(); viewer_delete_popup(); + viewer_export_popup(); viewer_full_window(); } @@ -1197,17 +1207,28 @@ viewer_duration_text :: (seconds: float64) -> string { // Writes the capture's original (a video, an animated GIF) as it was imported to a file in the // temporary folder, on a thread of its own, as it may be large; viewer_open_frame opens it. viewer_open_original :: (index: s32) { + viewer_write_original(index, ""); +} + +// Writes the capture's original as it was imported into folder, on a thread of its own, as it may be +// large; viewer_open_frame says where it went. +viewer_export_original :: (index: s32, folder: string) { + viewer_write_original(index, folder); +} + +viewer_write_original :: (index: s32, folder: string) { if viewer.open_started return; capture := *viewer.captures[index]; push_allocator(context.default_allocator); - free(viewer.open_source); free(viewer.open_name); free(viewer.open_result); free(viewer.open_error); + free(viewer.open_source); free(viewer.open_name); free(viewer.open_folder); free(viewer.open_result); free(viewer.open_error); viewer.open_source = copy_string(capture.path); viewer.open_name = copy_string(capture.name); + viewer.open_folder = copy_string(folder); viewer.open_result = ""; viewer.open_error = ""; viewer.open_busy = true; - viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "open original"); - if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not open %", capture.name); return; } + viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "write original"); + if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not write % out", capture.name); return; } thread_start(*viewer.open_thread); } @@ -1217,14 +1238,28 @@ viewer_open_proc :: (thread: *Thread) -> s64 { m, original, ok, error := mcap_read_without_original(viewer.open_source,, temp); if !ok { viewer.open_error = copy_string(error); return 0; } - #if OS == .WINDOWS base := windows_expand_environment("%TEMP%"); - else base := "/tmp"; - folder := tprint("%/%", base, APP_FOLDER_NAME); + exporting := viewer.open_folder.count > 0; + folder := viewer.open_folder; + if !exporting { + #if OS == .WINDOWS base := windows_expand_environment("%TEMP%"); + else base := "/tmp"; + folder = tprint("%/%", base, APP_FOLDER_NAME); + } make_directory_if_it_does_not_exist(folder, recursive = true); + if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; } extension := path_extension(m.original_name); - path := tprint("%/%.%", folder, viewer.open_name, ifx extension then extension else "bin"); + if !extension extension = "bin"; + path := tprint("%/%.%", folder, viewer.open_name, extension); _, size, exists := file_modtime_and_size(path); - if !exists || size != original.size { // written out before, it is used again + // Written out before, it is used again. An export does not overwrite another file of that name. + if exporting && exists && size != original.size { + for 2..1000 { + path = tprint("%/% (%).%", folder, viewer.open_name, it, extension); + _, size, exists = file_modtime_and_size(path); + if !exists || size == original.size break; + } + } + if !exists || size != original.size { ok, error = mcap_extract_original(original, path); if !ok { viewer.open_error = copy_string(error); return 0; } } @@ -1232,14 +1267,141 @@ viewer_open_proc :: (thread: *Thread) -> s64 { return 0; } -// Each frame: opens what viewer_open_proc wrote, once it is done. +// Each frame: opens what viewer_open_proc wrote, or says where it was exported, once it is done. viewer_open_frame :: () { if !viewer.open_started || viewer.open_busy return; thread_is_done(*viewer.open_thread, -1); thread_deinit(*viewer.open_thread); viewer.open_started = false; - if viewer.open_result platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result)); - else toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error); + if !viewer.open_result toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error); + else if viewer.open_folder toast_show(.Success, "Exported % to %", viewer.open_name, viewer.open_result); + else platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result)); +} + +// The folders Export offers: the [export] folder lines in settings.config, else the Downloads folder. +viewer_export_choices :: () -> [] string { + if app_user_settings.export_folders return app_user_settings.export_folders; + #if OS == .WINDOWS downloads := tprint("%/Downloads", windows_expand_environment("%USERPROFILE%")); + else downloads := posix_expand_home("~/Downloads"); + choices := NewArray(1, string,, temp); + choices[0] = downloads; + return choices; +} + +// Asks where to export the capture's original, starting from the default folder. +viewer_export_ask :: (index: s32) { + viewer.export_index = index; + viewer.export_choice = 0; + viewer.export_add = false; + viewer.export_make_default = false; +} + +viewer_export_set_buffer :: (folder: string) { + count := min(folder.count, viewer.export_buffer.count - 1); + memcpy(viewer.export_buffer.data, folder.data, count); + viewer.export_buffer[count] = 0; +} + +// Puts folder in [export]: first when it is to be the default, else at the end if it is not there yet. +viewer_export_remember :: (folder: string, make_default: bool) { + folders := *app_user_settings.export_folders; + changed := false; + if !folders.count { + // The list was only the Downloads folder: it stays offered. + array_add(folders, copy_string(viewer_export_choices()[0],, context.default_allocator)); + changed = true; + } + at := -1; + for folders.* if it == folder { at = it_index; break; } + if make_default && at != 0 { + copy := copy_string(folder,, context.default_allocator); + if at > 0 { free(folders.*[at],, context.default_allocator); array_ordered_remove_by_index(folders, at); } + array_insert_at(folders, copy, 0); + changed = true; + } else if at < 0 { + array_add(folders, copy_string(folder,, context.default_allocator)); + changed = true; + } + if changed app_save_user_settings_file_and_toast("the export folders"); +} + +// The folder for viewer.export_index, as a modal popup. +viewer_export_popup :: () { using ImGui; + POPUP :: "Export capture"; + if viewer.export_index >= 0 && !IsPopupOpen(POPUP) OpenPopup(POPUP); + if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return; + index := viewer.export_index; + if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed { + viewer.export_index = -1; + CloseCurrentPopup(); + EndPopup(); + return; + } + capture := *viewer.captures[index]; + Text("Export %", capture.name); + TextColored(app_theme.palette.dim, "Writes the file as it was imported, unchanged, into the folder."); + Spacing(); + + choices := viewer_export_choices(); + if viewer.export_choice >= choices.count viewer.export_choice = 0; + custom := viewer.export_choice < 0; + SetNextItemWidth(GetFontSize() * 30); + if BeginCombo("##folders", temp_c_string(ifx custom then "Custom..." else choices[viewer.export_choice])) { + for choices { + if Selectable(temp_c_string(it), it_index == viewer.export_choice) viewer.export_choice = it_index; + if it_index == 0 && IsItemHovered() SetTooltip("The default"); + } + Separator(); + if Selectable("Custom...", custom) && !custom { + viewer.export_choice = -1; + viewer_export_set_buffer(choices[0]); // somewhere to start from + } + EndCombo(); + } + SameLine(); + Text("Folder"); + custom = viewer.export_choice < 0; + + folder: string; + if custom { + SetNextItemWidth(GetFontSize() * 30); + InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count); + #if OS == .WINDOWS { + SameLine(); + if Button("Browse...") { + chosen := windows_choose_folder("Choose a folder to export to"); + if chosen viewer_export_set_buffer(chosen); + } + } + folder = trim(to_string(viewer.export_buffer.data)); + Checkbox("Add it to the list", *viewer.export_add); + if IsItemHovered() SetTooltip("Adds a folder line under [export] in %", APP_USER_SETTINGS_FILE_NAME); + } else { + folder = choices[viewer.export_choice]; + } + if custom || viewer.export_choice > 0 { + Checkbox("Make it the default", *viewer.export_make_default); + if IsItemHovered() SetTooltip("Puts it first under [export] in %", APP_USER_SETTINGS_FILE_NAME); + } + Spacing(); + + // Enter and Escape are the open dropdown's while it is up. + keys := IsWindowFocused(); + if !folder BeginDisabled(); + if Button("Export") || (folder && keys && IsKeyPressed(.Enter)) { + viewer.export_index = -1; + CloseCurrentPopup(); + if (custom || viewer.export_choice > 0) && viewer.export_make_default viewer_export_remember(folder, true); + else if custom && viewer.export_add viewer_export_remember(folder, false); + viewer_export_original(index, folder); + } + if !folder EndDisabled(); + SameLine(); + if Button("Cancel") || (keys && IsKeyPressed(.Escape)) { + viewer.export_index = -1; + CloseCurrentPopup(); + } + EndPopup(); } // ---------------------------------------------------------------- copying the selected capture @@ -1413,6 +1575,13 @@ viewer_full_window :: () { using ImGui; TextColored(app_theme.palette.dim, "% x %", viewer.full_source_width, viewer.full_source_height); } if viewer.full_loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); } + SameLine(); + exporting := viewer.open_started; + if exporting BeginDisabled(); + if SmallButton("Export...") viewer_export_ask(viewer.full_index); + if exporting EndDisabled(); + if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported, unchanged, to a folder"); + if exporting && viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Exporting..."); } TextColored(app_theme.palette.dim, "Left / Right: previous / next"); // Zoom is a percentage of the size the capture was taken at. A fit sets it from the window, larger @@ -1445,7 +1614,7 @@ viewer_full_window :: () { using ImGui; if SmallButton(temp_c_string(ifx entry.media == .VIDEO then "Play in the default player" else "Open in the default viewer")) viewer_open_original(viewer.full_index); if busy EndDisabled(); if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported to the temporary folder, and opens it"); - if busy { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); } + if busy && !viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); } } ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai diff --git a/src/test/logger_tests.jai b/src/test/logger_tests.jai index 8266387..57f67e3 100644 --- a/src/test/logger_tests.jai +++ b/src/test/logger_tests.jai @@ -230,6 +230,19 @@ settings_tests :: () { expect(ok && !seen.event_log && settings.event_log_capacity == EVENT_LOG_CAPACITY_DEFAULT, "a file without the key gave seen % and capacity %", seen.event_log, settings.event_log_capacity); + { + group("[export] folder lines"); + data := tprint("@version %\n[export]\nfolder: D:\\Exports\nfolder:\nfolder: \" E:\\Shared #2\"\n", APP_USER_SETTINGS_VERSION); + settings: App_User_Settings; + ok, seen := settings_parse("logger_tests", data, *settings); + defer settings_free(*settings); + expect(ok && seen.export_folder && settings.export_folders.count == 2, "gave seen % and % folders", seen.export_folder, settings.export_folders.count); + if settings.export_folders.count == 2 { + expect(settings.export_folders[0] == "D:\\Exports" && settings.export_folders[1] == " E:\\Shared #2", + "gave % and %", settings.export_folders[0], settings.export_folders[1]); + } + } + // A section that does not mention an action gets its default installed right away, since the save // that writes it into the file does not reload it; a pre-upgrade file gets "none" instead. { diff --git a/src/ui/imgui_theme_registry.jai b/src/ui/imgui_theme_registry.jai index 7034ac5..71357c9 100644 --- a/src/ui/imgui_theme_registry.jai +++ b/src/ui/imgui_theme_registry.jai @@ -293,6 +293,9 @@ app_user_settings: App_User_Settings; App_User_Settings :: struct { theme: string; // "" means the default theme; heap-allocated ocr_model: string; // [ocr] model: the OCR model's name in models.config; "" means the default; heap-allocated + // [export] folder, one line each: the folders Export offers for a capture's original, the first + // the default. None means the Downloads folder. Heap-allocated. + export_folders: [..] string; // Of the monitor the window first opens on. window_width_fraction: float = 0.80; @@ -323,6 +326,8 @@ App_User_Settings :: struct { settings_free :: (s: *App_User_Settings) { free(s.theme,, context.default_allocator); free(s.ocr_model,, context.default_allocator); + for s.export_folders free(it,, context.default_allocator); + array_free(s.export_folders); free(s.video_capture,, context.default_allocator); array_free(s.hotkey_bindings); array_free(s.global_hotkey_bindings); @@ -382,11 +387,11 @@ app_load_user_settings_file :: () -> write_back: bool { if hotkeys_missing.count || global_missing.count return true; #if OS == .LINUX seen.global_hotkeys = true; // not written there: Linux has no global hotkeys yet - return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone); + return !(seen.theme && seen.width && seen.height && seen.font_size && seen.font_scale && seen.event_log && seen.hotkeys && seen.global_hotkeys && seen.ocr_model && seen.export_folder && seen.video_fps && seen.video_cursor && seen.video_capture && seen.video_system_audio && seen.video_microphone); } Settings_Seen :: struct { - theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model: bool; + theme, width, height, font_size, font_scale, event_log, hotkeys, global_hotkeys, ocr_model, export_folder: bool; video_fps, video_cursor, video_capture, video_system_audio, video_microphone: bool; } @@ -398,13 +403,14 @@ settings_file_hash: u64; Settings_App_Values :: struct { theme: string; ocr_model: string; + export_folders: string; // the list, a line each font_size_base: float; font_scale_main: float; } // The app values as of the last load or save. A save keeps what the file says for a setting that // has not changed in the app since, so an edit made by hand while the app runs is not overwritten. -settings_synced: Settings_App_Values; // theme heap-allocated +settings_synced: Settings_App_Values; // its strings heap-allocated settings_app_values :: () -> Settings_App_Values { values: Settings_App_Values; @@ -412,6 +418,7 @@ settings_app_values :: () -> Settings_App_Values { values.theme = ifx entry then entry.name else app_user_settings.theme; if !values.theme.count values.theme = APP_DEFAULT_THEME_NAME; values.ocr_model = ifx app_user_settings.ocr_model.count then app_user_settings.ocr_model else OCR_DEFAULT_MODEL; + values.export_folders = join(..app_user_settings.export_folders, "\n",, temp); values.font_size_base = app_user_settings.font_size_base; values.font_scale_main = app_user_settings.font_scale_main; @@ -428,9 +435,11 @@ settings_app_values :: () -> Settings_App_Values { settings_note_synced :: () { free(settings_synced.theme,, context.default_allocator); free(settings_synced.ocr_model,, context.default_allocator); + free(settings_synced.export_folders,, context.default_allocator); settings_synced = settings_app_values(); settings_synced.theme = copy_string(settings_synced.theme,, context.default_allocator); settings_synced.ocr_model = copy_string(settings_synced.ocr_model,, context.default_allocator); + settings_synced.export_folders = copy_string(settings_synced.export_folders,, context.default_allocator); } // Parses the file's contents into out, replacing its theme and hotkey bindings. ok is false when @@ -445,6 +454,9 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) -> array_reset(*out.hotkey_bindings); out.global_hotkey_bindings.allocator = context.default_allocator; array_reset(*out.global_hotkey_bindings); + for out.export_folders free(it,, context.default_allocator); + out.export_folders.allocator = context.default_allocator; + array_reset(*out.export_folders); seen_version := false; version: s64; @@ -506,6 +518,10 @@ settings_parse :: (path: string, file_data: string, out: *App_User_Settings) -> free(out.ocr_model,, context.default_allocator); out.ocr_model = copy_string(settings_unquote(value),, context.default_allocator); seen.ocr_model = true; + } else if section == "[export]" && key == "folder" { + folder := settings_unquote(value); // an empty line says the list is empty + if folder.count array_add(*out.export_folders, copy_string(folder,, context.default_allocator)); + seen.export_folder = true; } else if section == "[ui]" && key == "theme" { free(out.theme,, context.default_allocator); out.theme = copy_string(settings_unquote(value),, context.default_allocator); @@ -633,6 +649,7 @@ app_save_user_settings_file :: () -> bool { values := settings_app_values(); theme := values.theme; ocr_model := values.ocr_model; + export_folders: [] string = app_user_settings.export_folders; font_size_base := values.font_size_base; font_scale_main := values.font_scale_main; @@ -663,6 +680,7 @@ app_save_user_settings_file :: () -> bool { if disk_ok { if seen.theme && values.theme == settings_synced.theme && on_disk.theme.count theme = on_disk.theme; if seen.ocr_model && values.ocr_model == settings_synced.ocr_model && on_disk.ocr_model.count ocr_model = on_disk.ocr_model; + if seen.export_folder && values.export_folders == settings_synced.export_folders export_folders = on_disk.export_folders; if seen.font_size && values.font_size_base == settings_synced.font_size_base font_size_base = on_disk.font_size_base; if seen.font_scale && values.font_scale_main == settings_synced.font_scale_main font_scale_main = on_disk.font_scale_main; if seen.width app_user_settings.window_width_fraction = on_disk.window_width_fraction; @@ -695,6 +713,12 @@ app_save_user_settings_file :: () -> bool { append(*builder, "[ocr]\n"); append(*builder, "# The OCR model, by its name in ocr-models/models.config: ppocr-v5-mobile (light) or ppocr-v5-server (heavy).\n"); print_to_builder(*builder, "model: %\n\n", settings_quote_if_needed(ocr_model)); + append(*builder, "[export]\n"); + append(*builder, "# The folders Export (in the Capture window) offers to write a capture to as it was imported, a\n"); + append(*builder, "# folder: line each; the first is the default. None: the Downloads folder.\n"); + for export_folders print_to_builder(*builder, "folder: %\n", settings_quote_if_needed(it)); + if !export_folders append(*builder, "folder:\n"); + append(*builder, "\n"); append(*builder, "[video]\n"); append(*builder, "# Screen recording (Ctrl+PrintScreen; Windows). fps: frames a second at most, written only when the\n"); append(*builder, "# screen changes. cursor: true or false. capture: auto (Desktop Duplication, else\n");