diff --git a/README.md b/README.md index 8964c99..5b0a15d 100644 --- a/README.md +++ b/README.md @@ -284,6 +284,32 @@ What becomes of each file: The files imported from are only read: nothing there is changed, moved or deleted. +## Opening a file + +`mcap.exe FILE`, with FILE first, opens one file: an `.mcap`, or any image, video or `.snagx` that +Import takes. This is what Explorer passes to the program a file type opens with (Open with > mcap). + +- **mcap not running:** it starts as usual and shows the file. + - **An `.mcap` in the library** is selected and opened in the Capture window. + - **Anything else** opens in the **Opened File** window, with its details. If the library has it + already, in any folder (found by its hash in `.import-index`), the window says where, with + **Show**. If not, **Import** brings it into the library folder typed or chosen from the menu + beside the name. That's the Import window's import, of this one file, so the same naming, + duplicate check and reading of text and labels apply. Once in, it opens in the Capture window. + - **An `.mcap` from outside the library** is copied in as it is, under its own name. (A folder's + import skips `.mcap` files.) +- **mcap already running:** a second window opens in **view mode**: the file alone, with Fit and + Zoom, and the same Import. + - **Show in mcap** (for a file the library has) and **Open in mcap** (for any other) hand the file to + the running mcap, which comes forward showing it, and close the view. + - **Ctrl-C** copies the image, and **Ctrl-Shift-C** the path. + - **Escape**, Ctrl-W or the close button ends it. + - **What's left out:** the library isn't loaded or watched, and there are no models, OCR, labels or + segmentation. There's no tray icon and no PrintScreen. It writes no settings or window layout. + - **After an import here:** the running mcap picks the capture up through the library watcher. OCR + and labels read it at the next scan, or right away with **Show in mcap** and **OCR** in the + Capture window. + ## Searching Type in the search box above the grid; the grid shows the matches as you type, with "N of M @@ -363,8 +389,10 @@ then in that order instead of by date. | Option | What it does | | --- | --- | +| `FILE` (first) | Opens FILE: an `.mcap`, image or video; in view mode when mcap is already running. See [Opening a file](#opening-a-file). | +| `-open-import DEST` | With FILE: imports it into the library folder DEST as its Import button does, then exits: 0 once the library has it (imported, or there already), else 1. | | `-library FOLDER` | Shows the `.mcap` files in FOLDER (and its folders) instead of `D:\McapIndex\mcap`. | -| `-import SOURCE DEST` | Imports the folder SOURCE into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed. | +| `-import SOURCE DEST` | Imports SOURCE, a folder or a single file, into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed. | | `-search QUERY` | Starts with QUERY in the search box. | | `-cold` | Loads the library as on a cold start: its files and the catalog cache are dropped from the system's file cache first (Windows and Linux; not macOS, which allows it only with `sudo purge`). The `[startup]` lines in the log time each step. With `-stats`, a startup benchmark: `bin\mcap.exe -cold -stats`. | | `-no-catalog-cache` | Reads every capture from its file, as without the catalog cache (for measuring); the cache is still written. | @@ -378,6 +406,7 @@ then in that order instead of by date. | `run_tests` | Runs the test suites (debug builds); with `-headless`, exits with 0 if all passed. | | `-headless` | No message boxes: a failure is logged and the app exits with 1. | | `-version` | Prints the version. | +| `-h`, `--help` | Opens the Command Line window, this table in the app (also in the Debug Panel). With mcap already running, that one comes forward with it. | `-import`, `-scan`, `-stats` and `-erase` still open the window, to show their progress; they are meant for scripts and for trying things on a copy of the library: `bin\mcap.exe -library D:\copy -scan`. diff --git a/check_targets.jai b/check_targets.jai index dce7ad4..e616f22 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.16"; +VERSION :: "0.1.17"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index 02ce52a..515f923 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.16"; +VERSION :: "0.1.17"; // 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.jai b/src/App.jai index c4cb546..cc07ce4 100644 --- a/src/App.jai +++ b/src/App.jai @@ -24,6 +24,7 @@ USING_WINDOWS :: OS == .WINDOWS; #load "app/background.jai"; #load "app/labels_ui.jai"; #load "app/import.jai"; +#load "app/open_file.jai"; #load "app/segment_ui.jai"; #load "app/library_folders.jai"; #load "app/exit_shutdown.jai"; @@ -186,6 +187,8 @@ App :: struct { show_logger_panel: bool = DEBUG; show_frame_time_graph: bool = DEBUG; show_style_editor: bool; + show_help_panel: bool; // the Command Line window (base/debug.jai): -h, --help, or the Debug Panel + help_panel_focus: bool; // bring it forward next frame minimize_to_tray_this_frame: bool; restore_window_this_frame: bool; capture_region_this_frame: bool; // the capture_region global hotkey fired: app/screen_capture.jai @@ -209,6 +212,9 @@ App :: struct { #if OS == .WINDOWS record_region: RECT; // -record-region X Y W H: -record records that, on the virtual desktop, not the whole screen segment_and_exit: bool; // -segment: segment the library folders set to segment once the catalog has loaded, then exit (0, or 1 if any image failed) import_source, import_dest: string; // -import SOURCE DEST: import SOURCE into the library folder DEST (app/import.jai), then exit + open_file: string; // mcap.exe FILE: the file to show (app/open_file.jai); absolute, with / between its folders + view_mode: bool; // FILE was given while another instance runs: only it is shown, with Import (app/open_file.jai) + open_import_dest: string; // -open-import DEST: FILE imported into the library folder DEST as its Import button does, then exit (0, or 1 if it failed) last_window_restore_time: Apollo_Time; imgui_context: *ImGui.ImGuiContext; @@ -329,6 +335,8 @@ APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE :: 2; // 1 byte for message-or-done, 1 b App_Shared_Memory_Tag :: enum u8 { Done :: 0; Bring_To_Front :: 1; + Open_File :: 2; // the path follows the header: show it (open_file_request) + Show_Help :: 3; // mcap --help while this one runs: come forward with the Command Line window } APP_SHARED_MEMORY_CHANNEL_BYTES :: 4096; // only one page? @@ -341,6 +349,12 @@ APP_WINDOW_TITLE :: #run -> string { git_info, append_debug); } +// The window's title, ending in a 0 for the OS: in view mode, the file's name first. +app_window_title :: () -> string { + if !app.view_mode return APP_WINDOW_TITLE; + return tprint("% - % (view)\0", path_filename(app.open_file), APP_NAME); +} + APP_TRAY_TOOLTIP_TEXT :: #run -> string { return ifx DEBUG tprint("% % (DEBUG)\0", APP_NAME, VERSION) diff --git a/src/app/app_ui.jai b/src/app/app_ui.jai index 106ade7..e9c596e 100644 --- a/src/app/app_ui.jai +++ b/src/app/app_ui.jai @@ -6,16 +6,25 @@ APP_MAIN_WINDOW_LABEL :: "Main"; // Once, after ImGui, the fonts, the theme and the settings are up and before the first frame. app_ui_startup :: () { + // View mode (app/open_file.jai): the file alone. The library is not listed, and no model, scan, + // watcher or recording starts. + if app.view_mode { + import_startup(); + open_file_startup(); + return; + } viewer_startup(); import_startup(); ocr_ui_startup(); labels_ui_startup(); segment_ui_startup(); // the library folders' options: after the viewer, which knows the library screen_record_startup(); // recordings a crash left behind, to offer + open_file_startup(); // mcap.exe FILE: after the viewer, which knows whether it is in the library } // Every frame, before input is handed to ImGui. Poll background work here. app_ui_frame_start :: () { + if app.view_mode return; screen_capture_frame(); // also while hidden in the tray, where the frame ends before the UI ocr_ui_unload_idle_models(); // models unused for a while, also while in the tray } @@ -24,6 +33,12 @@ app_ui_frame_start :: () { // stop background work here. Must not touch ImGui, since the main thread is still drawing frames // (the overlay; app_main_window_ui stops running the app's frames once shutdown starts). app_ui_shutdown :: () { + if app.view_mode { + app_shutdown_step(.IMPORT); + import_shutdown(); + open_file_shutdown(); + return; + } app_shutdown_step(.CAPTURE); screen_capture_shutdown(); app_shutdown_step(.IMPORT); @@ -38,6 +53,7 @@ app_ui_shutdown :: () { labels_ui_shutdown(); // after the OCR thread and the background workers have stopped app_shutdown_step(.STATS); stats_ui_shutdown(); + open_file_shutdown(); } // Which step of app_ui_shutdown is running, and since when, for the overlay a slow shutdown shows. @@ -91,6 +107,10 @@ app_ui_shutdown_status :: () -> string { } app_main_window_ui :: () { + if app.view_mode { + if !app_exit_shutdown_started() open_file_view_mode_ui(); // the whole window (app/open_file.jai) + return; + } push_imgui_window(APP_MAIN_WINDOW_LABEL); // Once shutdown has started, its thread is stopping the threads these frames hand work to and take // it from (the viewer's workers, the search group, the import, OCR), and freeing what they share: @@ -102,5 +122,6 @@ app_main_window_ui :: () { segment_ui_frame(); stats_ui_frame(); import_frame(); + open_file_frame(); // after import_frame, which hands the grid what an import of it wrote screen_record_leftovers_popup(); } diff --git a/src/app/import.jai b/src/app/import.jai index 07b5d2e..42d6951 100644 --- a/src/app/import.jai +++ b/src/app/import.jai @@ -16,6 +16,9 @@ // folder) holds a hash of every .mcap's original image, made once for the files already there and kept // up to date after that; an image whose hash is in it is reported as a duplicate, with where it is. // +// The source may also be a single file (app/open_file.jai, or -import FILE DEST): it alone is imported, +// and an .mcap, which a folder's import skips, is copied in as it is. +// // The import runs on a thread of its own, which lists the files, brings the index up to date, and then // imports with IMPORT_WORKERS threads. New captures go into the grid as they are written; when it ends, // the OCR scan reads and labels them, if asked to. @@ -60,6 +63,7 @@ importer: struct { // Set by the main thread before the thread starts; read-only while it runs. source: string; + single_file: bool; // source is a file, not a folder dest_name: string; dest_folder: string; library: string; @@ -137,13 +141,15 @@ import_start :: (source: string, dest_name: string) -> bool { importer.library = copy_string(viewer_folder()); importer.dest_folder = sprint("%/%", importer.library, dest); importer.error = ""; + importer.single_file = !is_directory(source_path); // The library is never imported into itself. lower_source := to_lower_copy(importer.source,, temp); lower_library := to_lower_copy(importer.library,, temp); path_overwrite_separators(lower_library, #char "/"); if lower_source == lower_library || begins_with(lower_source, tprint("%/", lower_library)) || begins_with(lower_library, tprint("%/", lower_source)) { - toast_show(.Error, "The folder to import holds the library, or is inside it"); + if importer.single_file toast_show(.Error, "% is in the library already", path_filename(importer.source)); + else toast_show(.Error, "The folder to import holds the library, or is inside it"); return false; } @@ -166,8 +172,11 @@ import_start :: (source: string, dest_name: string) -> bool { importer.start = current_time_monotonic(); importer.scan_pending = false; - table_set(*importer.sources, copy_string(dest), copy_string(importer.source)); - import_sources_save(); + // Where a library folder's captures come from: a folder, not each file opened on its own. + if !importer.single_file { + table_set(*importer.sources, copy_string(dest), copy_string(importer.source)); + import_sources_save(); + } importer.running = true; if importer.thread_started thread_deinit(*importer.thread); @@ -197,7 +206,11 @@ import_thread_proc :: (thread: *Thread) -> s64 { // The files to import, sorted so a second run goes through them in the same order. Found :: struct { files: *[..] string; } found := Found.{*importer.files}; - visited := visit_files(importer.source, true, *found, (info: *File_Visit_Info, found: *Found) { + visited := true; + if importer.single_file { + array_add(*importer.files, copy_string(importer.source)); + importer.listed = 1; + } else visited = visit_files(importer.source, true, *found, (info: *File_Visit_Info, found: *Found) { if info.is_directory { name := info.short_name; if name.count && (name[0] == #char "." || name[0] == #char "$" || name == "System Volume Information") info.descend_into_directory = false; @@ -295,6 +308,10 @@ import_worker :: (thread: *Thread) -> s64 { import_file :: (path: string) -> Import_Outcome, text: string, new_path: string { name := path_filename(path); extension := to_lower_copy(path_extension(name),, temp); + if extension == MCAP_EXTENSION && importer.single_file { + outcome, text, new_path := import_mcap_file(path, name); + return outcome, text, new_path; + } if array_find(IMPORT_OTHER_EXTENSIONS, extension) return .SKIPPED, extension, ""; modified, size, stat_ok := file_modtime_and_size(path); @@ -378,6 +395,28 @@ import_video_file :: (path: string, name: string, head: [] u8, size: s64, modifi return .IMPORTED, "", new_path; } +// An .mcap imported on its own: copied into the library folder as it is, under its own name, a few MB +// at a time (it may hold a video). A duplicate when the library has its original. +import_mcap_file :: (path: string, name: string) -> Import_Outcome, text: string, new_path: string { + _, original, parsed, error := mcap_read_without_original(path,, temp); + if !parsed return .FAILED, error, ""; + hash, hashed := import_hash_file(original); + if !hashed return .FAILED, "its original cannot be read", ""; + claimed, where := import_claim(hash); + if !claimed return .DUPLICATE, where, ""; + let_go := true; + defer if let_go import_let_go(hash); + + _, size, stat_ok := file_modtime_and_size(path); + if !stat_ok return .FAILED, "cannot be read", ""; + out_path := import_reserve_path(name); + written, write_error := mcap_extract_original(.{path = path, offset = 0, size = size}, out_path); + new_path := import_written(out_path, hash, written); + if !written return .FAILED, write_error, ""; + let_go = false; + return .IMPORTED, "", new_path; +} + // Holds hash for a file being imported, so no other worker imports the same bytes; false, with where // they already are, when the library has them, or another file of this import does. import_claim :: (hash: u64) -> claimed: bool, where: string { @@ -853,6 +892,20 @@ import_index_worker :: (thread: *Thread) -> s64 { return 0; } +// Where the library holds the original whose hash is given, by the index as last saved in library: a +// path inside it ("Phone/2024-05-12_10-15-30_1a2b3c4d.mcap"), or "" for nowhere. Any thread; temp. +import_index_find :: (library: string, hash: u64) -> string { + data, ok := read_entire_file(tprint("%/%", library, IMPORT_INDEX_NAME), log_errors = false,, temp); + if !ok return ""; + wanted := import_hash_text(hash); + for split(data, "\n",, temp) { + if !begins_with(it, wanted) continue; + parts := split(trim_right(it, "\r"), "\t",, temp); + if parts.count == 4 && parts[0] == wanted && file_exists(tprint("%/%", library, parts[3])) return parts[3]; + } + return ""; +} + import_record_set :: (relative: string, record: Index_Record) { pointer := table_find_pointer(*importer.records, relative); if pointer { pointer.* = record; return; } diff --git a/src/app/main.jai b/src/app/main.jai index 995cf3b..2fbd4d2 100644 --- a/src/app/main.jai +++ b/src/app/main.jai @@ -38,6 +38,12 @@ main :: () { for app.args if it == "-search" && it_index + 1 < app.args.count app.start_search = app.args[it_index + 1]; for app.args if it == "-erase" && it_index + 1 < app.args.count app.erase_and_exit = app.args[it_index + 1]; for app.args if it == "-import" && it_index + 2 < app.args.count { app.import_source = app.args[it_index + 1]; app.import_dest = app.args[it_index + 2]; } + // mcap.exe FILE, as Explorer's Open with passes it: an .mcap, or an image or video (app/open_file.jai). + if app.args.count > 1 && app.args[1].count && app.args[1][0] != #char "-" && app.args[1] != "run_tests" { + app.open_file = open_file_normalize(app.args[1]); + } + for app.args if it == "-open-import" && it_index + 1 < app.args.count app.open_import_dest = app.args[it_index + 1]; + for app.args if it == "-h" || it == "--help" || it == "-help" { app.show_help_panel = true; app.help_panel_focus = true; } // the Command Line window (base/debug.jai) defer report_memory_leaks(); @@ -105,7 +111,7 @@ main :: () { another_instance_owns_channel := false; #if USING_SDL another_instance_owns_channel = app_instance_running; - if !another_instance_owns_channel && app_bring_running_instance_to_front(channel_key, attempts=1) return; + if !another_instance_owns_channel && app_running_instance_reachable(channel_key) another_instance_owns_channel = true; if !another_instance_owns_channel { read_channel_success, read_channel, already_exists := @@ -121,7 +127,19 @@ main :: () { } } - if another_instance_owns_channel { + if another_instance_owns_channel && app.open_file { + // A file opened while mcap runs: shown here, on its own, in view mode (app/open_file.jai). + app.view_mode = true; + app.show_debug_panel = false; + app.show_logger_panel = false; + app.show_frame_time_graph = false; + } else if another_instance_owns_channel && app.show_help_panel { + // mcap --help while mcap runs: that one comes forward with the Command Line window. + if !app_send_to_running_instance(channel_key, 20, .Show_Help) { + log_error("Another instance is running, but its shared memory channel could not be reached (it may be running as administrator); exiting."); + } + return; + } else if another_instance_owns_channel { if !app_bring_running_instance_to_front(channel_key, attempts=20) { log_error("Another instance is running, but its shared memory channel could not be reached (it may be running as administrator); exiting."); } @@ -142,6 +160,7 @@ main :: () { log(" - GIT_HASH: %", GIT_HASH); log(" - COMPILE_ARGS: %", COMPILE_ARGS); log(" - COMMAND_LINE_ARGUMENTS: %", args_combined); + if app.view_mode log(" - VIEW MODE: another instance is running; showing % on its own.", app.open_file); log(" --- \\STARTUP INFO/ --- "); app.startup_time = current_time_monotonic(); @@ -172,7 +191,7 @@ main :: () { app.username = copy_string(get_username()); log("Current PC name: %, username: %", app.machine_name, app.username); - settings_write_back := app_load_user_settings_file(); + settings_write_back := app_load_user_settings_file() && !app.view_mode; // the running instance owns the file logger_set_event_log_capacity(app_user_settings.event_log_capacity); // #Startup:CreateWindow @@ -196,7 +215,7 @@ main :: () { window_flags : SDL_WindowFlags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIDDEN | SDL_WINDOW_HIGH_PIXEL_DENSITY; - platform.window = SDL_CreateWindow(APP_WINDOW_TITLE, w, h, window_flags); + platform.window = SDL_CreateWindow(app_window_title().data, w, h, window_flags); ok_or_exit(platform.window != null, "failed to create window with error = %", consume_sdl_error()); centered := SDL_WINDOWPOS_CENTERED_DISPLAY(display); @@ -241,7 +260,7 @@ main :: () { hwnd = CreateWindowExW(WS_EX_APPWINDOW, utf8_to_wide(lpszClassName,,temp), - utf8_to_wide(APP_WINDOW_TITLE,,temp), + utf8_to_wide(app_window_title(),,temp), WS_OVERLAPPEDWINDOW, window_x, window_y, win_width, win_height, null, null, null, null); @@ -260,12 +279,13 @@ main :: () { } } #if USING_WINDOWS { // :Win32_Initialize_Extended - Win32_Set_Tray_Icon(); - Win32_Hide_Tray_Icon(); - defer Win32_Hide_Tray_Icon(); // exiting from the tray would otherwise leave the icon behind + // View mode has no tray: closing its window ends it. + if !app.view_mode { Win32_Set_Tray_Icon(); Win32_Hide_Tray_Icon(); } + defer if !app.view_mode Win32_Hide_Tray_Icon(); // exiting from the tray would otherwise leave the icon behind } // [global_hotkeys] was read with the settings, before there was a window to register them for. - global_hotkeys_apply(); + // The running instance has them; view mode leaves them alone. + if !app.view_mode global_hotkeys_apply(); defer platform_unregister_global_hotkeys(); // #Startup:Graphics #if USING_SDL { @@ -406,6 +426,15 @@ app_frame :: (in_live_resize := false) -> bool { command := cast(App_Shared_Memory_Tag)message.user_read_pointer[0]; if command == .Bring_To_Front { app.restore_window_this_frame = true; + } else if command == .Show_Help { + app.restore_window_this_frame = true; + app.show_help_panel = true; + app.help_panel_focus = true; + } else if command == .Open_File && message.user_read_bytes > APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE { + // Open in mcap, from a copy in view mode (app/open_file.jai): the path follows the header. + path := string.{message.user_read_bytes - APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE, message.user_read_pointer + APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE}; + app.restore_window_this_frame = true; + if !app_exit_shutdown_started() open_file_request(path); } Shared_Channel.reader_done_with_message(*message); } @@ -434,13 +463,14 @@ app_frame :: (in_live_resize := false) -> bool { if ev.type.(SDL_EventType) == { case SDL_EventType.SDL_EVENT_WINDOW_MINIMIZED; - app.minimize_to_tray_this_frame = true; + if !app.view_mode app.minimize_to_tray_this_frame = true; case SDL_EventType.QUIT; app.should_exit = true; case SDL_EventType.WINDOW_CLOSE_REQUESTED; - app.minimize_to_tray_this_frame = true; + if app.view_mode app.should_exit = true; // view mode has no tray + else app.minimize_to_tray_this_frame = true; case; } @@ -548,10 +578,11 @@ app_frame :: (in_live_resize := false) -> bool { { // #UI Start - ImGui.DockSpaceOverViewport(); + if !app.view_mode ImGui.DockSpaceOverViewport(); // view mode is one window filling the main one app_debug_panel_ui(); app_frame_times_ui(); + app_help_panel_ui(); app_style_editor_ui(); app_logger_panel_ui(); @@ -700,8 +731,21 @@ app_shared_memory_channel_key :: () -> string { } } +// Whether a running instance's channel can be connected to; nothing is sent. +app_running_instance_reachable :: (channel_key: string) -> bool { + connected, channel := Shared_Channel.writer_connect(channel_key, name_not_found_is_quiet=true); + if connected Shared_Channel.writer_disconnect(*channel); + return connected; +} + // Returns true once connected to a running instance, whether or not the message got through. app_bring_running_instance_to_front :: (channel_key: string, attempts: int) -> bool { + return app_send_to_running_instance(channel_key, attempts, .Bring_To_Front); +} + +// Sends tag, and payload after the header, to the running instance. True once connected to it, +// whether or not the message got through. +app_send_to_running_instance :: (channel_key: string, attempts: int, tag: App_Shared_Memory_Tag, payload := "") -> bool { connected := false; for 1..attempts { connected, app.write_channel = Shared_Channel.writer_connect(channel_key, name_not_found_is_quiet=true); @@ -712,18 +756,19 @@ app_bring_running_instance_to_front :: (channel_key: string, attempts: int) -> b defer Shared_Channel.writer_disconnect(*app.write_channel); // Reserving fails when the queue is full, e.g. the running instance has stopped polling. - message_success, message := Shared_Channel.writer_reserve_message(*app.write_channel, APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE); + message_success, message := Shared_Channel.writer_reserve_message(*app.write_channel, APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE + payload.count); if !message_success { - log_error("Another instance is running, but no space could be reserved in its message queue; it was not brought to the front."); + log_error("Another instance is running, but no space could be reserved in its message queue; % was not sent.", tag); return true; } - message.user_write_pointer[0] = cast(u8)App_Shared_Memory_Tag.Bring_To_Front; + message.user_write_pointer[0] = cast(u8) tag; message.user_write_pointer[1] = 0; + if payload.count memcpy(message.user_write_pointer + APP_SHARED_MEMORY_CHANNEL_HEADER_SIZE, payload.data, payload.count); #if USING_WINDOWS AllowSetForegroundWindow(ASFW_ANY); if !Shared_Channel.writer_commit_message(*app.write_channel, message) { - log_error("Another instance is running, but the bring-to-front message could not be committed."); + log_error("Another instance is running, but the % message could not be committed.", tag); } return true; } diff --git a/src/app/open_file.jai b/src/app/open_file.jai new file mode 100644 index 0000000..f8b07ef --- /dev/null +++ b/src/app/open_file.jai @@ -0,0 +1,593 @@ +// A file named on the command line, as Explorer's Open with passes it (mcap.exe FILE): an .mcap, or +// an image, video or Snagit .snagx such as the Import window takes. +// +// With no other copy of mcap running, the app starts as usual and shows the file: an .mcap in the +// library is selected and opened in the Capture window; anything else opens in the Opened File +// window, which says whether the library has it already and, if not, imports it into the library +// folder chosen (app/import.jai, with the file as its source). Once in, it opens in the Capture window. +// +// With mcap already running, this copy starts in view mode (app.view_mode): the file alone, filling +// the window, with the same Import, and Open in mcap, which hands the file to the running copy +// (App_Shared_Memory_Tag.Open_File, open_file_request) and closes this one. Nothing else starts: the +// library is not listed or watched, no model loads, and there is no tray icon, no global hotkey, and +// no settings or window layout written. Closing the window, Escape or Ctrl-W ends it. + +OPEN_FILE_WINDOW :: "Opened File"; + +Open_File_Kind :: enum u8 { UNKNOWN; MCAP; SNAGX; IMAGE; VIDEO; } + +Open_File_Stage :: enum s32 { + IDLE; // nothing opened, or it went to the Capture window + DECODING; // the thread is reading the image + DECODED; // the image is the main thread's; the thread is looking for the file in the library + DONE; // the thread has ended +} + +open_file: struct { + path: string; // heap; absolute, with / between its folders + kind: Open_File_Kind; + size: s64; + library_path: string; // heap: the capture in the library this file is (an .mcap in it); else "" + window_open: bool; // the Opened File window; in view mode the file is the whole window + focus: bool; // bring the window forward next frame + + // Read on a thread of its own: a camera RAW, or a video's frame, takes a while. + thread: Thread; + thread_started: bool; + stage: Open_File_Stage; // written by the thread + decoded: Decoded; // the thread's until DECODED; then the main thread's, kept for Ctrl-C + details: string; // heap, as decoded: "JPEG, 4032 x 3024", "Video, 0:42, h264" + error: string; // heap, as decoded: why it cannot be shown + match: string; // heap, the thread's until DONE: where the library has it already, inside it + uploaded: bool; // texture made from decoded, or tried + texture: Gpu_Texture; + texture_generation: u64; + fit := Viewer_Fit.WINDOW; + zoom: float = 100; + + // Importing it (app/import.jai). + folders: [..] string; // the library's folders, for the menu beside the name (heap) + dest_buffer: [256] u8; + importing: bool; // the importer's run is this file's + result: string; // heap: what the import did + result_ok: bool; + imported_path: string; // heap: where the library has it after the import (or had it already) + flag_started: bool; // -open-import has pressed Import +} + +// ---------------------------------------------------------------- startup and shutdown + +// A command-line argument as a path: absolute, with / between its folders (heap); "" for a folder. +open_file_normalize :: (argument: string) -> string { + path := trim(argument); + absolute, ok := get_absolute_path(path,, temp); + if ok path = absolute; + if is_directory(path) return ""; + result := copy_string(path,, context.default_allocator); + path_overwrite_separators(result, #char "/"); + return result; +} + +open_file_startup :: () { + open_file.folders.allocator = context.default_allocator; + // No OCR in view mode: the running copy reads it, once shown there and read. + if app.view_mode importer.read_after = false; + if app.open_file open_file_set(app.open_file); +} + +// On the shutdown thread: the frames have stopped running the UI. +open_file_shutdown :: () { + if open_file.thread_started thread_is_done(*open_file.thread, -1); +} + +// From the running copy's channel (main.jai): a copy in view mode handed it path. +open_file_request :: (path: string) { + if open_file.importing { toast_show(.Error, "Still importing %", path_filename(open_file.path)); return; } + log("[open] % was handed over by a copy in view mode.", path); + open_file_set(path); +} + +// ---------------------------------------------------------------- opening + +// Shows path: in the Capture window when it is a capture in the library (and the library is shown), +// else in the Opened File window, or in view mode the whole window, read on a thread. +open_file_set :: (path: string) { + open_file_stop(); + push_allocator(context.default_allocator); + free(open_file.path); free(open_file.library_path); free(open_file.result); free(open_file.imported_path); + open_file.path = copy_string(path); + open_file.library_path = copy_string(open_file_in_library(path)); + open_file.kind = open_file_kind(path); + open_file.result = ""; + open_file.result_ok = false; + open_file.imported_path = ""; + open_file.fit = .WINDOW; + _, size, _ := file_modtime_and_size(path); + open_file.size = size; + log("[open] Opened % (%, % bytes)%.", path, open_file.kind, size, ifx open_file.library_path then ", in the library" else ""); + + if !app.view_mode && open_file.library_path && open_file_show_in_library(open_file.library_path) return; + + open_file.window_open = !app.view_mode; + open_file.focus = true; + // The library folder it would go into: the one its folder went into before, else its folder's name. + dest := import_dest_for(path_strip_filename(path)); + count := min(dest.count, open_file.dest_buffer.count - 1); + memcpy(open_file.dest_buffer.data, dest.data, count); + open_file.dest_buffer[count] = 0; + open_file_list_folders(); + + open_file.stage = .DECODING; + open_file.thread_started = thread_init(*open_file.thread, open_file_proc, temporary_storage_size = 16 * 1024 * 1024, name = "open file"); + if !open_file.thread_started { + open_file.error = copy_string("the thread to read it could not start"); + open_file.stage = .DONE; + return; + } + thread_start(*open_file.thread); +} + +// Lets go of the file shown: its thread (waited for), texture and pixels. +open_file_stop :: () { + if open_file.thread_started { + thread_is_done(*open_file.thread, -1); + thread_deinit(*open_file.thread); + open_file.thread_started = false; + } + gpu_texture_release(*open_file.texture); + viewer_free_pixels(*open_file.decoded); + open_file.decoded = .{}; + push_allocator(context.default_allocator); + free(open_file.details); free(open_file.error); free(open_file.match); + open_file.details, open_file.error, open_file.match = "", "", ""; + open_file.uploaded = false; + open_file.stage = .IDLE; +} + +// The capture at path in the Capture window, added to the grid if it is not there yet. Full mode only. +open_file_show_in_library :: (path: string) -> bool { + index := viewer_capture_find(path); + if index < 0 && viewer_is_library_file(path) && file_exists(path) index = viewer_add_capture(path); + if index < 0 return false; + viewer_request_full(index); + open_file.window_open = false; + return true; +} + +// path as the library names it (its folder as viewer_folder() writes it) when it is a capture in the +// library; else "". Temp. +open_file_in_library :: (path: string) -> string { + folder := viewer_folder(); + #if OS == .WINDOWS inside := begins_with_nocase(path, folder); // D:\ and d:\ alike + else inside := begins_with(path, folder); + if !inside || path.count <= folder.count + 1 || path[folder.count] != #char "/" return ""; + in_library := tprint("%/%", folder, advance(path, folder.count + 1)); + return ifx viewer_is_library_file(in_library) then in_library else ""; +} + +open_file_kind :: (path: string) -> Open_File_Kind { + name := path_filename(path); + extension := to_lower_copy(path_extension(name),, temp); + if extension == MCAP_EXTENSION return .MCAP; + head := import_read_head(path, IMPORT_HEAD_SIZE); + if extension == "snagx" && head.count >= 4 && memcmp(head.data, "PK\x03\x04".data, 4) == 0 return .SNAGX; + kind := image_kind_from_name(image_kind(head), name); + if kind == .VIDEO || (kind == .UNKNOWN && array_find(IMPORT_VIDEO_EXTENSIONS, extension)) return .VIDEO; + if kind == .UNKNOWN return .UNKNOWN; + return .IMAGE; +} + +// The library's folders, for the menu of where to import into. +open_file_list_folders :: () { + for open_file.folders free(it,, context.default_allocator); + array_reset_keeping_memory(*open_file.folders); + visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) { + if !info.is_directory || !import_name_is_valid(info.short_name) return; // not hidden ones (.thumbnails) + #if OS != .WINDOWS if info.short_name == "deleted" return; // where deleted captures go there + array_add(folders, copy_string(info.short_name,, context.default_allocator)); + }, visit_files = false, visit_directories = true); + quick_sort(open_file.folders, compare); +} + +// The thread: the image first, shown as soon as it is there, then whether the library has the file, +// by its hash in the library index (which, for a video, means reading it all). +open_file_proc :: (thread: *Thread) -> s64 { + context.allocator = context.default_allocator; + defer atomic_swap(*open_file.stage, Open_File_Stage.DONE); + push_allocator(temp); + path := open_file.path; // left alone by the main thread while this runs + + decoded: Decoded; + decoded.failed = true; + details, error: string; + hash: u64; + hashed: bool; + to_hash: Mcap_Original_Ref; // hashed once the image is shown, when it was not hashed already + + if open_file.kind == { + case .MCAP; + m, original, ok, read_error := mcap_read_without_original(path); + if !ok { error = read_error; } + else { + image := m.rendered; // annotations and all, or a video's frame + if m.rendered_kind != .OWN { + bytes, read_ok := mcap_read_original(original); + if read_ok image = bytes; + else error = "its image cannot be read"; + } + if image.count decoded = viewer_decode_image(path, image); + details = ifx m.original_name then tprint("Capture of %", m.original_name) else "Capture"; + if !decoded.failed details = tprint("%, % x %", details, decoded.source_width, decoded.source_height); + to_hash = original; + } + + case .SNAGX; + data, ok := read_entire_file(path, log_errors = false); + if !ok { error = "it cannot be read"; } + else { + m, _, converted, convert_error := snagx_to_mcap(data); + if !converted { error = convert_error; } + else { + decoded = viewer_decode_image(path, ifx m.rendered_kind == .OWN then m.rendered else m.original); + details = "Snagit capture"; + hash, hashed = import_hash(m.original), true; + } + } + + case .IMAGE; + if open_file.size > IMPORT_MAX_FILE { error = tprint("it is larger than the % MB mcap reads", IMPORT_MAX_FILE / (1024 * 1024)); } + else { + data, ok := read_entire_file(path, log_errors = false); + if !ok { error = "it cannot be read"; } + else { + bytes := cast([] u8) data; + kind := image_kind_from_name(image_kind(bytes), path_filename(path)); + decoded = viewer_decode_image(path, bytes, kind); + details = image_kind_name(kind); + if kind == .GIF { + frames, duration_ms := gif_info(bytes); + if frames > 1 details = tprint("Animated GIF, % frames, %", frames, viewer_duration_text(duration_ms / 1000.0)); + } + if !decoded.failed details = tprint("%, % x %", details, decoded.source_width, decoded.source_height); + hash, hashed = import_hash(bytes), true; + } + } + + case .VIDEO; + #if OS == .WINDOWS { + image, video, ok, poster_error := video_poster(path); + if !ok { error = poster_error; } + else { + decoded = viewer_decoded_from_image(path, image); + details = tprint("Video, %%%, % x %", viewer_duration_text(video.duration), ifx video.codec then tprint(", %", video.codec) else "", + ifx video.frame_rate > 0 then tprint(", % fps", FF(video.frame_rate, ifx video.frame_rate == floor(video.frame_rate) then 0 else 2)) else "", + decoded.source_width, decoded.source_height); + } + to_hash = .{path = path, offset = 0, size = open_file.size}; + } else { + error = "videos are read on Windows only"; + } + + case; + error = "it is not an image, a video or a capture"; + } + if !error && decoded.failed error = "it cannot be decoded (see the Event Log)"; + + open_file.decoded = decoded; + open_file.details = copy_string(details,, context.default_allocator); + open_file.error = copy_string(error,, context.default_allocator); + atomic_swap(*open_file.stage, Open_File_Stage.DECODED); + reset_temporary_storage(); + + if !hashed && to_hash.path hash, hashed = import_hash_file(to_hash); + if hashed open_file.match = copy_string(import_index_find(viewer_folder(), hash),, context.default_allocator); + return 0; +} + +// ---------------------------------------------------------------- importing + +open_file_import :: (dest: string) { + if !import_start(open_file.path, dest) return; // it says why + open_file.importing = true; + free(open_file.result); free(open_file.imported_path); + open_file.result, open_file.imported_path = "", ""; +} + +// Once the import of the file has ended: what it did, and in full mode the capture in the Capture window. +open_file_import_poll :: () { + if !open_file.importing || importer.running return; + if app.view_mode { + // No import_frame here: the thread is this file's to take back, and its paths, which no grid wants. + if importer.thread_started { + thread_is_done(*importer.thread, -1); + thread_deinit(*importer.thread); + importer.thread_started = false; + } + } else if importer.thread_started { + return; // import_frame takes it back first, and puts what it wrote in the grid + } + open_file.importing = false; + + new_path, duplicate, failure: string; + lock(*importer.mutex); + if app.view_mode { + if importer.new_paths.count new_path = copy_string(importer.new_paths[0],, temp); + for importer.new_paths free(it,, context.default_allocator); + array_reset_keeping_memory(*importer.new_paths); + } + if importer.duplicate_notes.count duplicate = copy_string(importer.duplicate_notes[0].text,, temp); + if importer.failures.count failure = copy_string(importer.failures[0].text,, temp); + unlock(*importer.mutex); + if !app.view_mode && importer.new_indices.count new_path = viewer.captures[importer.new_indices[0]].path; + + result: string; + ok := false; + where := new_path; + if importer.error result = importer.error; + else if new_path { result = tprint("Imported into %.", importer.dest_name); ok = true; } + else if duplicate { result = tprint("Already in the library: %", duplicate); ok = true; where = tprint("%/%", viewer_folder(), duplicate); } + else if failure result = tprint("Could not import it: %", failure); + else if importer.cancel result = "The import was cancelled."; + else result = "mcap does not import this kind of file."; + log("[open] Import of % into %: %", open_file.path, importer.dest_name, result); + + push_allocator(context.default_allocator); + open_file.result = copy_string(result); + open_file.result_ok = ok; + open_file.imported_path = copy_string(where); + if ok open_file_list_folders(); // it may have made one + if ok && !app.view_mode open_file_show_in_library(where); +} + +// View mode: path handed to the running copy, which shows it; this copy then closes. +open_file_send_to_running :: (path: string) { + if path.count > APP_SHARED_MEMORY_CHANNEL_BYTES / 2 { toast_show(.Error, "The path is too long to hand over"); return; } + key := app_shared_memory_channel_key(); + defer free(key); + if app_send_to_running_instance(key, 5, .Open_File, path) app.should_exit = true; + else toast_show(.Error, "mcap is no longer running"); +} + +// ---------------------------------------------------------------- copying (view mode's Ctrl-C and Ctrl-Shift-C) + +open_file_copy_path :: () { + if !open_file.path return; + path := open_file.path; + #if OS == .WINDOWS path = replace(path, "/", "\\",, temp); + clipboard_set_text(path); + toast_show(.Success, "Copied the path of %", path_filename(open_file.path)); +} + +open_file_copy_image :: () { + #if OS != .WINDOWS { + toast_show(.Error, "Copying images is not available on this system yet"); + return; + } + d := *open_file.decoded; + if !open_file.uploaded || d.failed || !d.pixels { toast_show(.Error, "There is no image to copy"); return; } + if !platform_copy_image_to_clipboard(.{cast(s64) d.width * d.height * 4, d.pixels}, d.width, d.height) { + toast_show(.Error, "Could not put % on the clipboard", path_filename(open_file.path)); + return; + } + toast_show(.Success, "Copied % (% x %)", path_filename(open_file.path), d.width, d.height); +} + +// ---------------------------------------------------------------- each frame + +// Full mode, from app_main_window_ui: the Opened File window. +open_file_frame :: () { using ImGui; + open_file_poll(); + if !open_file.window_open { + if open_file.stage != .IDLE && !open_file.importing open_file_stop(); // closed: its image goes + return; + } + SetNextWindowSize(.{900, 700}, .FirstUseEver); + if open_file.focus { SetNextWindowFocus(); open_file.focus = false; } + if Begin(OPEN_FILE_WINDOW, *open_file.window_open) open_file_contents(); + End(); +} + +// View mode: the file, filling the main window. +open_file_view_mode_ui :: () { using ImGui; + open_file_poll(); + viewport := GetMainViewport(); + SetNextWindowPos(viewport.WorkPos); + SetNextWindowSize(viewport.WorkSize); + SetNextWindowViewport(viewport.ID_); + flags := WindowFlags.NoDecoration | .NoMove | .NoSavedSettings | .NoDocking | .NoBringToFrontOnFocus; + if Begin("View", null, flags) open_file_contents(); + End(); + if IsKeyPressed(.Escape) && !IsAnyItemActive() app.should_exit = true; +} + +// The thread's image, once there, as a texture (again, after the device changed); the thread taken +// back once it has ended; the import's end. +open_file_poll :: () { + stage := atomic_read(*open_file.stage); + if (stage == .DECODED || stage == .DONE) && (!open_file.uploaded || open_file.texture_generation != g_texture_generation) { + open_file.uploaded = true; + open_file.texture_generation = g_texture_generation; + gpu_texture_release(*open_file.texture); + d := *open_file.decoded; + if !d.failed && d.pixels open_file.texture = gpu_texture_create(d.pixels, d.width, d.height); + } + if stage == .DONE && open_file.thread_started { + thread_is_done(*open_file.thread, -1); + thread_deinit(*open_file.thread); + open_file.thread_started = false; + } + open_file_import_poll(); + if app.open_import_dest open_file_flag_frame(); +} + +// -open-import DEST: once the file has been read, Import into DEST, as the button does; then exits, +// 0 when it is in the library (imported, or there already), 1 when not. +open_file_flag_frame :: () { + if app.should_exit return; + if !app.open_file || open_file.library_path { + log("[open] -open-import: %.", ifx app.open_file then "it is in the library" else "no file was opened"); + app.exit_code = ifx app.open_file then cast(s32) 0 else 1; + app.should_exit = true; + return; + } + if !open_file.flag_started { + if atomic_read(*open_file.stage) != .DONE return; + open_file.flag_started = true; + open_file_import(app.open_import_dest); + if !open_file.importing { log_error("[open] -open-import % could not start.", app.open_import_dest); app.exit_code = 1; app.should_exit = true; } + return; + } + if open_file.importing return; + log("[open] -open-import is done: %", open_file.result); + app.exit_code = ifx open_file.result_ok then cast(s32) 0 else 1; + app.should_exit = true; +} + +open_file_contents :: () { using ImGui; + if !open_file.path return; + stage := atomic_read(*open_file.stage); + shown := stage == .DECODED || stage == .DONE; + Text("%", path_filename(open_file.path)); + SameLine(); + if shown && open_file.details TextColored(app_theme.palette.dim, "%, %", open_file.details, open_file_size_text(open_file.size)); + else TextColored(app_theme.palette.dim, "%", open_file_size_text(open_file.size)); + if !shown { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); } + native := open_file.path; + #if OS == .WINDOWS native = replace(native, "/", "\\",, temp); + TextColored(app_theme.palette.dim, "%", native); + + open_file_library_ui(stage); + Separator(); + + if shown && open_file.error { + TextColored(app_theme.palette.warning, "This file cannot be shown: %.", open_file.error); + return; + } + if open_file.texture.handle open_file_image_ui(); +} + +// Where the file stands with the library, and Import when it is not in it. +open_file_library_ui :: (stage: Open_File_Stage) { using ImGui; + library := viewer_folder(); + if open_file.library_path { + relative := advance(open_file.library_path, library.count + 1); + folder := trim_right(path_strip_filename(relative), "/"); + Text("In the library, in %.", ifx folder then folder else "its top folder"); + if app.view_mode { SameLine(); open_file_show_button(open_file.library_path); } + return; + } + if open_file.result { + TextColored(ifx open_file.result_ok then app_theme.palette.success else app_theme.palette.warning, "%", open_file.result); + if open_file.result_ok { + if app.view_mode && open_file.imported_path { SameLine(); open_file_show_button(open_file.imported_path); } + return; + } + } + if stage == .DONE && open_file.match { + Text("Already in the library: %", open_file.match); + SameLine(); + where := tprint("%/%", library, open_file.match); + if app.view_mode open_file_show_button(where); + else if Button("Show") open_file_show_in_library(where); + return; + } + if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); return; } + if OS != .WINDOWS && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows only."); return; } + + if open_file.importing { + if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total)); + else Text("Importing into %...", importer.dest_name); + SameLine(); + if SmallButton("Cancel") { atomic_swap(*importer.cancel, true); atomic_swap(*importer.paused, false); } + return; + } + + Text("Import into library folder"); + SameLine(); + SetNextItemWidth(GetFontSize() * 16); + InputTextWithHint("##dest", "e.g. Photos", open_file.dest_buffer.data, open_file.dest_buffer.count); + SameLine(); + if BeginCombo("##folders", "", .NoPreview) { + for open_file.folders { + if Selectable(temp_c_string(it)) { + count := min(it.count, open_file.dest_buffer.count - 1); + memcpy(open_file.dest_buffer.data, it.data, count); + open_file.dest_buffer[count] = 0; + } + } + if !open_file.folders.count TextColored(app_theme.palette.dim, "The library has no folders yet"); + EndCombo(); + } + if IsItemHovered() SetTooltip("The library's folders"); + dest := trim(to_string(open_file.dest_buffer.data)); + valid := dest.count > 0 && import_name_is_valid(dest); + busy := importer.running; // a folder's import, from the Import window + SameLine(); + if !valid || busy BeginDisabled(); + if Button("Import") open_file_import(dest); + if !valid || busy EndDisabled(); + if busy && IsItemHovered(.AllowWhenDisabled) SetTooltip("After the import running now"); + if app.view_mode { SameLine(); open_file_show_button(open_file.path, "Open in mcap"); } + + if dest && !valid TextColored(app_theme.palette.warning, "\"%\" cannot be a folder's name.", dest); + else if dest { + into := tprint("%/%", library, dest); + #if OS == .WINDOWS into = replace(into, "/", "\\",, temp); + TextColored(app_theme.palette.dim, "Into %%", into, ifx array_find(open_file.folders, dest) then "" else " (a new folder)"); + } + if !app.view_mode Checkbox("Read text and labels once it is in", *importer.read_after); + if stage != .DONE TextColored(app_theme.palette.dim, "Looking for it in the library..."); +} + +// View mode: a button handing path to the running copy, which shows it. +open_file_show_button :: (path: string, label := "Show in mcap") { using ImGui; + if Button(temp_c_string(label)) open_file_send_to_running(path); + if IsItemHovered() SetTooltip("Shows it in the mcap already running, and closes this window"); +} + +// The image, sized as the Capture window sizes its own (viewer_full_window). +open_file_image_ui :: () { using ImGui; + fit := open_file.fit; + Text("Fit:"); + SameLine(); if RadioButton("Window", fit == .WINDOW) fit = .WINDOW; + SameLine(); if RadioButton("Width", fit == .WIDTH) fit = .WIDTH; + SameLine(); if RadioButton("Height", fit == .HEIGHT) fit = .HEIGHT; + SameLine(); if RadioButton("Off", fit == .OFF) fit = .OFF; + open_file.fit = fit; + SameLine(); + SetNextItemWidth(260); + if SliderFloat("Zoom", *open_file.zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM, "%.0f%%", .Logarithmic) open_file.fit = .OFF; + SameLine(); + if SmallButton("100%") { open_file.fit = .OFF; open_file.zoom = 100; } + if open_file.kind == .VIDEO { + SameLine(); + if SmallButton("Play in the default player") platform_open_file_in_default_program(open_file.path, path_strip_filename(open_file.path)); + } + + flags := WindowFlags.HorizontalScrollbar; + if open_file.fit == { + case .WINDOW; flags = .NoScrollbar; + case .WIDTH; flags |= .AlwaysVerticalScrollbar; + case .HEIGHT; flags |= .AlwaysHorizontalScrollbar; + } + BeginChild("image", .{0, 0}, .None, flags); + area := GetContentRegionAvail(); + source_w, source_h := cast(float) open_file.decoded.source_width, cast(float) open_file.decoded.source_height; + if source_w > 0 && source_h > 0 && area.x > 0 && area.y > 0 { + if open_file.fit == { + case .WINDOW; open_file.zoom = 100 * min(area.x / source_w, area.y / source_h); + case .WIDTH; open_file.zoom = 100 * area.x / source_w; + case .HEIGHT; open_file.zoom = 100 * area.y / source_h; + } + } + zoom := clamp(open_file.zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM) / 100; + Image(gpu_texture_ref(open_file.texture), .{source_w * zoom, source_h * zoom}); + EndChild(); +} + +// 812 KB, 4.2 MB, 1.3 GB. Temp. +open_file_size_text :: (bytes: s64) -> string { + if bytes < 1024 * 1024 return tprint("% KB", (bytes + 1023) / 1024); + if bytes < 1024 * 1024 * 1024 return tprint("% MB", FF(bytes / (1024.0 * 1024.0), 1)); + return tprint("% GB", FF(bytes / (1024.0 * 1024.0 * 1024.0), 1)); +} diff --git a/src/app/viewer.jai b/src/app/viewer.jai index 9aea35e..42f01d4 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -351,6 +351,14 @@ viewer_decode_image :: (path: string, bytes: [] u8, kind := Image_Kind.UNKNOWN) log_error("[viewer] Cannot decode the image in %: %", path, error); return result; } + return viewer_decoded_from_image(path, image); +} + +// image, which this takes, as a texture can hold it: scaled down when too large for one. +viewer_decoded_from_image :: (path: string, image_in: Image) -> Decoded { + result: Decoded; + result.failed = true; + image := image_in; pixels := image.pixels; width, height := image.width, image.height; result.pixels = pixels; @@ -446,12 +454,15 @@ viewer_frame_gpu_work :: () { capture.failed = true; } else if !capture.texture.handle { capture.texture = gpu_texture_create(d.pixels, d.width, d.height); - if capture.texture.handle array_add(*viewer.with_texture, d.index); - if capture.texture.handle && !viewer.first_preview_logged { - viewer.first_preview_logged = true; - log("[startup] First preview shown at % ms.", FF(startup_ms(), 1)); + if capture.texture.handle { + array_add(*viewer.with_texture, d.index); + if !viewer.first_preview_logged { + viewer.first_preview_logged = true; + log("[startup] First preview shown at % ms.", FF(startup_ms(), 1)); + } + } else { + capture.failed = true; } - else capture.failed = true; } viewer_free_pixels(*d); } diff --git a/src/base/debug.jai b/src/base/debug.jai index e855515..4545103 100644 --- a/src/base/debug.jai +++ b/src/base/debug.jai @@ -30,6 +30,8 @@ app_debug_panel_ui :: () { Text("Time to first frame = % ms", FF(perf.time_to_first_frame_ms, 1)); Checkbox("Frame Times window", *app.show_frame_time_graph); ButtonTooltip(to_string(toggle_frame_times_hotkey)); + Checkbox("Command Line window", *app.show_help_panel); + ButtonTooltip("The command-line options; mcap --help opens it"); Spacing(); SeparatorText("Memory Usage"); @@ -104,6 +106,70 @@ app_frame_times_ui :: () { frame_time_graph(); } +// The command-line options, opened by mcap -h or --help, or from the Debug Panel. Keep in step with +// the Command line table in README.md. +app_help_panel_ui :: () { + if !app.show_help_panel return; + using ImGui; + + SetNextWindowSize(imgui_scale(Vector2.{760, 560}), .FirstUseEver); + if app.help_panel_focus { SetNextWindowFocus(); app.help_panel_focus = false; } + push_imgui_window("Command Line", p_open=*app.show_help_panel); + + Text("% % [FILE] [options]", APP_NAME, VERSION); + SameLine(); + if SmallButton("Copy") { + builder: String_Builder; + builder.allocator = temp; + for APP_COMMAND_LINE_OPTIONS print_to_builder(*builder, "%\t%\n", it.option, it.text); + clipboard_set_text(builder_to_string(*builder,, temp)); + toast_show(.Success, "Copied the command-line options"); + } + TextColored(app_theme.palette.dim, "-import, -scan, -segment, -stats and -erase still open the window, to show their progress. README.md has the details."); + Spacing(); + + if BeginTable("options", 2, .Borders | .RowBg | .SizingStretchProp | .ScrollY) { + TableSetupColumn("Option", .WidthFixed); + TableSetupColumn("What it does", .WidthStretch); + TableSetupScrollFreeze(0, 1); + TableHeadersRow(); + for APP_COMMAND_LINE_OPTIONS { + TableNextRow(); + TableNextColumn(); + TextUnformatted(it.option); + TableNextColumn(); + TextWrapped("%", it.text); + } + EndTable(); + } +} + +Command_Line_Option :: struct { option: string; text: string; } + +APP_COMMAND_LINE_OPTIONS :: Command_Line_Option.[ + .{"FILE (first)", "Opens FILE: an .mcap, image or video. With mcap already running, it opens in view mode beside it."}, + .{"-open-import DEST", "With FILE: imports it into the library folder DEST as its Import button does, then exits: 0 once the library has it (imported, or there already), else 1."}, + .{"-library FOLDER", "Shows the .mcap files in FOLDER (and its folders) instead of D:\\McapIndex\\mcap."}, + .{"-import SOURCE DEST", "Imports SOURCE, a folder or a single file, into the library folder DEST, as the Import window does, then exits: 0, or 1 if any file failed."}, + .{"-search QUERY", "Starts with QUERY in the search box."}, + .{"-scan", "Scans the library (the chosen OCR model, and labels when the label model is up) once it has loaded, then exits: 0, or 1 if any capture failed."}, + .{"-segment", "Segments the images not yet segmented in the library folders set to segment, then exits: 0, or 1 if any image failed."}, + .{"-stats", "Logs the Stats window's numbers once the catalog has loaded, then exits; with -search, for the matches."}, + .{"-erase WHAT", "Erases ocr, labels, segments or all from every file in the library, then exits (for testing)."}, + .{"-record SECONDS", "Records the screen under the cursor that long (no overlay), puts it in the library, then exits: 0, or 1 if it failed."}, + .{"-record-region X Y W H", "With -record: records that region instead."}, + .{"-record-quit", "With -record: quits while still recording, to check the recording is kept."}, + .{"-record-ui-visible", "With -record: leaves the border and toolbar in screenshots, to look at them."}, + .{"-cold", "Loads the library as on a cold start (its files dropped from the system's file cache first). With -stats, a startup benchmark."}, + .{"-no-catalog-cache", "Reads every capture from its file, as without the catalog cache (for measuring); the cache is still written."}, + .{"-catalog-threads N", "Loads the catalog on N threads instead of all the background group's (for measuring)."}, + .{"-allow-multiple-instances", "Runs beside another copy instead of bringing that one forward."}, + .{"-headless", "No message boxes: a failure is logged and the app exits with 1."}, + .{"run_tests", "Runs the test suites (debug builds); with -headless, exits with 0 if all passed."}, + .{"-version", "Prints the version, then exits."}, + .{"-h, --help", "Opens this window."}, +]; + frame_time_graph :: () { using ImGui; diff --git a/src/base/platform/platform_windows.jai b/src/base/platform/platform_windows.jai index 6708b8f..4493d7f 100644 --- a/src/base/platform/platform_windows.jai +++ b/src/base/platform/platform_windows.jai @@ -184,6 +184,9 @@ WndProc :: (hWnd: HWND, message: u32, wParam: WPARAM, lParam: LPARAM) -> LRESULT if message == { case WM_SYSCOMMAND; SC_MINIMIZE :: 0xF020; SC_CLOSE :: 0xF060; + // View mode (app/open_file.jai) has no tray: close ends it, and minimize is the system's own. + if app.view_mode && (wParam & 0xFFF0) == SC_CLOSE { app.should_exit = true; return 0; } + if app.view_mode && (wParam & 0xFFF0) == SC_MINIMIZE return DefWindowProcW(hWnd, message, wParam, lParam); if (wParam & 0xFFF0) == { case SC_CLOSE; #through; case SC_MINIMIZE; diff --git a/src/ui/imgui.jai b/src/ui/imgui.jai index 4c848fa..0194cd4 100644 --- a/src/ui/imgui.jai +++ b/src/ui/imgui.jai @@ -67,6 +67,9 @@ app_imgui_config_setup :: () { this_allocation_is_not_a_leak(app_imgui_ini_path.data); io := ImGui.GetIO(); + // View mode (app/open_file.jai) is one window filling the main one: no layout to load, and the + // running instance's is left alone. + if app.view_mode { io.IniFilename = null; return; } io.IniFilename = app_imgui_ini_path.data; // Loaded before the first frame, so ImGui does not go looking for the file itself. diff --git a/src/ui/imgui_keyboard.jai b/src/ui/imgui_keyboard.jai index ef5376b..4342718 100644 --- a/src/ui/imgui_keyboard.jai +++ b/src/ui/imgui_keyboard.jai @@ -634,9 +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(); + // The viewer is the shutdown thread's once shutdown has started. View mode has the file alone (app/open_file.jai). + if check_key_combination(*copy_image_hotkey) && !app_exit_shutdown_started() { if app.view_mode open_file_copy_image(); else viewer_copy_image(); } + if check_key_combination(*copy_path_hotkey) && !app_exit_shutdown_started() { if app.view_mode open_file_copy_path(); else viewer_copy_path(); } if should_exit_this_frame { log("Calling exit hotkey... exiting program!"); @@ -650,7 +650,9 @@ app_check_key_combinations :: () { // system does, so we have to ignore it if we just restored the window, // OTHERWISE, the window will pop up, and then immediately minimize. // Very stupid! - if (to_float64_seconds(app.frame_time - app.last_window_restore_time) > 0.15) { + if app.view_mode { + app.should_exit = true; // no tray: it closes, as a viewer's Ctrl-W does + } else if (to_float64_seconds(app.frame_time - app.last_window_restore_time) > 0.15) { app.minimize_to_tray_this_frame = true; } }