mcap/src/app/viewer.jai

979 lines
42 KiB
Plaintext

// The capture viewer: a grid of every .mcap's preview, and one capture at full size in a window of
// its own. Scrolling never waits on the disk: previews are read and decoded on worker threads,
// those in view first and then the rows around them, and are uploaded a few per frame. Textures
// for previews far out of view are released once there are more than VIEWER_MAX_TEXTURES.
//
// 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. 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
// appends the new ones.
VIEWER_FOLDER :: "D:/SnagitIndex/mcap"; // hardcoded for now; -library FOLDER shows another
viewer_folder :: () -> string {
return ifx app.library_folder then app.library_folder else VIEWER_FOLDER;
}
VIEWER_WORKER_COUNT :: 4;
VIEWER_MAX_TEXTURES :: 1200; // previews on the GPU at once, about 200 KB each
VIEWER_PREFETCH_ROWS :: 8; // most rows decoded ahead of the view, above and below
VIEWER_UPLOAD_BUDGET_MS :: 4.0; // time per frame spent uploading previews
VIEWER_FULL_WINDOW :: "Capture";
VIEWER_MIN_ZOOM :: 5.0; // percent, for the full-size window
VIEWER_MAX_ZOOM :: 800.0;
GPU_TEXTURE_MAX_SIDE :: 16384; // what D3D11 guarantees; larger images are scaled down to fit
// A texture made by gpu_texture_create (graphics/DX11.jai, base/platform/sdl.jai).
Gpu_Texture :: struct {
handle: *void; // the backend's own: an ID3D11ShaderResourceView or an SDL_GPUTexture
width, height: s32;
}
// Bumped when the renderer moves to another device (DX11's WARP-first startup, or device loss),
// which takes every texture with it.
g_texture_generation: u64;
gpu_texture_ref :: (texture: Gpu_Texture) -> ImGui.ImTextureRef {
return .{null, cast(ImGui.ImTextureID) texture.handle};
}
Viewer_Capture :: struct {
name: string; // the file name without .mcap
path: string;
texture: Gpu_Texture; // its preview, once uploaded
last_drawn_frame: u64;
last_wanted_frame: u64; // last in the rows viewer_request_previews asked for
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.
Catalog_Update :: struct {
index: s32;
entry: *Catalog_Entry; // null when it could not be loaded
}
// How the full-size window sizes its image.
Viewer_Fit :: enum u8 {
OFF; // at the zoom the slider sets
WINDOW; // the whole image in the window
WIDTH; // the window's width, scrolling down
HEIGHT; // the window's height, scrolling across
}
// Pixels a worker decoded, waiting for the main thread to upload them.
Decoded :: struct {
index: s32;
pixels: *u8; // RGBA; from stb_image, or from the default allocator when scaled down
scaled: bool; // pixels came from the default allocator
width, height: s32;
source_width, source_height: s32; // before any scaling down
failed: bool;
}
viewer: struct {
captures: [..] Viewer_Capture;
scan_error: string;
thumb_size: float = 200;
frame: u64;
texture_generation: u64;
with_texture: [..] s32; // captures holding a preview texture
// Shared with the workers: everything below up to the full view, under mutex.
mutex: Mutex;
wake: Semaphore;
queue: [..] s32; // previews wanted, nearest the view first
queue_next: s64;
done: [..] Decoded;
full_wanted: s32 = -1;
full_request: u64; // bumped for every new full-size request
full_taken: u64; // the request a worker last took
full_done: Decoded;
full_done_request: u64; // 0 while there is none
quit: bool;
workers: [VIEWER_WORKER_COUNT] Thread;
workers_started: s64;
catalog_next: s64; // the next capture whose catalog entry a worker loads
catalog_updates: [..] Catalog_Update; // made, waiting for the main thread
// Display order and search; main thread only.
visible: [..] s32; // indices into captures, as the grid shows them
visible_dirty := true;
visible_rebuilt_at: Apollo_Time;
catalog_loaded: s64; // entries put in place, or found unreadable
removed_count: s64; // captures marked removed
delete_index: s32 = -1; // the capture whose delete is being confirmed
delete_without_asking: bool; // until the app restarts
delete_mode: Viewer_Delete_Mode; // the last chosen, until the app restarts
catalog_generation: s64; // counts changes to catalog entries (loading, an OCR reading stored)
visible_generation: s64; // counts rebuilds of visible
sorted_by_date: bool;
search_buffer: [256] u8;
search_terms: [..] Search_Term;
// The full view; main thread only.
full_open: bool;
full_index: s32 = -1; // the capture shown, or being loaded
full_texture: Gpu_Texture;
full_source_width, full_source_height: s32;
full_loading: bool;
full_failed: bool;
full_fit := Viewer_Fit.WINDOW;
full_zoom: float = 100; // percent of the size it was captured at; set by the fit, unless OFF
}
// ---------------------------------------------------------------- startup and shutdown
viewer_startup :: () {
push_allocator(context.default_allocator);
viewer.captures.allocator = context.default_allocator;
viewer.with_texture.allocator = context.default_allocator;
viewer.queue.allocator = context.default_allocator;
viewer.done.allocator = context.default_allocator;
viewer.catalog_updates.allocator = context.default_allocator;
viewer.visible.allocator = context.default_allocator;
viewer.search_terms.allocator = context.default_allocator;
init(*viewer.mutex, "viewer");
init(*viewer.wake);
start := current_time_monotonic();
files := file_list(viewer_folder(),, temp);
for files {
name := path_filename(it);
if !ends_with_nocase(name, tprint(".%", MCAP_EXTENSION)) continue;
capture := array_add(*viewer.captures);
capture.path = copy_string(it);
capture.name = copy_string(path_strip_extension(name));
}
// Newest first, roughly: dated names (2026-...) sort after the older {GUID} ones.
quick_sort(viewer.captures, (a: Viewer_Capture, b: Viewer_Capture) -> s64 { return compare(b.name, a.name); });
if !viewer.captures.count viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder());
log("[viewer] Found % captures in % in % ms.", viewer.captures.count, viewer_folder(), FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
for * viewer.workers {
// Reading a front takes 64 KB of temporary storage, and a full image several MB.
if !thread_init(it, viewer_worker, temporary_storage_size = 1024 * 1024, name = tprint("viewer %", it_index)) {
log_error("[viewer] Could not start worker %.", it_index);
continue;
}
thread_start(it);
viewer.workers_started += 1;
}
background_startup();
}
// On the shutdown thread: stops the workers. The textures go with the device.
viewer_shutdown :: () {
lock(*viewer.mutex);
viewer.quit = true;
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
viewer_worker :: (thread: *Thread) -> s64 {
while true {
wait_for(*viewer.wake, 250);
while true {
index: s32 = -1;
full: bool;
catalog: bool;
request: u64;
path: string;
lock(*viewer.mutex);
if viewer.quit { unlock(*viewer.mutex); return 0; }
if viewer.full_request != viewer.full_taken {
full = true;
request = viewer.full_request;
viewer.full_taken = request;
index = viewer.full_wanted;
} else if viewer.queue_next < viewer.queue.count {
index = viewer.queue[viewer.queue_next];
viewer.queue_next += 1;
} else if viewer.catalog_next < viewer.captures.count {
// Nothing to show is waiting: load the next catalog entry.
catalog = true;
index = cast(s32) viewer.catalog_next;
viewer.catalog_next += 1;
}
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
if catalog {
entry, error := catalog_load(path);
if !entry log_error("[catalog] % %", path, error);
reset_temporary_storage();
lock(*viewer.mutex);
array_add(*viewer.catalog_updates, .{index, entry});
unlock(*viewer.mutex);
continue;
}
result := ifx full then viewer_decode_full(path) else viewer_decode_preview(path);
result.index = index;
lock(*viewer.mutex);
if full {
if request == viewer.full_request { // still the one wanted
viewer_free_pixels(*viewer.full_done);
viewer.full_done = result;
viewer.full_done_request = request;
} else {
viewer_free_pixels(*result);
}
} else {
array_add(*viewer.done, result);
}
unlock(*viewer.mutex);
}
}
return 0;
}
// The preview at the front of the file: only the header, text and preview are read.
viewer_decode_preview :: (path: string) -> Decoded {
result: Decoded;
result.failed = true;
push_allocator(temp);
defer reset_temporary_storage();
m, ok, error := mcap_read_front(path);
if !ok { log_error("[viewer] % %", path, error); return result; }
if !m.preview.png.count { log_error("[viewer] % has no preview.", path); return result; }
return viewer_decode_png(path, m.preview.png);
}
// The rendered image, annotations and all, or the original when there is none.
viewer_decode_full :: (path: string) -> Decoded {
result: Decoded;
result.failed = true;
push_allocator(temp);
defer reset_temporary_storage();
data, read_ok := read_entire_file(path, log_errors = false);
if !read_ok { log_error("[viewer] Cannot read %.", path); return result; }
m, ok, error := mcap_parse(cast([] u8) data);
if !ok { log_error("[viewer] % %", path, error); return result; }
png := mcap_rendered(m);
if !png.count png = m.original;
return viewer_decode_png(path, png);
}
viewer_decode_png :: (path: string, png: [] u8) -> Decoded {
result: Decoded;
result.failed = true;
width, height, channels: s32;
pixels := stbi_load_from_memory(png.data, cast(s32) png.count, *width, *height, *channels, 4);
if !pixels || width <= 0 || height <= 0 {
log_error("[viewer] Cannot decode the image in %.", path);
if pixels stbi_image_free(pixels);
return result;
}
result.pixels = pixels;
result.width, result.height = width, height;
result.source_width, result.source_height = width, height;
result.failed = false;
// Taller or wider than a texture can be (long scrolling captures): scaled down to fit.
longest := max(width, height);
if longest > GPU_TEXTURE_MAX_SIDE {
scale := cast(float64) GPU_TEXTURE_MAX_SIDE / longest;
out_width := max(cast(s32) 1, cast(s32) (width * scale));
out_height := max(cast(s32) 1, cast(s32) (height * scale));
scaled := cast(*u8) alloc(cast(s64) out_width * out_height * 4,, context.default_allocator);
if stbir_resize_uint8_srgb(pixels, width, height, 0, scaled, out_width, out_height, 0, 4, 3, 0) {
stbi_image_free(pixels);
result.pixels = scaled;
result.scaled = true;
result.width, result.height = out_width, out_height;
} else {
free(scaled,, context.default_allocator);
stbi_image_free(pixels);
result.pixels = null;
result.failed = true;
log_error("[viewer] Cannot scale down the % x % image in %.", width, height, path);
}
}
return result;
}
viewer_free_pixels :: (d: *Decoded) {
if d.pixels {
if d.scaled free(d.pixels,, context.default_allocator);
else stbi_image_free(d.pixels);
}
d.pixels = null;
}
// ---------------------------------------------------------------- each frame: GPU work
// Inside the frame, before anything is drawn: the device can only have changed before this point,
// and nothing released here has been drawn yet this frame.
viewer_frame_gpu_work :: () {
viewer.frame += 1;
if viewer.texture_generation != g_texture_generation {
viewer.texture_generation = g_texture_generation;
// Made on the old device: released, so it can go, and made again as they come into view.
for viewer.with_texture gpu_texture_release(*viewer.captures[it].texture);
array_reset_keeping_memory(*viewer.with_texture);
if viewer.full_texture.handle {
gpu_texture_release(*viewer.full_texture);
viewer_request_full(viewer.full_index);
}
}
// The full-size image, when one has arrived.
lock(*viewer.mutex);
full := viewer.full_done;
arrived := viewer.full_done_request != 0 && viewer.full_done_request == viewer.full_request;
if viewer.full_done_request != 0 {
viewer.full_done = .{};
viewer.full_done_request = 0;
}
unlock(*viewer.mutex);
if arrived {
gpu_texture_release(*viewer.full_texture);
viewer.full_loading = false;
viewer.full_failed = full.failed;
if !full.failed {
viewer.full_texture = gpu_texture_create(full.pixels, full.width, full.height);
viewer.full_failed = !viewer.full_texture.handle;
viewer.full_source_width, viewer.full_source_height = full.source_width, full.source_height;
}
}
viewer_free_pixels(*full);
// Previews, until the frame's budget is spent; the rest wait for the next frame.
start := current_time_monotonic();
while to_float64_seconds(current_time_monotonic() - start) * 1000.0 < VIEWER_UPLOAD_BUDGET_MS {
lock(*viewer.mutex);
if !viewer.done.count { unlock(*viewer.mutex); break; }
d := pop(*viewer.done);
viewer.captures[d.index].in_flight = false;
unlock(*viewer.mutex);
capture := *viewer.captures[d.index];
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);
if capture.texture.handle array_add(*viewer.with_texture, d.index);
else capture.failed = true;
}
viewer_free_pixels(*d);
}
viewer_evict();
viewer_apply_catalog_updates();
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
// yet; going by the frame it was drawn alone, it would be released first and decoded again at once.
viewer_last_used :: (capture: Viewer_Capture) -> u64 {
return max(capture.last_drawn_frame, capture.last_wanted_frame);
}
// Catalog entries made by the workers (or by an OCR scan) go in place here, on the main thread,
// which is the only one that reads them; a replaced entry is freed now, before anything draws it.
viewer_apply_catalog_updates :: () {
lock(*viewer.mutex);
updates := array_copy(viewer.catalog_updates,, temp);
array_reset_keeping_memory(*viewer.catalog_updates);
more_to_load := viewer.catalog_next < viewer.captures.count;
unlock(*viewer.mutex);
for updates {
capture := *viewer.captures[it.index];
if !capture.catalog viewer.catalog_loaded += 1;
if it.entry || !capture.catalog {
background_retire(capture.catalog); // a search or Stats job may be reading it
capture.catalog = it.entry;
}
}
if updates.count viewer.catalog_generation += 1;
// 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;
}
// The workers sleep when there is nothing to show; wake them while there is catalog to load.
if more_to_load signal(*viewer.wake);
}
// 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; }
viewer_mark_removed(cast(s32) index);
removed += 1;
}
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
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);
}
// Takes a capture out of everything shown, as though its file had never been there; its index stays.
viewer_mark_removed :: (index: s32) {
capture := *viewer.captures[index];
if capture.removed return;
capture.removed = true;
viewer.removed_count += 1;
if capture.texture.handle {
gpu_texture_release(*capture.texture);
found, position := array_find(viewer.with_texture, index);
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 = .{};
}
Viewer_Delete_Mode :: enum u8 {
RECYCLE; // to the Recycle Bin, or the deleted folder elsewhere: can be undone
OVERWRITE; // overwritten with random bytes, renamed, then deleted for good
}
// Deletes the capture's file, then takes it out of the grid. RECYCLE: to the Recycle Bin on Windows
// (Windows asks first when the drive has none and it would be deleted for good), elsewhere into a
// deleted folder beside it. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
// is still there.
viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string {
capture := *viewer.captures[index];
if mode == .OVERWRITE {
ok, error := viewer_overwrite_and_delete(capture.path);
if !ok return false, error;
} else #if OS == .WINDOWS {
path := replace(capture.path, "/", "\\",, temp);
wide, ok, bytes := utf8_to_wide(path,, temp);
if !ok return false, "its path cannot be converted";
from := NewArray(bytes / 2 + 2, u16, initialized = true,, temp); // two nulls at the end
memcpy(from.data, wide, bytes);
operation := SHFILEOPSTRUCTW.{wFunc = FO_DELETE, pFrom = from.data, fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_WANTNUKEWARNING | FOF_SILENT | FOF_NOERRORUI};
result := SHFileOperationW(*operation);
if operation.fAnyOperationsAborted return false, "cancelled";
if result != 0 return false, tprint("the shell could not delete it (error 0x%)", formatInt(result, base = 16));
} else {
folder := tprint("%/deleted", viewer_folder());
make_directory_if_it_does_not_exist(folder);
if !file_move(capture.path, tprint("%/%", folder, path_filename(capture.path))) return false, tprint("it could not be moved to %", folder);
}
if file_exists(capture.path) return false, "the file is still there";
log("[viewer] Deleted % (%).", capture.path, ifx mode == .OVERWRITE then "overwritten, then deleted for good" else tprint("to the %", VIEWER_TRASH_NAME));
viewer_mark_removed(index);
return true, "";
}
// Writes random bytes over the whole file in place, makes sure they reach the disk, renames it to a
// random name (so its name is not left in the folder's records either) and deletes it. What this
// cannot reach: copies elsewhere, and on an SSD, old blocks the drive keeps out of sight.
viewer_overwrite_and_delete :: (path: string) -> ok: bool, error: string {
file, opened := file_open(path, for_writing = true, keep_existing_content = true, log_errors = false);
if !opened return false, "it could not be opened for writing (is another program using it?)";
length := file_length(file);
CHUNK :: 1024 * 1024;
noise := NewArray(CHUNK / 8, u64,, context.default_allocator);
defer array_free(noise,, context.default_allocator);
random_seed(cast(u64) to_nanoseconds(current_time_consensus()));
written: s64;
failed := false;
while written < length {
for * noise it.* = random_get();
count := min(CHUNK, length - written);
if !file_write(*file, noise.data, count) { failed = true; break; }
written += count;
}
#if OS == .WINDOWS {
if !failed && !FlushFileBuffers(file.handle) failed = true; // to the disk, not just the cache
}
file_close(*file);
if failed return false, tprint("overwriting it failed after % of % bytes; it is partly overwritten and still there", written, length);
hidden := tprint("%/%.deleting", path_strip_filename(path), formatInt(cast(s64) (random_get() & 0x7FFF_FFFF_FFFF_FFFF), base = 16));
target := path;
if file_move(path, hidden) target = hidden;
if !file_delete(target) return false, tprint("it was overwritten, but could not be deleted (%)", target);
return true, "";
}
// Deletes now, the way last chosen, or asks first.
viewer_request_delete :: (index: s32) {
if viewer.delete_without_asking {
viewer_delete_now(index);
return;
}
viewer.delete_index = index;
}
viewer_delete_now :: (index: s32) {
name := viewer.captures[index].name;
ok, error := viewer_delete_capture(index, viewer.delete_mode);
if !ok toast_show(.Error, "Could not delete %: %", name, error);
else if viewer.delete_mode == .RECYCLE toast_show(.Success, "Moved % to the %", name, VIEWER_TRASH_NAME);
else toast_show(.Success, "Overwrote and deleted %", name);
}
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else VIEWER_TRASH_NAME :: "deleted folder";
// The confirmation for viewer.delete_index, as a modal popup.
viewer_delete_popup :: () { using ImGui;
POPUP :: "Delete capture?";
if viewer.delete_index >= 0 && !IsPopupOpen(POPUP) OpenPopup(POPUP);
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
index := viewer.delete_index;
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed {
viewer.delete_index = -1;
CloseCurrentPopup();
EndPopup();
return;
}
capture := *viewer.captures[index];
Text("Delete %?", capture.name);
if capture.catalog && capture.catalog.date TextColored(app_theme.palette.dim, "Captured %", capture.catalog.date);
TextColored(app_theme.palette.dim, "%", capture.path);
Spacing();
mode := cast(s32) viewer.delete_mode;
RadioButton(temp_c_string(tprint("Move it to the %", VIEWER_TRASH_NAME)), *mode, xx Viewer_Delete_Mode.RECYCLE);
RadioButton("Delete it permanently, overwriting it first", *mode, xx Viewer_Delete_Mode.OVERWRITE);
viewer.delete_mode = cast(Viewer_Delete_Mode) mode;
if viewer.delete_mode == .OVERWRITE {
PushTextWrapPos(GetFontSize() * 34);
TextColored(app_theme.palette.warning, "This cannot be undone. The file is overwritten with random bytes, renamed and deleted. Copies elsewhere are untouched: the .snagx it was converted from, backups and synced versions. On an SSD, the drive may keep old blocks out of reach.");
PopTextWrapPos();
}
Checkbox("Don't ask again until I restart", *viewer.delete_without_asking);
Spacing();
if Button(temp_c_string(ifx viewer.delete_mode == .OVERWRITE then "Delete permanently" else "Delete")) || IsKeyPressed(.Enter) {
viewer.delete_index = -1;
CloseCurrentPopup();
viewer_delete_now(index);
}
SameLine();
if Button("Cancel") || IsKeyPressed(.Escape) {
viewer.delete_without_asking = false;
viewer.delete_index = -1;
CloseCurrentPopup();
}
EndPopup();
}
// 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);
memcpy(viewer.search_buffer.data, query.data, count);
viewer.search_buffer[count] = 0;
viewer_search_changed();
}
viewer_search_changed :: () {
query := to_string(viewer.search_buffer.data);
terms := search_parse(query);
for viewer.search_terms free(it.text);
array_reset_keeping_memory(*viewer.search_terms);
for terms array_add(*viewer.search_terms, .{it.field, copy_string(it.text)});
viewer.visible_dirty = true;
viewer.visible_rebuilt_at = .{}; // at once, not throttled
}
// 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 {
if date_a != date_b {
if !date_a.count return 1;
if !date_b.count return -1;
return compare(date_b, date_a);
}
return compare(name_b, name_a);
}
// Where index is in the display order, or -1.
viewer_visible_position :: (index: s32) -> s64 {
for viewer.visible if it == index return it_index;
return -1;
}
// Past VIEWER_MAX_TEXTURES, the previews used longest ago are released, never one in view or
// wanted around it as of the last frame.
viewer_evict :: () {
if viewer.with_texture.count <= VIEWER_MAX_TEXTURES return;
quick_sort(viewer.with_texture, (a: s32, b: s32) -> s64 {
fa, fb := viewer_last_used(viewer.captures[a]), viewer_last_used(viewer.captures[b]);
if fa == fb return 0;
return ifx fa < fb then -1 else 1;
});
target := VIEWER_MAX_TEXTURES * 3 / 4;
removed := 0;
for viewer.with_texture {
if viewer.with_texture.count - removed <= target break;
capture := *viewer.captures[it];
if viewer_last_used(capture.*) >= viewer.frame - 1 break; // still wanted: the rest are newer still
gpu_texture_release(*capture.texture);
removed += 1;
}
for 0..viewer.with_texture.count - removed - 1 viewer.with_texture[it] = viewer.with_texture[it + removed];
viewer.with_texture.count -= removed;
}
// What the workers should decode next: the rows in view, then the rows around them, nearest first.
viewer_request_previews :: (first_row: s64, last_row: s64, columns: s64) {
if !viewer.visible.count || columns <= 0 return;
rows := (viewer.visible.count + columns - 1) / columns;
// Rows ahead only as far as the textures they need leave half of VIEWER_MAX_TEXTURES spare, so
// what is wanted always fits and nothing wanted is ever released to make room.
visible_rows := last_row - first_row + 1;
rows_that_fit := VIEWER_MAX_TEXTURES / 2 / columns - visible_rows;
prefetch_rows := clamp(rows_that_fit / 2, 0, VIEWER_PREFETCH_ROWS);
wanted: [..] s32;
wanted.allocator = temp;
add_row :: (wanted: *[..] s32, row: s64, columns: s64) {
for column: 0..columns - 1 {
position := row * columns + column;
if position >= viewer.visible.count break;
index := viewer.visible[position];
capture := *viewer.captures[index];
capture.last_wanted_frame = viewer.frame; // kept by viewer_evict, texture or not
if !capture.texture.handle && !capture.failed array_add(wanted, cast(s32) index);
}
}
for row: max(first_row, 0)..min(last_row, rows - 1) add_row(*wanted, row, columns);
for distance: 1..prefetch_rows {
if last_row + distance < rows add_row(*wanted, last_row + distance, columns); // scrolling down is likelier
if first_row - distance >= 0 add_row(*wanted, first_row - distance, columns);
}
added := 0;
lock(*viewer.mutex);
// Whatever was queued and not yet taken is let go; what is still wanted is queued again.
for viewer.queue_next..viewer.queue.count - 1 viewer.captures[viewer.queue[it]].in_flight = false;
array_reset_keeping_memory(*viewer.queue);
viewer.queue_next = 0;
for wanted {
capture := *viewer.captures[it];
if capture.in_flight continue; // a worker has it
capture.in_flight = true;
array_add(*viewer.queue, it);
added += 1;
}
unlock(*viewer.mutex);
for 1..min(added, VIEWER_WORKER_COUNT) signal(*viewer.wake);
}
viewer_request_full :: (index: s32) {
if index < 0 || index >= viewer.captures.count return;
viewer.full_index = index;
viewer.full_open = true;
viewer.full_loading = true;
viewer.full_failed = false;
lock(*viewer.mutex);
viewer.full_wanted = index;
viewer.full_request += 1;
unlock(*viewer.mutex);
signal(*viewer.wake);
}
// ---------------------------------------------------------------- drawing
viewer_ui :: () { using ImGui;
viewer_frame_gpu_work();
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_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);
}
SameLine();
SetNextItemWidth(160);
SliderFloat("Size", *viewer.thumb_size, 64, MCAP_PREVIEW_MAX_SIDE, "%.0f px");
SameLine();
// The OCR window (app/ocr_ui.jai): the model, GPU or CPU, and the library scan.
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
if engine SetTooltip("OCR with %, on %", engine.model.name, ifx engine.provider == .CUDA then "the GPU" else "the CPU");
else SetTooltip("OCR is starting, or could not start; the OCR window says which");
}
// A running OCR scan, as a thin bar under the search, so it shows with the OCR window closed.
if ocr_ui.scan.running {
done, total, failed, rate, left, elapsed := ocr_scan_progress();
ProgressBar(cast(float) done / max(total, 1), .{-FLOAT32_MIN, max(2.0, GetFontSize() * 0.15)}, "");
if IsItemHovered() SetTooltip("OCR scan with %: %, % elapsed, % left, % failed%", ocr_ui.scan.model, ocr_scan_progress_text(done, total), ocr_duration_text(elapsed), ocr_duration_text(left), failed, ifx ocr_ui.scan.paused then " (paused)" else "");
}
BeginChild("grid");
viewer_grid();
EndChild();
viewer_delete_popup();
viewer_full_window();
}
// The trash button's square in a thumbnail whose top left is at top_left.
viewer_trash_rect :: (top_left: ImGui.ImVec2, cell: float) -> ImGui.ImVec2, ImGui.ImVec2 {
size := clamp(cell * 0.16, 18, 30);
margin: float = 4;
min := ImGui.ImVec2.{top_left.x + cell - size - margin, top_left.y + margin};
return min, .{min.x + size, min.y + size};
}
// A trash can: lid, handle and a body with two ribs, on a dark square, red when the mouse is on it.
viewer_draw_trash :: (draw: *ImGui.ImDrawList, p0: ImGui.ImVec2, p1: ImGui.ImVec2, active: bool) {
using ImGui;
background := ifx active then ImGui.ColorConvertFloat4ToU32(.{0.80, 0.15, 0.15, 0.95}) else ImGui.ColorConvertFloat4ToU32(.{0, 0, 0, 0.65});
ink := ImGui.ColorConvertFloat4ToU32(.{1, 1, 1, 1});
draw.AddRectFilled(draw, p0, p1, background, 4);
w := p1.x - p0.x;
x0, x1 := p0.x + w * 0.28, p1.x - w * 0.28;
lid := p0.y + w * 0.30;
thick := max(1.0, w / 14);
draw.AddLine(draw, .{p0.x + w * 0.20, lid}, .{p1.x - w * 0.20, lid}, ink, thick); // lid
draw.AddRect(draw, .{p0.x + w * 0.40, p0.y + w * 0.18}, .{p1.x - w * 0.40, lid}, ink, 0, thick); // handle
draw.AddRect(draw, .{x0, lid + thick}, .{x1, p1.y - w * 0.18}, ink, 0, thick); // body
middle := (x0 + x1) / 2;
draw.AddLine(draw, .{middle, lid + w * 0.14}, .{middle, p1.y - w * 0.28}, ink, thick); // rib
}
viewer_grid :: () { using ImGui;
style := GetStyle();
cell := viewer.thumb_size;
label := GetTextLineHeight();
available := GetContentRegionAvail().x;
columns := max(1, cast(s64) ((available + style.ItemSpacing.x) / (cell + style.ItemSpacing.x)));
rows := (viewer.visible.count + columns - 1) / columns;
row_height := cell + label + style.ItemSpacing.y;
draw := GetWindowDrawList();
placeholder := GetColorU32(.FrameBg);
outline := GetColorU32(.ButtonHovered);
selected := GetColorU32(.CheckMark);
text_color := GetColorU32(.Text);
can_delete := !ocr_ui.thread_busy; // a scan or a reading refers to captures by index
first_row, last_row: s64 = 0x7FFF_FFFF_FFFF_FFFF, -1;
clipper: ListClipper;
clipper.Constructor(*clipper);
defer clipper.Destructor(*clipper);
clipper.Begin(*clipper, xx rows, row_height);
while clipper.Step(*clipper) {
first_row = min(first_row, cast(s64) clipper.DisplayStart);
last_row = max(last_row, cast(s64) clipper.DisplayEnd - 1);
for row: clipper.DisplayStart..clipper.DisplayEnd - 1 {
for column: 0..columns - 1 {
position := row * columns + column;
if position >= viewer.visible.count break;
index := viewer.visible[position];
capture := *viewer.captures[index];
if column > 0 SameLine();
top_left := GetCursorScreenPos();
PushID(cast(s32) index);
clicked := InvisibleButton("cell", .{cell, cell + label});
hovered := IsItemHovered();
PopID();
bottom_right := ImVec2.{top_left.x + cell, top_left.y + cell};
if capture.texture.handle {
// Fitted inside the square, keeping its shape.
w, h := cast(float) capture.texture.width, cast(float) capture.texture.height;
scale := min(cell / w, cell / h);
size := ImVec2.{w * scale, h * scale};
offset := ImVec2.{top_left.x + (cell - size.x) / 2, top_left.y + (cell - size.y) / 2};
draw.AddImage(draw, gpu_texture_ref(capture.texture), offset, .{offset.x + size.x, offset.y + size.y});
capture.last_drawn_frame = viewer.frame;
} else {
draw.AddRectFilled(draw, top_left, bottom_right, placeholder);
if capture.failed draw.AddText(draw, .{top_left.x + 6, top_left.y + 6}, text_color, "Cannot read");
}
if index == viewer.full_index draw.AddRect(draw, top_left, bottom_right, selected, 0, 3);
else if hovered draw.AddRect(draw, top_left, bottom_right, outline, 0, 2);
// Delete: a trash button in the top-right corner while hovered. Drawn, not a widget of its
// own, which would break the grid's SameLine layout; a click on it is the cell's click.
on_trash := false;
if hovered && can_delete {
trash_min, trash_max := viewer_trash_rect(top_left, cell);
on_trash = IsMouseHoveringRect(trash_min, trash_max);
viewer_draw_trash(draw, trash_min, trash_max, on_trash);
}
label_min := ImVec2.{top_left.x, top_left.y + cell};
label_max := ImVec2.{top_left.x + cell, top_left.y + cell + label};
draw.PushClipRect(draw, label_min, label_max, true);
draw.AddText(draw, label_min, text_color, capture.name);
draw.PopClipRect(draw);
if on_trash {
if viewer.delete_mode == .OVERWRITE && viewer.delete_without_asking SetTooltip("Delete % permanently, overwriting it first", capture.name);
else if viewer.delete_without_asking SetTooltip("Move % to the %", capture.name, VIEWER_TRASH_NAME);
else SetTooltip("Delete %", capture.name);
}
else if hovered SetTooltip("%", capture.name);
if clicked && on_trash viewer_request_delete(cast(s32) index);
else if clicked viewer_request_full(cast(s32) index);
}
}
}
clipper.End(*clipper);
if last_row >= 0 viewer_request_previews(first_row, last_row, columns);
}
viewer_full_window :: () { using ImGui;
if !viewer.full_open {
if viewer.full_texture.handle gpu_texture_release(*viewer.full_texture); // only while shown
return;
}
SetNextWindowSize(.{1000, 700}, .FirstUseEver);
if Begin(VIEWER_FULL_WINDOW, *viewer.full_open) {
capture := *viewer.captures[viewer.full_index];
Text("%", capture.name);
if viewer.full_texture.handle && !viewer.full_loading {
SameLine();
TextColored(app_theme.palette.dim, "% x %", viewer.full_source_width, viewer.full_source_height);
}
if viewer.full_loading { SameLine(); TextColored(app_theme.palette.dim, "Loading..."); }
TextColored(app_theme.palette.dim, "Left / Right: previous / next");
// Zoom is a percentage of the size the capture was taken at. A fit sets it from the window, larger
// or smaller, and the slider shows what it works out to; moving the slider turns the fit off,
// and the zoom then stays for the next capture.
fit := viewer.full_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;
viewer.full_fit = fit;
SameLine();
SetNextItemWidth(260);
if SliderFloat("Zoom", *viewer.full_zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM, "%.0f%%", .Logarithmic) viewer.full_fit = .OFF;
SameLine();
if SmallButton("100%") { viewer.full_fit = .OFF; viewer.full_zoom = 100; }
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
// Left and Right step through the captures in grid order while this window has focus.
if IsWindowFocused(.RootAndChildWindows) {
position := viewer_visible_position(viewer.full_index);
if IsKeyPressed(.LeftArrow) && position > 0 viewer_request_full(viewer.visible[position - 1]);
if IsKeyPressed(.RightArrow) && position >= 0 && position < viewer.visible.count - 1 viewer_request_full(viewer.visible[position + 1]);
}
if viewer.full_failed {
TextColored(app_theme.palette.warning, "This capture cannot be shown; see the Event Log.");
} else if viewer.full_texture.handle {
// Each fit keeps the same scrollbars from frame to frame. Otherwise fitting the width of a tall
// image would bring up a vertical scrollbar, narrowing the area, changing the fit, and so on.
flags := WindowFlags.HorizontalScrollbar;
if viewer.full_fit == {
case .WINDOW; flags = .NoScrollbar;
case .WIDTH; flags |= .AlwaysVerticalScrollbar;
case .HEIGHT; flags |= .AlwaysHorizontalScrollbar;
}
BeginChild("image", .{0, 0}, .None, flags);
area := GetContentRegionAvail();
// Sized from the captured size, so an image scaled down to fit a texture is drawn back up.
source_w, source_h := cast(float) viewer.full_source_width, cast(float) viewer.full_source_height;
if source_w > 0 && source_h > 0 && area.x > 0 && area.y > 0 {
if viewer.full_fit == {
case .WINDOW; viewer.full_zoom = 100 * min(area.x / source_w, area.y / source_h);
case .WIDTH; viewer.full_zoom = 100 * area.x / source_w;
case .HEIGHT; viewer.full_zoom = 100 * area.y / source_h;
}
}
zoom := clamp(viewer.full_zoom, VIEWER_MIN_ZOOM, VIEWER_MAX_ZOOM) / 100;
Image(gpu_texture_ref(viewer.full_texture), .{source_w * zoom, source_h * zoom});
// OCR boxes are in the image's own pixels, which is the captured size.
ocr_ui_capture_overlay(viewer.full_index, GetItemRectMin(), zoom);
EndChild();
// The previous image stays up while the next one loads, so stepping through does not flicker.
}
}
End();
}