[0.0.4] Search and Stats on background threads, so typing never stalls the UI

This commit is contained in:
Musa Mahmood 2026-10-08 10:23:15 +01:00
parent 8c9870ac01
commit 3c6da5b53d
7 changed files with 353 additions and 78 deletions

View File

@ -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 - **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:` 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 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 - **Order:** newest first by capture date, once the catalog (what search needs from every file) has
loaded; by name until then. loaded; by name until then.
- **OCR** (`modules/OCR`): the OCR window shows whether it runs on the GPU and, if not, what to install, - **OCR** (`modules/OCR`): the OCR window shows whether it runs on the GPU and, if not, what to install,

View File

@ -29,7 +29,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.0.3"; VERSION :: "0.0.4";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.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 // 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. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -19,6 +19,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#load "app/ocr_ui.jai"; #load "app/ocr_ui.jai";
#load "app/catalog.jai"; #load "app/catalog.jai";
#load "app/stats_ui.jai"; #load "app/stats_ui.jai";
#load "app/background.jai";
#load "app/exit_shutdown.jai"; #load "app/exit_shutdown.jai";
#load "base/circle_buffer.jai"; #load "base/circle_buffer.jai";

259
src/app/background.jai Normal file
View File

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

View File

@ -1,6 +1,7 @@
// The Statistics window: what OCR found in the captures shown (the whole library, or a search's // 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 // matches), by the model chosen in the OCR window. Worked out from the catalog, in memory, on a
// the catalog, the search or the model changes; at most once a second while a scan adds readings. // 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_WINDOW :: "Statistics";
STATS_TOP_CAPTURES :: 10; 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_Capture :: struct { index: s32; words: s64; }
Stats_Word :: struct { word: string; count: s64; } Stats_Word :: struct { word: string; count: s64; }
stats_ui: struct { // The numbers, worked out on a worker by stats_compute. Heap, with everything it holds.
window_open: bool; Stats_Result :: struct {
model: string;
// 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;
compute_ms: float64; compute_ms: float64;
captures, read, with_text: s64; captures, read, with_text: s64;
lines, words, characters, cjk_characters, turned_lines, low_confidence_lines: s64; lines, words, characters, cjk_characters, turned_lines, low_confidence_lines: s64;
confidence_sum: float64; confidence_sum: float64;
words_median, words_p90, words_p99, words_max: s64; words_median, words_p90, words_p99, words_max: s64;
buckets: [STATS_BUCKET_TOPS.count] 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 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_ui_shutdown :: () {
stats_free_results(); stats_result_free(stats_ui.result);
stats_ui.result = null;
free(stats_ui.model,, context.default_allocator); 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. // 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; stats_ui_frame :: () { using ImGui;
if app.stats_and_exit && viewer.captures.count && viewer.catalog_loaded >= viewer.captures.count && !ocr_ui.thread_busy { // -stats: once the catalog and the search are settled, at once, on this thread.
stats_compute(stats_model()); 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(); stats_log();
app.stats_and_exit = false; app.stats_and_exit = false;
app.should_exit = true; app.should_exit = true;
} }
if !stats_ui.window_open return; 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(); 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; || 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); SetNextWindowSize(.{560, 640}, .FirstUseEver);
if Begin(STATS_WINDOW, *stats_ui.window_open) { 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. // The numbers, as the window shows them, to the log: for -stats.
stats_log :: () { 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.", 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, 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)); 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; 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"; 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)); TextWrapped("OCR with % over %: % captures.", s.model, shown, stats_number(s.captures));
if viewer.catalog_loaded < viewer.captures.count { 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)); if SmallButton(label) viewer_set_search(tprint("ocr:%", it.word));
} }
Spacing(); 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. #scope_export
stats_compute :: (model: string) {
// 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(); start := current_time_monotonic();
s := *stats_ui; s := New(Stats_Result,, context.default_allocator);
stats_free_results();
free(s.model,, context.default_allocator);
s.model = copy_string(model,, 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.years.allocator = context.default_allocator;
s.most_words.allocator = context.default_allocator; s.most_words.allocator = context.default_allocator;
s.common_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: Table(string, s64); // keys in temp
words.allocator = temp; words.allocator = temp;
for index: viewer.visible { for item: items {
index := item.index;
s.captures += 1; 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_text := ifx entry && entry.date.count >= 4 then slice(entry.date, 0, 4) else "undated";
year := find_or_add(*years, year_text); year := find_or_add(*years, year_text);
year.year = 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; 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]; box := s32.[x0, y0, x1, y0, x1, y1, x0, y1];
for corners if it != box[it_index] { s.turned_lines += 1; break; } 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; s.words += capture_words;
if section.lines.count { s.with_text += 1; year.with_text += 1; } 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}); array_add(*s.common_words, .{copy_string(it.word,, context.default_allocator), it.count});
} }
s.computed = true; s.compute_ms = to_float64_seconds(current_time_monotonic() - start) * 1000;
s.computed_at = current_time_monotonic(); return s;
s.compute_ms = to_float64_seconds(s.computed_at - start) * 1000;
} }
#scope_file
// Adds the line's characters to the counts, and its Latin words to words; returns its words (runs // Adds the line's characters to the counts, and its Latin words to words; returns its words (runs
// between spaces, numbers and symbols included). // between spaces, numbers and symbols included).
stats_count_line :: (text: string, words: *Table(string, s64)) -> s64 { stats_count_line :: (s: *Stats_Result, text: string, words: *Table(string, s64)) -> s64 {
s := *stats_ui;
count: s64; count: s64;
in_word := false; in_word := false;
latin_end := 0; // the end of the last Latin word, which its hyphens and apostrophes belong to 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", "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. // 874477 as "874,477". Temp.
stats_number :: (n: s64) -> string { stats_number :: (n: s64) -> string {
digits := tprint("%", abs(n)); digits := tprint("%", abs(n));

View File

@ -5,7 +5,8 @@
// //
// When the workers have no previews to decode they load the catalog (app/catalog.jai), what search // 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, // 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 // 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 // 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); thread_start(it);
viewer.workers_started += 1; viewer.workers_started += 1;
} }
background_startup();
} }
// On the shutdown thread: stops the workers. The textures go with the device. // On the shutdown thread: stops the workers. The textures go with the device.
@ -174,6 +176,7 @@ viewer_shutdown :: () {
unlock(*viewer.mutex); unlock(*viewer.mutex);
for 1..viewer.workers_started signal(*viewer.wake); for 1..viewer.workers_started signal(*viewer.wake);
for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1); for 0..viewer.workers_started - 1 thread_is_done(*viewer.workers[it], -1);
background_shutdown();
} }
// ---------------------------------------------------------------- the workers // ---------------------------------------------------------------- the workers
@ -377,7 +380,7 @@ viewer_frame_gpu_work :: () {
viewer_evict(); viewer_evict();
viewer_apply_catalog_updates(); 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 // 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]; capture := *viewer.captures[it.index];
if !capture.catalog viewer.catalog_loaded += 1; if !capture.catalog viewer.catalog_loaded += 1;
if it.entry || !capture.catalog { 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; 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 found array_ordered_remove_by_index(*viewer.with_texture, position);
} }
if viewer.full_index == index viewer.full_open = false; 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.catalog_generation += 1;
viewer.visible_dirty = true; viewer.visible_dirty = true;
viewer.visible_rebuilt_at = .{}; viewer.visible_rebuilt_at = .{};
@ -629,26 +635,6 @@ viewer_search_changed :: () {
viewer.visible_rebuilt_at = .{}; // at once, not throttled 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. // 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 { 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(); SameLine();
if viewer.search_terms.count Text("% of % captures", viewer.visible.count, viewer_capture_count()); if viewer.search_terms.count Text("% of % captures", viewer.visible.count, viewer_capture_count());
else Text("% captures", 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 { if viewer.catalog_loaded < viewer.captures.count {
SameLine(); SameLine();
TextColored(app_theme.palette.dim, "(loading the catalog: % of %; sorted by name until then)", viewer.catalog_loaded, viewer.captures.count); TextColored(app_theme.palette.dim, "(loading the catalog: % of %; sorted by name until then)", viewer.catalog_loaded, viewer.captures.count);