[0.0.1] Viewer: a Refresh button, for captures deleted or added on disk

This commit is contained in:
Musa Mahmood 2026-10-07 23:42:08 +01:00
parent cf3a745588
commit a254e7a3dc
5 changed files with 89 additions and 8 deletions

View File

@ -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

View File

@ -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;

View File

@ -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";

View File

@ -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)});
}

View File

@ -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