diff --git a/README.md b/README.md index 9c3a378..7f74e9c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ of previews, and one capture at full size in the Capture window. so it can pause, cancel and pick up again. `-library FOLDER` shows another folder of `.mcap` files; `-scan` scans it with the chosen model once it has loaded and exits (0, or 1 if any capture failed), for a scan without the UI, such as on a copy. +- **Refresh** above the grid looks at the folder again: captures whose file was deleted leave the + grid, and new files are added (each capture keeps its index, as the workers, the OCR scan and Stats + refer to captures by index). By hand for now; File_Watcher can call `viewer_refresh` later. - **Stats** above the grid: what OCR found in the captures shown, by the chosen model: captures with text, lines, words, characters, confidence, words per capture, a year table, the captures with the most words (click to open) and the commonest words (click to search). `-stats` logs the same diff --git a/check_targets.jai b/check_targets.jai index cec6e3a..5f1229e 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -29,7 +29,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; GIT_BRANCH :: "check"; GIT_HASH :: "-"; -VERSION :: "0.0.0"; +VERSION :: "0.0.1"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index e2ffa5f..c864eac 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.0.0"; +VERSION :: "0.0.1"; // 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/ocr_ui.jai b/src/app/ocr_ui.jai index c06186e..71917a7 100644 --- a/src/app/ocr_ui.jai +++ b/src/app/ocr_ui.jai @@ -303,6 +303,7 @@ ocr_scan_start :: () { // Every capture without a reading by this model, in the order the grid shows them. for viewer.visible { capture := *viewer.captures[it]; + if capture.removed continue; if capture.catalog && catalog_section(capture.catalog, scan.model) continue; array_add(*scan.items, .{it, copy_string(capture.path,, context.default_allocator)}); } diff --git a/src/app/viewer.jai b/src/app/viewer.jai index 206c12c..b0b9fe3 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -6,6 +6,10 @@ // When the workers have no previews to decode they load the catalog (app/catalog.jai), what search // and sorting need from each capture. The grid shows viewer.visible: the captures in display order, // newest first once the catalog is complete, and only those matching the search. +// +// A capture keeps its index for as long as the app runs: the workers, the OCR scan and Stats refer +// to captures by index. Refresh (viewer_refresh) marks the files gone from the folder as removed and +// appends the new ones. VIEWER_FOLDER :: "D:/SnagitIndex/mcap"; // hardcoded for now; -library FOLDER shows another @@ -45,6 +49,7 @@ Viewer_Capture :: struct { failed: bool; in_flight: bool; // under viewer.mutex: queued for a worker, or being decoded catalog: *Catalog_Entry; // main thread only; null until loaded + removed: bool; // main thread only: its file was gone at a refresh; shown nowhere } // A catalog entry made off the main thread, for the main thread to put in place. @@ -101,6 +106,7 @@ viewer: struct { visible_dirty := true; visible_rebuilt_at: Apollo_Time; catalog_loaded: s64; // entries put in place, or found unreadable + removed_count: s64; // captures marked removed catalog_generation: s64; // counts changes to catalog entries (loading, an OCR reading stored) visible_generation: s64; // counts rebuilds of visible sorted_by_date: bool; @@ -195,7 +201,7 @@ viewer_worker :: (thread: *Thread) -> s64 { index = cast(s32) viewer.catalog_next; viewer.catalog_next += 1; } - if index >= 0 path = viewer.captures[index].path; // the array no longer grows after startup + if index >= 0 path = viewer.captures[index].path; // the array grows only under the mutex, and paths are never freed unlock(*viewer.mutex); if index < 0 break; // nothing to do: wait to be woken @@ -354,7 +360,9 @@ viewer_frame_gpu_work :: () { unlock(*viewer.mutex); capture := *viewer.captures[d.index]; - if d.failed { + if capture.removed { + // Its file went at a refresh while it was being decoded. + } else if d.failed { capture.failed = true; } else if !capture.texture.handle { capture.texture = gpu_texture_create(d.pixels, d.width, d.height); @@ -393,7 +401,9 @@ viewer_apply_catalog_updates :: () { } } if updates.count viewer.catalog_generation += 1; - if updates.count && viewer.search_terms.count viewer.visible_dirty = true; + // Under a search, or once sorted by date, entries arriving can move captures (after a refresh, + // the new ones get their dates). + if updates.count && (viewer.search_terms.count || viewer.sorted_by_date) viewer.visible_dirty = true; if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count { viewer.sorted_by_date = true; viewer.visible_dirty = true; @@ -404,6 +414,61 @@ viewer_apply_catalog_updates :: () { // The captures in display order: by date, newest first, once every date is known (by name until // then), and only those matching the search. +// Looks at the folder again: captures whose file is gone are marked removed, and new files are +// added, their previews and catalog entries loaded as the others' were. Indices stay as they are. +viewer_refresh :: () { + start := current_time_monotonic(); + present: Table(string, bool); // the folder's .mcap files now + present.allocator = temp; + for file_list(viewer_folder(),, temp) { + if ends_with_nocase(path_filename(it), tprint(".%", MCAP_EXTENSION)) table_set(*present, it, true); + } + + removed, added: s64; + known: Table(string, bool); // the captures still there + known.allocator = temp; + for * capture, index: viewer.captures { + if capture.removed continue; + if table_contains(*present, capture.path) { table_set(*known, capture.path, true); continue; } + capture.removed = true; + removed += 1; + if capture.texture.handle { + gpu_texture_release(*capture.texture); + found, position := array_find(viewer.with_texture, cast(s32) index); + if found array_ordered_remove_by_index(*viewer.with_texture, position); + } + if viewer.full_index == index viewer.full_open = false; + } + + lock(*viewer.mutex); + for _, path: present { + if table_contains(*known, path) continue; + capture := array_add(*viewer.captures); + capture.* = .{}; + capture.path = copy_string(path,, context.default_allocator); + capture.name = copy_string(path_strip_extension(path_filename(path)),, context.default_allocator); + added += 1; + } + unlock(*viewer.mutex); + if added signal(*viewer.wake); // their catalog entries + + viewer.removed_count += removed; + free(viewer.scan_error); + viewer.scan_error = ""; + if !viewer_capture_count() viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder()); + viewer.catalog_generation += 1; + viewer.visible_dirty = true; + viewer.visible_rebuilt_at = .{}; + log("[viewer] Refreshed %: % removed, % added, % captures, in % ms.", viewer_folder(), removed, added, + viewer.captures.count - viewer.removed_count, FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1)); + toast_show(.Success, "% removed, % added", removed, added); +} + +// The captures not removed. +viewer_capture_count :: () -> s64 { + return viewer.captures.count - viewer.removed_count; +} + // Sets the search box to query and searches, as typing it would. viewer_set_search :: (query: string) { count := min(query.count, viewer.search_buffer.count - 1); @@ -431,6 +496,7 @@ viewer_rebuild_visible :: () { array_reset_keeping_memory(*viewer.visible); for viewer.captures { + if it.removed continue; if viewer.search_terms.count && !search_matches(it.catalog, viewer.search_terms) continue; array_add(*viewer.visible, cast(s32) it_index); } @@ -544,15 +610,20 @@ viewer_request_full :: (index: s32) { viewer_ui :: () { using ImGui; viewer_frame_gpu_work(); - if viewer.scan_error { TextColored(app_theme.palette.warning, "%", viewer.scan_error); return; } + if viewer.scan_error { + TextColored(app_theme.palette.warning, "%", viewer.scan_error); + SameLine(); + if Button("Refresh") viewer_refresh(); + return; + } SetNextItemWidth(min(GetContentRegionAvail().x * 0.5, 520)); if InputTextWithHint("##search", "Search text, titles, URLs, apps, notes. \"a phrase\" ocr: title: url: app: note:", viewer.search_buffer.data, viewer.search_buffer.count) { viewer_search_changed(); } SameLine(); - if viewer.search_terms.count Text("% of % captures", viewer.visible.count, viewer.captures.count); - else Text("% captures", viewer.captures.count); + if viewer.search_terms.count Text("% of % captures", viewer.visible.count, viewer_capture_count()); + else Text("% captures", viewer_capture_count()); if viewer.catalog_loaded < viewer.captures.count { SameLine(); TextColored(app_theme.palette.dim, "(loading the catalog: % of %; sorted by name until then)", viewer.catalog_loaded, viewer.captures.count); @@ -565,6 +636,12 @@ viewer_ui :: () { using ImGui; if Button("OCR") ocr_ui.window_open = !ocr_ui.window_open; SameLine(); if Button("Stats") stats_ui.window_open = !stats_ui.window_open; // app/stats_ui.jai + SameLine(); + busy := ocr_ui.thread_busy; // a scan or a reading refers to captures by index + if busy BeginDisabled(); + if Button("Refresh") viewer_refresh(); + if busy EndDisabled(); + if IsItemHovered(.AllowWhenDisabled) SetTooltip(ifx busy then "After the OCR scan or reading" else "Look at the folder again: drop the captures deleted, add the new ones"); if IsItemHovered() { engine: *Ocr_Engine; if !ocr_ui.thread_busy engine = ocr_ui.engine; // the background thread owns it while busy