From 3c6da5b53d7b05dc05796ce8100236024a39e276 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Thu, 8 Oct 2026 10:23:15 +0100 Subject: [PATCH] [0.0.4] Search and Stats on background threads, so typing never stalls the UI --- README.md | 4 +- check_targets.jai | 2 +- first.jai | 2 +- src/App.jai | 1 + src/app/background.jai | 259 +++++++++++++++++++++++++++++++++++++++++ src/app/stats_ui.jai | 130 ++++++++++++--------- src/app/viewer.jai | 33 ++---- 7 files changed, 353 insertions(+), 78 deletions(-) create mode 100644 src/app/background.jai diff --git a/README.md b/README.md index 7ed7556..c7033bc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ of previews, and one capture at full size in the Capture window. - **Search** above the grid: every word must match, anywhere in the OCR text, window title, URL, app name or annotation text; `"a phrase"` matches as one; `ocr:`, `title:`, `url:`, `app:` and `note:` limit a word to one field. In the Capture window the matching OCR lines are highlighted, with Next - and Previous. + and Previous. Matching runs on a pool of background threads (`app/background.jai`), as do the + Stats window's numbers, so typing never waits on them; the grid shows the last results until the + new ones are back, usually within a frame. - **Order:** newest first by capture date, once the catalog (what search needs from every file) has loaded; by name until then. - **OCR** (`modules/OCR`): the OCR window shows whether it runs on the GPU and, if not, what to install, diff --git a/check_targets.jai b/check_targets.jai index 199ab5c..213fc80 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.3"; +VERSION :: "0.0.4"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index eb6f5ad..b216715 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.3"; +VERSION :: "0.0.4"; // 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 0195706..4628f1a 100644 --- a/src/App.jai +++ b/src/App.jai @@ -19,6 +19,7 @@ USING_WINDOWS :: OS == .WINDOWS; #load "app/ocr_ui.jai"; #load "app/catalog.jai"; #load "app/stats_ui.jai"; +#load "app/background.jai"; #load "app/exit_shutdown.jai"; #load "base/circle_buffer.jai"; diff --git a/src/app/background.jai b/src/app/background.jai new file mode 100644 index 0000000..9c5a459 --- /dev/null +++ b/src/app/background.jai @@ -0,0 +1,259 @@ +// Background work for the viewer, on a thread group: matching the search against the catalog, and +// the Statistics window's numbers. The UI thread hands out jobs and takes back results each frame; +// it never waits on them, so typing in the search box or opening Stats cannot stall a frame. Until +// a search's results are back, the grid keeps showing the last ones. +// +// A job gets a snapshot (capture index, catalog entry, name) of the captures it covers, made on the +// UI thread, and reads nothing else: never viewer.captures, which a refresh can grow. Catalog +// entries replaced while jobs are out are freed once none are (background_retire). A newer search +// makes the older ones stop early, and their results are dropped. + +BACKGROUND_SEARCH_CHUNK :: 256; // captures matched per job + +Search_Item :: struct { + index: s32; + entry: *Catalog_Entry; // null while not loaded + name: string; // the capture's; never freed +} + +background: struct { + group: Thread_Group; + started: bool; + jobs_out: s64; // UI thread: handed out and not yet taken back + search_generation: s64; // the newest search; workers read it to stop early (atomic) + searching: bool; // the newest search's results are not back yet + stats_generation: s64; // the newest Statistics job + stats_out: bool; + retired: [..] *Catalog_Entry; // replaced while jobs were out +}; + +background_startup :: () { + background.retired.allocator = context.default_allocator; + threads := clamp(get_number_of_processors(.ALL_LOGICAL) - 2, 2, 16); + // Statistics count words in a table in temporary storage: a few MB on the full library. + init(*background.group, cast(s32) threads, background_proc, temporary_storage_size = 4 * 1024 * 1024, group_name = "background"); + background.group.logging = false; + start(*background.group); + background.started = true; +} + +// On the shutdown thread: stops the workers. Jobs still out are left to the process's end. +background_shutdown :: () { + if background.started shutdown(*background.group); + background.started = false; +} + +// A catalog entry the UI thread no longer points at: freed now, or once no job can be reading it. +background_retire :: (entry: *Catalog_Entry) { + if !entry return; + if background.jobs_out array_add(*background.retired, entry); + else catalog_entry_free(entry); +} + +// The captures shown, as a snapshot a job can read. Heap. +background_snapshot_visible :: () -> [] Search_Item { + items := NewArray(viewer.visible.count, Search_Item,, context.default_allocator); + for viewer.visible { + capture := *viewer.captures[it]; + items[it_index] = .{it, capture.catalog, capture.name}; + } + return items; +} + +// Each frame, on the UI thread: takes back what the workers finished, frees retired entries once +// nothing is out, and starts a search when the shown captures need working out again. +background_frame :: () { + if !background.started return; + for work: get_completed_work(*background.group) { + job := cast(*Background_Job) work; + background.jobs_out -= 1; + if job.kind == { + case .SEARCH_CHUNK; + request := job.request; + request.chunk_matches[job.chunk] = job.matches; + request.chunks_back += 1; + if request.chunks_back == request.chunk_matches.count { + if request.generation == background.search_generation { + merge := New(Background_Job,, context.default_allocator); + merge.* = .{kind = .SEARCH_MERGE, generation = request.generation, request = request}; + background_add(merge); + } else { + search_request_free(request); + } + } + case .SEARCH_MERGE; + if job.generation == background.search_generation { + array_free(viewer.visible); + viewer.visible = job.visible; + viewer.visible_generation += 1; + background.searching = false; + } else { + array_free(job.visible); + } + search_request_free(job.request); + case .STATS; + stats_result_arrived(job.generation, job.stats); + background.stats_out = false; + array_free(job.items,, context.default_allocator); + free(job.model,, context.default_allocator); + } + free(job,, context.default_allocator); + } + + if !background.jobs_out && background.retired.count { + for background.retired catalog_entry_free(it); + array_reset_keeping_memory(*background.retired); + } + + if viewer.visible_dirty background_search_start(); +} + +// Matches the search against every capture not removed, in chunks on the workers; the results +// replace viewer.visible when they are all back. +background_search_start :: () { + // While the catalog loads under a search, matches arrive every frame: a few searches a second do. + if viewer.search_terms.count && !viewer.sorted_by_date && to_float64_seconds(current_time_monotonic() - viewer.visible_rebuilt_at) < 0.25 return; + viewer.visible_dirty = false; + viewer.visible_rebuilt_at = current_time_monotonic(); + + push_allocator(context.default_allocator); + generation := background.search_generation + 1; + atomic_write(*background.search_generation, generation); // older chunks see it and stop + background.searching = true; + + request := New(Search_Request); + request.generation = generation; + request.by_date = viewer.sorted_by_date; + request.terms = NewArray(viewer.search_terms.count, Search_Term); + for viewer.search_terms request.terms[it_index] = .{it.field, copy_string(it.text)}; + items: [..] Search_Item; + array_reserve(*items, viewer_capture_count()); + for * viewer.captures if !it.removed array_add(*items, .{cast(s32) it_index, it.catalog, it.name}); + request.items = items; + chunks := max((items.count + BACKGROUND_SEARCH_CHUNK - 1) / BACKGROUND_SEARCH_CHUNK, 1); + request.chunk_matches = NewArray(chunks, [..] Search_Item); + + for chunk: 0..chunks - 1 { + job := New(Background_Job); + first := chunk * BACKGROUND_SEARCH_CHUNK; + job.* = .{kind = .SEARCH_CHUNK, generation = generation, request = request, chunk = chunk, + first = first, count = clamp(items.count - first, 0, BACKGROUND_SEARCH_CHUNK)}; + background_add(job); + } +} + +// Works out the Statistics window's numbers for items (taken over by the job) with model. +background_stats_start :: (items: [] Search_Item, model: string) { + background.stats_generation += 1; + background.stats_out = true; + job := New(Background_Job,, context.default_allocator); + job.* = .{kind = .STATS, generation = background.stats_generation, items = items, model = copy_string(model,, context.default_allocator)}; + background_add(job); +} + +#scope_file + +Background_Kind :: enum u8 { SEARCH_CHUNK; SEARCH_MERGE; STATS; } + +Background_Job :: struct { + kind: Background_Kind; + generation: s64; + + // SEARCH_CHUNK: request.items[first..first+count-1], matched into matches, in display order. + // SEARCH_MERGE: every chunk's matches, into visible. + request: *Search_Request; + chunk, first, count: s64; + matches: [..] Search_Item; + visible: [..] s32; + + // STATS + items: [] Search_Item; + model: string; + stats: *Stats_Result; +} + +Search_Request :: struct { + generation: s64; + terms: [] Search_Term; + items: [] Search_Item; + by_date: bool; // newest first; else in the captures' own order + chunk_matches: [] [..] Search_Item; // each chunk's, as they come back + chunks_back: s64; +} + +background_add :: (job: *Background_Job) { + background.jobs_out += 1; + add_work(*background.group, job); +} + +search_request_free :: (request: *Search_Request) { + push_allocator(context.default_allocator); + for request.terms free(it.text); + array_free(request.terms); + array_free(request.items); + for request.chunk_matches array_free(it); + array_free(request.chunk_matches); + free(request); +} + +// On a worker. +background_proc :: (group: *Thread_Group, thread: *Thread, work: *void) -> Thread_Continue_Status { + context.allocator = context.default_allocator; + job := cast(*Background_Job) work; + if job.kind == { + case .SEARCH_CHUNK; search_chunk(job); + case .SEARCH_MERGE; search_merge(job); + case .STATS; job.stats = stats_compute(job.items, job.model); + } + reset_temporary_storage(); + return .CONTINUE; +} + +search_chunk :: (job: *Background_Job) { + request := job.request; + for i: job.first..job.first + job.count - 1 { + if (i & 63) == 0 && atomic_read(*background.search_generation) != job.generation return; // a newer search: these are wasted + item := request.items[i]; + if search_matches(item.entry, request.terms) array_add(*job.matches, item); + } + if request.by_date quick_sort(job.matches, search_order_by_date); +} + +// Every chunk's matches as one list. In date order, each chunk is sorted already, so they are merged +// pairwise; in the captures' own order, chunks follow one another. +search_merge :: (job: *Background_Job) { + request := job.request; + runs: [..] [] Search_Item; + runs.allocator = temp; + for request.chunk_matches array_add(*runs, it); + if request.by_date { + while runs.count > 1 { + next: [..] [] Search_Item; + next.allocator = temp; + for i: 0..(runs.count + 1) / 2 - 1 { + if 2 * i + 1 < runs.count array_add(*next, merge_two(runs[2 * i], runs[2 * i + 1])); + else array_add(*next, runs[2 * i]); + } + runs = next; + } + } + total := 0; + for runs total += it.count; + array_reserve(*job.visible, total); + for run: runs for run array_add(*job.visible, it.index); +} + +// Two lists in date order as one. Temp. +merge_two :: (a: [] Search_Item, b: [] Search_Item) -> [] Search_Item { + result := NewArray(a.count + b.count, Search_Item,, temp); + i, j := 0, 0; + for * result { + if j >= b.count || (i < a.count && search_order_by_date(a[i], b[j]) <= 0) { it.* = a[i]; i += 1; } + else { it.* = b[j]; j += 1; } + } + return result; +} + +search_order_by_date :: (a: Search_Item, b: Search_Item) -> s64 { + return viewer_display_order(ifx a.entry then a.entry.date else "", a.name, ifx b.entry then b.entry.date else "", b.name); +} diff --git a/src/app/stats_ui.jai b/src/app/stats_ui.jai index 42ac821..6bac00d 100644 --- a/src/app/stats_ui.jai +++ b/src/app/stats_ui.jai @@ -1,6 +1,7 @@ // The Statistics window: what OCR found in the captures shown (the whole library, or a search's -// matches), by the model chosen in the OCR window. Worked out from the catalog, in memory, again when -// the catalog, the search or the model changes; at most once a second while a scan adds readings. +// matches), by the model chosen in the OCR window. Worked out from the catalog, in memory, on a +// background worker (app/background.jai), again when the catalog, the search or the model changes; +// at most once a second while a scan adds readings. The window shows the last numbers until then. STATS_WINDOW :: "Statistics"; STATS_TOP_CAPTURES :: 10; @@ -15,32 +16,54 @@ Stats_Year :: struct { year: string; captures, with_text, words: s64; } Stats_Capture :: struct { index: s32; words: s64; } Stats_Word :: struct { word: string; count: s64; } -stats_ui: struct { - window_open: bool; - - // What the numbers were worked out from; any change makes them stale. - model: string; // heap - catalog_generation, visible_generation: s64; - computed: bool; - computed_at: Apollo_Time; +// The numbers, worked out on a worker by stats_compute. Heap, with everything it holds. +Stats_Result :: struct { + model: string; compute_ms: float64; - captures, read, with_text: s64; lines, words, characters, cjk_characters, turned_lines, low_confidence_lines: s64; confidence_sum: float64; words_median, words_p90, words_p99, words_max: s64; buckets: [STATS_BUCKET_TOPS.count] s64; - years: [..] Stats_Year; // oldest first; year strings on the heap + years: [..] Stats_Year; // oldest first most_words: [..] Stats_Capture; // most first - common_words: [..] Stats_Word; // commonest first; words on the heap + common_words: [..] Stats_Word; // commonest first +} + +stats_ui: struct { + window_open: bool; + result: *Stats_Result; // the numbers shown; null until the first are back + + // What the last job was asked for; any change makes the numbers stale. + model: string; // heap + catalog_generation, visible_generation: s64; + requested: bool; + requested_at: Apollo_Time; }; stats_ui_shutdown :: () { - stats_free_results(); + stats_result_free(stats_ui.result); + stats_ui.result = null; free(stats_ui.model,, context.default_allocator); - array_free(stats_ui.years); - array_free(stats_ui.most_words); - array_free(stats_ui.common_words); +} + +// From background_frame: a job's numbers. The newest are shown; older ones are dropped. +stats_result_arrived :: (generation: s64, result: *Stats_Result) { + if generation != background.stats_generation { stats_result_free(result); return; } + stats_result_free(stats_ui.result); + stats_ui.result = result; +} + +stats_result_free :: (s: *Stats_Result) { + if !s return; + push_allocator(context.default_allocator); + free(s.model); + for s.years free(it.year); + for s.common_words free(it.word); + array_free(s.years); + array_free(s.most_words); + array_free(s.common_words); + free(s); } // The model the numbers are for: the one chosen in the OCR window. @@ -50,17 +73,31 @@ stats_model :: () -> string { } stats_ui_frame :: () { using ImGui; - if app.stats_and_exit && viewer.captures.count && viewer.catalog_loaded >= viewer.captures.count && !ocr_ui.thread_busy { - stats_compute(stats_model()); + // -stats: once the catalog and the search are settled, at once, on this thread. + if app.stats_and_exit && viewer.captures.count && viewer.catalog_loaded >= viewer.captures.count && !ocr_ui.thread_busy + && !viewer.visible_dirty && !background.searching { + items := background_snapshot_visible(); + stats_result_arrived(background.stats_generation, stats_compute(items, stats_model())); + array_free(items,, context.default_allocator); stats_log(); app.stats_and_exit = false; app.should_exit = true; } if !stats_ui.window_open return; + + // A new job when the numbers are stale and none is out: at most one a second. model := stats_model(); - stale := !stats_ui.computed || stats_ui.model != model + stale := !stats_ui.requested || stats_ui.model != model || stats_ui.catalog_generation != viewer.catalog_generation || stats_ui.visible_generation != viewer.visible_generation; - if stale && (!stats_ui.computed || to_float64_seconds(current_time_monotonic() - stats_ui.computed_at) >= 1) stats_compute(model); + if stale && !background.stats_out && !background.searching + && (!stats_ui.requested || to_float64_seconds(current_time_monotonic() - stats_ui.requested_at) >= 1) { + free(stats_ui.model,, context.default_allocator); + stats_ui.model = copy_string(model,, context.default_allocator); + stats_ui.catalog_generation, stats_ui.visible_generation = viewer.catalog_generation, viewer.visible_generation; + stats_ui.requested = true; + stats_ui.requested_at = current_time_monotonic(); + background_stats_start(background_snapshot_visible(), model); + } SetNextWindowSize(.{560, 640}, .FirstUseEver); if Begin(STATS_WINDOW, *stats_ui.window_open) { @@ -73,7 +110,8 @@ stats_ui_frame :: () { using ImGui; // The numbers, as the window shows them, to the log: for -stats. stats_log :: () { - s := *stats_ui; + s := stats_ui.result; + if !s return; log("[stats] % with %: % captures, % read, % with text (%), % lines, % words, % characters (% CJK), % at an angle, mean confidence %, % under %, worked out in % ms.", viewer_folder(), s.model, s.captures, s.read, s.with_text, stats_percent(s.with_text, s.read), s.lines, s.words, s.characters, s.cjk_characters, s.turned_lines, FF(s.confidence_sum / max(s.lines, 1), 3), s.low_confidence_lines, FF(STATS_LOW_CONFIDENCE, 1), FF(s.compute_ms, 1)); @@ -86,7 +124,8 @@ stats_log :: () { } stats_window_contents :: () { using ImGui; - s := *stats_ui; + s := stats_ui.result; + if !s { TextColored(app_theme.palette.dim, "Working them out..."); return; } shown := ifx viewer.search_terms.count then "the captures matching the search" else "the library"; TextWrapped("OCR with % over %: % captures.", s.model, shown, stats_number(s.captures)); if viewer.catalog_loaded < viewer.captures.count { @@ -171,17 +210,17 @@ stats_window_contents :: () { using ImGui; if SmallButton(label) viewer_set_search(tprint("ocr:%", it.word)); } Spacing(); - TextColored(app_theme.palette.dim, "Worked out in % ms.", FF(s.compute_ms, 1)); + TextColored(app_theme.palette.dim, "Worked out in % ms on a background thread%.", FF(s.compute_ms, 1), ifx background.stats_out then "; updating" else ""); } -// Recounts everything over viewer.visible for model. -stats_compute :: (model: string) { +#scope_export + +// Counts everything over items for model. On any thread: it reads only items and their catalog +// entries. The result, and all it holds, from the default allocator. +stats_compute :: (items: [] Search_Item, model: string) -> *Stats_Result { start := current_time_monotonic(); - s := *stats_ui; - stats_free_results(); - free(s.model,, context.default_allocator); + s := New(Stats_Result,, context.default_allocator); s.model = copy_string(model,, context.default_allocator); - s.catalog_generation, s.visible_generation = viewer.catalog_generation, viewer.visible_generation; s.years.allocator = context.default_allocator; s.most_words.allocator = context.default_allocator; s.common_words.allocator = context.default_allocator; @@ -193,9 +232,10 @@ stats_compute :: (model: string) { words: Table(string, s64); // keys in temp words.allocator = temp; - for index: viewer.visible { + for item: items { + index := item.index; s.captures += 1; - entry := viewer.captures[index].catalog; + entry := item.entry; year_text := ifx entry && entry.date.count >= 4 then slice(entry.date, 0, 4) else "undated"; year := find_or_add(*years, year_text); year.year = year_text; @@ -213,7 +253,7 @@ stats_compute :: (model: string) { x0, y0, x1, y1 := line.x, line.y, line.x + line.width, line.y + line.height; box := s32.[x0, y0, x1, y0, x1, y1, x0, y1]; for corners if it != box[it_index] { s.turned_lines += 1; break; } - capture_words += stats_count_line(line.text, *words); + capture_words += stats_count_line(s, line.text, *words); } s.words += capture_words; if section.lines.count { s.with_text += 1; year.with_text += 1; } @@ -246,15 +286,15 @@ stats_compute :: (model: string) { array_add(*s.common_words, .{copy_string(it.word,, context.default_allocator), it.count}); } - s.computed = true; - s.computed_at = current_time_monotonic(); - s.compute_ms = to_float64_seconds(s.computed_at - start) * 1000; + s.compute_ms = to_float64_seconds(current_time_monotonic() - start) * 1000; + return s; } +#scope_file + // Adds the line's characters to the counts, and its Latin words to words; returns its words (runs // between spaces, numbers and symbols included). -stats_count_line :: (text: string, words: *Table(string, s64)) -> s64 { - s := *stats_ui; +stats_count_line :: (s: *Stats_Result, text: string, words: *Table(string, s64)) -> s64 { count: s64; in_word := false; latin_end := 0; // the end of the last Latin word, which its hyphens and apostrophes belong to @@ -301,20 +341,6 @@ STATS_STOP_WORDS :: string.[ "got", "off", "per", "via", "yes", "new", ]; -stats_free_results :: () { - s := *stats_ui; - for s.years free(it.year,, context.default_allocator); - for s.common_words free(it.word,, context.default_allocator); - array_reset_keeping_memory(*s.years); - array_reset_keeping_memory(*s.most_words); - array_reset_keeping_memory(*s.common_words); - s.captures, s.read, s.with_text = 0, 0, 0; - s.lines, s.words, s.characters, s.cjk_characters, s.turned_lines, s.low_confidence_lines = 0, 0, 0, 0, 0, 0; - s.confidence_sum = 0; - s.words_median, s.words_p90, s.words_p99, s.words_max = 0, 0, 0, 0; - for * s.buckets it.* = 0; -} - // 874477 as "874,477". Temp. stats_number :: (n: s64) -> string { digits := tprint("%", abs(n)); diff --git a/src/app/viewer.jai b/src/app/viewer.jai index 3dab66e..500b89e 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -5,7 +5,8 @@ // // 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. +// newest first once the catalog is complete, and only those matching the search. Searching runs on +// background workers (app/background.jai), so the UI thread never waits on it. // // 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 @@ -165,6 +166,7 @@ viewer_startup :: () { thread_start(it); viewer.workers_started += 1; } + background_startup(); } // On the shutdown thread: stops the workers. The textures go with the device. @@ -174,6 +176,7 @@ viewer_shutdown :: () { unlock(*viewer.mutex); for 1..viewer.workers_started signal(*viewer.wake); for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1); + background_shutdown(); } // ---------------------------------------------------------------- the workers @@ -377,7 +380,7 @@ viewer_frame_gpu_work :: () { viewer_evict(); viewer_apply_catalog_updates(); - if viewer.visible_dirty viewer_rebuild_visible(); + background_frame(); // takes back search results, and starts a search when visible_dirty (app/background.jai) } // When a preview was last in view or wanted ahead of it. A prefetched preview has not been drawn @@ -399,7 +402,7 @@ viewer_apply_catalog_updates :: () { capture := *viewer.captures[it.index]; if !capture.catalog viewer.catalog_loaded += 1; if it.entry || !capture.catalog { - catalog_entry_free(capture.catalog); + background_retire(capture.catalog); // a search or Stats job may be reading it capture.catalog = it.entry; } } @@ -472,6 +475,9 @@ viewer_mark_removed :: (index: s32) { if found array_ordered_remove_by_index(*viewer.with_texture, position); } if viewer.full_index == index viewer.full_open = false; + // Out of the grid at once; the search that visible_dirty starts would take it out too, a frame or two later. + found, position := array_find(viewer.visible, index); + if found array_ordered_remove_by_index(*viewer.visible, position); viewer.catalog_generation += 1; viewer.visible_dirty = true; viewer.visible_rebuilt_at = .{}; @@ -629,26 +635,6 @@ viewer_search_changed :: () { viewer.visible_rebuilt_at = .{}; // at once, not throttled } -viewer_rebuild_visible :: () { - // While the catalog loads under a search, matches arrive every frame: a few rebuilds a second do. - if viewer.search_terms.count && !viewer.sorted_by_date && to_float64_seconds(current_time_monotonic() - viewer.visible_rebuilt_at) < 0.25 return; - viewer.visible_dirty = false; - viewer.visible_rebuilt_at = current_time_monotonic(); - viewer.visible_generation += 1; - - 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); - } - if viewer.sorted_by_date { - quick_sort(viewer.visible, (a: s32, b: s32) -> s64 { - ca, cb := *viewer.captures[a], *viewer.captures[b]; - return viewer_display_order(ifx ca.catalog then ca.catalog.date else "", ca.name, ifx cb.catalog then cb.catalog.date else "", cb.name); - }); - } -} // The grid's order: newest CaptureDate first, undated captures last, ties by name, newest first. viewer_display_order :: (date_a: string, name_a: string, date_b: string, name_b: string) -> s64 { @@ -766,6 +752,7 @@ viewer_ui :: () { using ImGui; SameLine(); if viewer.search_terms.count Text("% of % captures", viewer.visible.count, viewer_capture_count()); else Text("% captures", viewer_capture_count()); + if background.searching { SameLine(); TextColored(app_theme.palette.dim, "searching..."); } 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);