mcap/src/app/viewer.jai
Musa Mahmood 03f23f7122 [0.1.16] Ctrl-C copies the selected capture's image, Ctrl-Shift-C its full path
The selected capture is the one outlined in the grid and shown in the Capture window. Its image is
copied as the Capture window shows it, decoded on a thread of its own and put on the clipboard as
screen captures are (Windows only so far); its path is copied as the system writes paths. Both are
actions in [hotkeys] (copy_image, copy_path), which do not act while typing in the search box.
Settings files at the current version get their default bindings at the next start; the comment
saying to raise APP_USER_SETTINGS_VERSION for a new action had it backwards.
2026-10-13 07:36:52 +01:00

1483 lines
67 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.
//
// The catalog (app/catalog.jai), what search and sorting need from each capture, loads on the
// background thread group (app/background.jai), so previews keep these workers to themselves. 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.
//
// The library is a folder of folders, one for each place imported from (app/import.jai), and any .mcap
// files at its top. The grid shows them all, or one folder's.
//
// 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. The library is watched (viewer_watch_frame), so files added or deleted
// outside the app come and go on their own; Refresh is still there for a watcher that missed some.
VIEWER_FOLDER :: "D:/McapIndex/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;
folder: string; // the library folder it is in, "SnagitLibraryWork"; "" at the top
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
catalog_failed: bool; // main thread only: its catalog entry could not be loaded
file_size: s64 = -1; // its file's, as listed at startup or a refresh; -1 when not known
file_modified: s64; // nanoseconds
}
// 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
from_cache: bool; // given by the catalog cache, not read from the file
}
// 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 not
scaled: bool; // pixels came from the default allocator (scaled down, turned, or decoded by WIC)
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
segments_wanted: s32 = -1; // the capture whose segments the overlay wants (app/segment_ui.jai)
segments_request: u64;
segments_taken: u64;
segments_done: *Segment_Overlay; // read, waiting for the main thread
segments_done_request: u64;
quit: bool;
workers: [VIEWER_WORKER_COUNT] Thread;
workers_started: s64;
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
catalog_started: Apollo_Time; // when the workers started on it, for the log line once it is complete
catalog_timed: bool; // that line has been logged
catalog_from_cache: s64; // entries the catalog cache gave
catalog_from_files: s64; // entries read from their files
first_preview_logged: bool;
first_frame_logged: bool;
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;
folders: [..] string; // the library's folders holding captures, sorted; "" for its top
folder_filter: string; // the folder the grid shows; "" for all (heap)
// 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
// A video or animation's original, written out to open in the default player (viewer_open_original).
open_thread: Thread;
open_started: bool;
open_busy: bool; // set by the main thread, cleared by the open thread when it ends
open_source: string; // the .mcap; heap, main thread's until the thread starts
open_name: string;
open_result: string; // the file written, or ""; heap, the open thread's until it ends
open_error: string;
// The selected capture's image, decoded off the main thread for the clipboard (viewer_copy_image).
copy_thread: Thread;
copy_started: bool;
copy_busy: bool; // set by the main thread, cleared by the copy thread when it ends
copy_source: string; // the .mcap; heap, main thread's until the thread starts
copy_name: string;
copy_done: Decoded; // the copy thread's until it ends
}
// ---------------------------------------------------------------- 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);
viewer.folders.allocator = context.default_allocator;
viewer_watch_start(); // before the listing, so nothing written meanwhile is missed
start := current_time_monotonic();
for viewer_list_files() {
capture := array_add(*viewer.captures);
viewer_capture_init(capture, it.path);
capture.file_size, capture.file_modified = it.size, it.modified;
}
viewer_update_folders();
// 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("[startup] Found % captures in % at % ms, listing them in % ms.", viewer.captures.count, viewer_folder(), FF(startup_ms(), 1), FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
if app.cold_start viewer_evict_file_cache();
viewer.catalog_started = current_time_monotonic();
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();
{
// The catalog: from the cache what it has a current entry for, the rest from the files.
items := NewArray(viewer.captures.count, Catalog_Item,, temp);
for * items {
capture := *viewer.captures[it_index];
it.* = .{index = cast(s32) it_index, path = capture.path, size = capture.file_size, modified = capture.file_modified};
}
if app.no_catalog_cache {
log("[startup] -no-catalog-cache: every capture is read from its file.");
background_catalog_load(items);
} else {
index := New(Catalog_Cache_Index,, context.default_allocator);
index.path = copy_string(catalog_cache_path(),, context.default_allocator);
index.captures = array_copy(items,, context.default_allocator);
background_catalog_cache(index);
}
}
if app.start_search viewer_set_search(app.start_search);
}
// 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);
if viewer.open_started thread_is_done(*viewer.open_thread, -1);
if viewer.copy_started thread_is_done(*viewer.copy_thread, -1);
background_shutdown();
catalog_cache_save_at_shutdown(); // the workers have stopped: nothing changes the entries now
viewer_watch_stop();
}
// ---------------------------------------------------------------- the workers
viewer_worker :: (thread: *Thread) -> s64 {
while true {
wait_for(*viewer.wake, 250);
while true {
index: s32 = -1;
full: bool;
request: u64;
path: string;
segments: bool;
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.segments_request != viewer.segments_taken {
// The Capture window's segments overlay (app/segment_ui.jai).
segments = true;
request = viewer.segments_request;
viewer.segments_taken = request;
index = viewer.segments_wanted;
} else if viewer.queue_next < viewer.queue.count {
index = viewer.queue[viewer.queue_next];
viewer.queue_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 { // nothing to do: wait to be woken, having given back what this thread's heap holds free
Default_Allocator.thread_idle();
break;
}
if segments {
overlay := segment_overlay_read(path);
overlay.index = index;
reset_temporary_storage();
lock(*viewer.mutex);
if request == viewer.segments_request { // still the one wanted
segment_overlay_free(viewer.segments_done);
viewer.segments_done = overlay;
viewer.segments_done_request = request;
} else {
segment_overlay_free(overlay);
}
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_image(path, m.preview.png, .PNG);
}
// The rendered image, annotations and all (a video's: a frame from it), or the original when there
// is none. A video itself is never read.
viewer_decode_full :: (path: string) -> Decoded {
result: Decoded;
result.failed = true;
push_allocator(temp);
defer reset_temporary_storage();
m, image, which, ok, error := mcap_read_shown_image(path);
if !ok { log_error("[viewer] % %", path, error); return result; }
return viewer_decode_image(path, image);
}
// Any image (modules/Image_Decode), turned the right way up.
viewer_decode_image :: (path: string, bytes: [] u8, kind := Image_Kind.UNKNOWN) -> Decoded {
result: Decoded;
result.failed = true;
image, ok, error := image_decode(bytes, kind);
if !ok {
log_error("[viewer] Cannot decode the image in %: %", path, error);
return result;
}
pixels := image.pixels;
width, height := image.width, image.height;
result.pixels = pixels;
result.scaled = !image.from_stb;
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) {
image_free(*image);
result.pixels = scaled;
result.scaled = true;
result.width, result.height = out_width, out_height;
} else {
free(scaled,, context.default_allocator);
image_free(*image);
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);
}
segment_overlay_device_lost(); // made again from the mask it holds
}
// 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);
if capture.texture.handle && !viewer.first_preview_logged {
viewer.first_preview_logged = true;
log("[startup] First preview shown at % ms.", FF(startup_ms(), 1));
}
else capture.failed = true;
}
viewer_free_pixels(*d);
}
viewer_evict();
viewer_apply_catalog_updates();
catalog_cache_frame(); // saves the catalog cache once it is complete and has changed (app/catalog.jai)
viewer_open_frame();
viewer_copy_frame();
segment_overlay_frame(); // the Capture window's segments: what arrived, and its texture (app/segment_ui.jai)
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);
unlock(*viewer.mutex);
// Whether the date order can change: a capture's first entry (one a refresh added), one lost, or a
// changed date. An entry read again after the scan stored a reading keeps its place, so a scan
// does not set off a search of the whole library for each capture it stores.
order_changes := false;
for updates {
capture := *viewer.captures[it.index];
if !capture.catalog viewer.catalog_loaded += 1;
if !capture.catalog capture.catalog_failed = !it.entry;
if it.from_cache viewer.catalog_from_cache += 1;
else { viewer.catalog_from_files += 1; catalog_cache.dirty = true; }
if !capture.catalog || !it.entry || it.entry.date != capture.catalog.date order_changes = true;
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, any entry can start or stop matching (a scan adds OCR text and labels);
// background_search_start keeps those searches to a few a second.
if updates.count && (viewer.search_terms.count || (viewer.sorted_by_date && order_changes)) viewer.visible_dirty = true;
if !viewer.sorted_by_date && viewer.catalog_loaded >= viewer.captures.count {
viewer.sorted_by_date = true;
viewer.visible_dirty = true;
if !viewer.catalog_timed {
viewer.catalog_timed = true;
failed: s64;
for viewer.captures if it.catalog_failed failed += 1;
log("[startup] Catalog complete at % ms: % entries in % ms, % from the cache and % read from their files on % threads (% could not be read)%.",
FF(startup_ms(), 1), viewer.captures.count, FF(to_float64_seconds(current_time_monotonic() - viewer.catalog_started) * 1000.0, 1),
viewer.catalog_from_cache, viewer.catalog_from_files, background_catalog_threads(), failed, ifx app.cold_start then ", from a cold start" else "");
}
}
}
// 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.
// quiet (the watcher's): no toast, and no log line when nothing changed.
viewer_refresh :: (quiet := false) {
start := current_time_monotonic();
present: Table(string, bool); // the folder's .mcap files now
present.allocator = temp;
for viewer_list_files() table_set(*present, it.path, 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;
}
new_items: [..] Catalog_Item;
new_items.allocator = temp;
lock(*viewer.mutex);
for _, path: present {
if table_contains(*known, path) continue;
capture := array_add(*viewer.captures);
capture.* = .{};
viewer_capture_init(capture, path);
array_add(*new_items, .{index = cast(s32) (viewer.captures.count - 1), path = capture.path});
added += 1;
}
unlock(*viewer.mutex);
background_catalog_load(new_items);
viewer_update_folders();
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 = .{};
if quiet && !removed && !added return;
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));
if !quiet toast_show(.Success, "% removed, % added", removed, added);
}
// One capture written into the library since it was looked at (an import): added as a refresh would
// add it. Its index; the one it has when it is already there (the watcher may have seen it first).
viewer_add_capture :: (path: string) -> s32 {
index := viewer_capture_find(path);
if index >= 0 return index;
lock(*viewer.mutex);
capture := array_add(*viewer.captures);
capture.* = .{};
viewer_capture_init(capture, path);
index = cast(s32) (viewer.captures.count - 1);
unlock(*viewer.mutex);
background_catalog_load(.[.{index = index, path = capture.path}]);
if viewer.scan_error { free(viewer.scan_error); viewer.scan_error = ""; }
if !array_find(viewer.folders, capture.folder) viewer_update_folders();
viewer.sorted_by_date = false; // until its entry is in
viewer.visible_dirty = true;
return index;
}
Listed_File :: struct {
path: string;
size: s64;
modified: s64; // nanoseconds
}
// Every .mcap in the library, in its folders too, but not in hidden ones, with its size and modified
// time (which listing a folder gives at no extra cost on Windows and Unix). Temp.
viewer_list_files :: () -> [..] Listed_File {
files: [..] Listed_File;
files.allocator = temp;
visit_files(viewer_folder(), true, *files, (info: *File_Visit_Info, files: *[..] Listed_File) {
if !viewer_is_library_file(info.full_name) return;
array_add(files, .{copy_string(info.full_name,, temp), info.size, to_nanoseconds(info.modification_time)});
});
return files;
}
// Whether path, a file or folder, is where the library keeps captures: under it, and not hidden or
// in the deleted folder viewer_delete_capture moves files to off Windows.
viewer_is_library_path :: (path: string) -> bool {
folder := viewer_folder();
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
relative := advance(path, folder.count + 1);
if begins_with(relative, ".") || contains(relative, "/.") return false;
#if OS != .WINDOWS if relative == "deleted" || begins_with(relative, "deleted/") return false;
return true;
}
viewer_is_library_file :: (path: string) -> bool {
return ends_with_nocase(path, tprint(".%", MCAP_EXTENSION)) && viewer_is_library_path(path);
}
// The capture whose file is path, of those not removed; -1 when none.
viewer_capture_find :: (path: string) -> s32 {
for viewer.captures if !it.removed && it.path == path return cast(s32) it_index;
return -1;
}
// ---------------------------------------------------------------- watching the library
// The watcher reports which paths changed, not what to do about them: a capture written in place
// (the app's own writes go to NAME.mcap.tmp and are renamed over NAME.mcap) shows as a remove and a
// move, a copy in progress as an add and modifies. So each path it reports is looked at as it is
// now, once it has settled: a file the grid lacks is added, a capture whose file is gone is
// removed. A folder added, removed or renamed reports no events for what is in it, and lost events
// say nothing of what was lost, so those compare the whole library (viewer_refresh) instead.
//
// Removals wait while the OCR thread is busy, as Refresh does: a scan or reading refers to captures
// by index. Main thread only, but for viewer_watch_stop on the shutdown thread once the frames stop.
VIEWER_WATCH_BUFFER_SIZE :: 64 * 1024; // per watched folder on Windows; the most a network share takes
viewer_watch: struct {
watcher: Watcher.File_Watcher(void);
started: bool;
paths: [..] string; // .mcap files changed, not looked at yet (heap)
rescan: bool; // compare the whole library
}
viewer_watch_start :: () {
push_allocator(context.default_allocator);
viewer_watch.paths.allocator = context.default_allocator;
folder := viewer_folder();
if !Watcher.init(*viewer_watch.watcher, viewer_watch_callback, merge_window_seconds = 0.25, event_buffer_size = VIEWER_WATCH_BUFFER_SIZE) {
log_error("[viewer] Could not watch %; Refresh shows what changed in it.", folder);
return;
}
if !Watcher.add_directories(*viewer_watch.watcher, folder) {
log_error("[viewer] Could not watch %; Refresh shows what changed in it.", folder);
Watcher.deinit(viewer_watch.watcher);
return;
}
viewer_watch.started = true;
}
viewer_watch_stop :: () {
if !viewer_watch.started return;
Watcher.deinit(viewer_watch.watcher);
viewer_watch.started = false;
for viewer_watch.paths free(it,, context.default_allocator);
array_free(viewer_watch.paths);
}
viewer_watch_frame :: () {
if !viewer_watch.started return;
Watcher.process_changes(*viewer_watch.watcher);
if !viewer_watch.rescan && !viewer_watch.paths.count return;
busy := ocr_ui.thread_busy; // removals wait for it
if viewer_watch.rescan {
if busy return;
viewer_watch.rescan = false;
for viewer_watch.paths free(it,, context.default_allocator);
array_reset_keeping_memory(*viewer_watch.paths);
viewer_refresh(quiet = true);
return;
}
kept: s64; // the paths that wait for the OCR thread, moved to the front
for path: viewer_watch.paths {
index := viewer_capture_find(path);
there := file_exists(path) && !is_directory(path);
wait := false;
if there && index < 0 {
viewer_add_capture(path);
log("[viewer] % was added to the library.", path);
} else if !there && index >= 0 {
if busy wait = true;
else { viewer_mark_removed(index); log("[viewer] % was removed from the library.", path); }
} else if there && (viewer.captures[index].failed || viewer.captures[index].catalog_failed) {
// Read while it was still being written (a copy in progress): read it again, as a new capture.
if busy wait = true;
else { viewer_mark_removed(index); viewer_add_capture(path); }
}
if wait { viewer_watch.paths[kept] = path; kept += 1; }
else free(path,, context.default_allocator);
}
viewer_watch.paths.count = kept;
}
viewer_watch_callback :: (watcher: *Watcher.File_Watcher(void), change: *Watcher.File_Change, user_data: *void) {
Event :: Watcher.File_Change_Event;
if change.events & (Event.OVERFLOW | .SCAN_CHILDREN) { viewer_watch.rescan = true; return; }
path := change.full_path;
if !viewer_is_library_path(path) return;
if viewer_is_library_file(path) && !(change.events & .IS_FOLDER) {
for viewer_watch.paths if it == path return;
array_add(*viewer_watch.paths, copy_string(path,, context.default_allocator));
return;
}
// A folder's entries changing makes it MODIFIED, which says nothing about the folder itself.
if !(change.events & (Event.ADDED | .REMOVED | .MOVED)) return;
if ends_with_nocase(path, ".tmp") return; // the app's own writes, on their way to a .mcap
// A folder now, or one that held captures: what is in it came or went with it.
if is_directory(path) { viewer_watch.rescan = true; return; }
prefix := tprint("%/", path);
for viewer.captures if !it.removed && begins_with(it.path, prefix) { viewer_watch.rescan = true; return; }
}
// -cold: every capture's file dropped from the system's file cache, so the catalog and previews are
// read from the disk as on the first start after a reboot.
viewer_evict_file_cache :: () {
start := current_time_monotonic();
evicted: s64;
for viewer.captures if platform_evict_file_cache(it.path) evicted += 1;
platform_evict_file_cache(catalog_cache_path());
if !evicted && viewer.captures.count {
log("[viewer] -cold: the system's file cache cannot be emptied here; the library is read from whatever it holds.");
return;
}
log("[viewer] -cold: dropped % of % captures from the system's file cache in % ms.", evicted, viewer.captures.count, FF(to_float64_seconds(current_time_monotonic() - start) * 1000.0, 1));
}
viewer_capture_init :: (capture: *Viewer_Capture, path: string) {
push_allocator(context.default_allocator);
capture.path = copy_string(path);
capture.name = copy_string(path_strip_extension(path_filename(path)));
relative := path_strip_filename(advance(path, viewer_folder().count + 1));
while relative.count && (relative[relative.count - 1] == #char "/" || relative[relative.count - 1] == #char "\\") relative.count -= 1;
capture.folder = copy_string(relative);
}
// The folders that hold captures, for the folder filter.
viewer_update_folders :: () {
for viewer.folders free(it);
array_reset_keeping_memory(*viewer.folders);
for viewer.captures {
if it.removed || array_find(viewer.folders, it.folder) continue;
array_add(*viewer.folders, copy_string(it.folder,, context.default_allocator));
}
quick_sort(viewer.folders, compare);
}
viewer_set_folder_filter :: (folder: string) {
free(viewer.folder_filter);
viewer.folder_filter = copy_string(folder,, context.default_allocator);
viewer.visible_dirty = true;
viewer.visible_rebuilt_at = .{};
}
// 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;
catalog_cache.dirty = true; // the next save leaves it out
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;
if !viewer.first_frame_logged { viewer.first_frame_logged = true; log("[startup] First frame of the grid at % ms.", FF(startup_ms(), 1)); }
viewer_watch_frame(); // files added or deleted outside the app
viewer_frame_gpu_work();
if viewer.scan_error {
TextColored(app_theme.palette.warning, "%", viewer.scan_error);
SameLine();
if Button("Refresh") viewer_refresh();
SameLine();
if Button("Import") importer.window_open = !importer.window_open; // app/import.jai
return;
}
SetNextItemWidth(min(GetContentRegionAvail().x * 0.5, 520));
if InputTextWithHint("##search", "Search text, titles, URLs, apps, notes, labels. \"a phrase\" ocr: title: url: app: note: label: like: folder: has:", viewer.search_buffer.data, viewer.search_buffer.count) {
viewer_search_changed();
}
SameLine();
// Which library folder: all, or one.
if viewer.folders.count > 1 || (viewer.folders.count == 1 && viewer.folders[0] != "") {
SetNextItemWidth(200);
folder_label := ifx viewer.folder_filter then viewer.folder_filter else "All folders";
if BeginCombo("##folder", temp_c_string(folder_label)) {
if Selectable("All folders", !viewer.folder_filter) viewer_set_folder_filter("");
for viewer.folders {
label := ifx it then it else "(the library's top)";
if Selectable(temp_c_string(tprint("%##%", label, it_index)), it == viewer.folder_filter) viewer_set_folder_filter(it);
}
EndCombo();
}
if IsItemHovered() SetTooltip("The library folder shown: each holds what was imported from one place");
SameLine();
}
if viewer.search_terms.count || viewer.folder_filter 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();
if Button("Labels") labels_ui.window_open = !labels_ui.window_open; // app/labels_ui.jai
SameLine();
if Button("Segments") segment_ui.window_open = !segment_ui.window_open; // app/segment_ui.jai
if IsItemHovered() SetTooltip("Segmentation: what is where in the images of the library folders you choose");
SameLine();
if Button("Import") importer.window_open = !importer.window_open; // app/import.jai
if IsItemHovered() SetTooltip("Bring in a folder of screenshots and photos");
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 not loaded, is loading, 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)}, "");
scan := *ocr_ui.scan;
doing := ifx scan.erase_ocr || scan.erase_labels || scan.erase_segments then tprint("Erasing %", erase_what_text(scan))
else ifx scan.segmenting then tprint("Segmentation scan with %", scan.model) else tprint("OCR scan with %", scan.model);
if IsItemHovered() SetTooltip("%: %, % elapsed, % left, % failed%", doing, ocr_scan_progress_text(done, total), ocr_duration_text(elapsed), ocr_duration_text(left), failed, ifx 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
}
// A video's play mark and how long it runs, or "GIF", at the bottom left of its thumbnail.
viewer_draw_media_badge :: (draw: *ImGui.ImDrawList, top_left: ImGui.ImVec2, cell: float, entry: *Catalog_Entry) {
using ImGui;
text := ifx entry.media == .VIDEO then viewer_duration_text(entry.duration) else "GIF";
text_size := CalcTextSize(text);
pad: float = 4;
mark := ifx entry.media == .VIDEO then text_size.y * 0.8 else 0;
width := pad + mark + (ifx mark > 0 then pad else 0) + text_size.x + pad;
height := text_size.y + pad;
p0 := ImVec2.{top_left.x + pad, top_left.y + cell - height - pad};
p1 := ImVec2.{p0.x + width, p0.y + height};
draw.AddRectFilled(draw, p0, p1, ImGui.ColorConvertFloat4ToU32(.{0, 0, 0, 0.65}), 4);
ink := ImGui.ColorConvertFloat4ToU32(.{1, 1, 1, 1});
x := p0.x + pad;
if mark > 0 { // a play triangle
y := p0.y + (height - mark) / 2;
draw.AddTriangleFilled(draw, .{x, y}, .{x, y + mark}, .{x + mark * 0.87, y + mark / 2}, ink);
x += mark + pad;
}
draw.AddText(draw, .{x, p0.y + pad / 2}, ink, text);
}
// 0:42, 12:05, 1:02:03. Temp.
viewer_duration_text :: (seconds: float64) -> string {
total := cast(s64) (seconds + 0.5);
two :: (n: s64) -> string { return tprint("%", formatInt(n, minimum_digits = 2)); }
if total >= 3600 return tprint("%:%:%", total / 3600, two(total / 60 % 60), two(total % 60));
return tprint("%:%", total / 60, two(total % 60));
}
// Writes the capture's original (a video, an animated GIF) as it was imported to a file in the
// temporary folder, on a thread of its own, as it may be large; viewer_open_frame opens it.
viewer_open_original :: (index: s32) {
if viewer.open_started return;
capture := *viewer.captures[index];
push_allocator(context.default_allocator);
free(viewer.open_source); free(viewer.open_name); free(viewer.open_result); free(viewer.open_error);
viewer.open_source = copy_string(capture.path);
viewer.open_name = copy_string(capture.name);
viewer.open_result = "";
viewer.open_error = "";
viewer.open_busy = true;
viewer.open_started = thread_init(*viewer.open_thread, viewer_open_proc, temporary_storage_size = 256 * 1024, name = "open original");
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not open %", capture.name); return; }
thread_start(*viewer.open_thread);
}
viewer_open_proc :: (thread: *Thread) -> s64 {
context.allocator = context.default_allocator;
defer atomic_swap(*viewer.open_busy, false);
m, original, ok, error := mcap_read_without_original(viewer.open_source,, temp);
if !ok { viewer.open_error = copy_string(error); return 0; }
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%");
else base := "/tmp";
folder := tprint("%/%", base, APP_FOLDER_NAME);
make_directory_if_it_does_not_exist(folder, recursive = true);
extension := path_extension(m.original_name);
path := tprint("%/%.%", folder, viewer.open_name, ifx extension then extension else "bin");
_, size, exists := file_modtime_and_size(path);
if !exists || size != original.size { // written out before, it is used again
ok, error = mcap_extract_original(original, path);
if !ok { viewer.open_error = copy_string(error); return 0; }
}
viewer.open_result = copy_string(path);
return 0;
}
// Each frame: opens what viewer_open_proc wrote, once it is done.
viewer_open_frame :: () {
if !viewer.open_started || viewer.open_busy return;
thread_is_done(*viewer.open_thread, -1);
thread_deinit(*viewer.open_thread);
viewer.open_started = false;
if viewer.open_result platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
else toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
}
// ---------------------------------------------------------------- copying the selected capture
// The selected capture: the one outlined in the grid and shown in the Capture window. -1 when none.
viewer_selected :: () -> s32 {
index := viewer.full_index;
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed return -1;
return index;
}
// Ctrl-Shift-C (copy_path in [hotkeys]): the selected capture's path, as the system writes paths.
viewer_copy_path :: () {
index := viewer_selected();
if index < 0 { toast_show(.Error, "Select a capture to copy its path"); return; }
capture := *viewer.captures[index];
path := capture.path;
#if OS == .WINDOWS path = replace(path, "/", "\\",, temp);
clipboard_set_text(path);
toast_show(.Success, "Copied the path of %", capture.name);
}
// Ctrl-C (copy_image in [hotkeys]): the selected capture's image, as the Capture window shows it
// (annotations and all; a video's frame), decoded on a thread of its own and put on the clipboard
// by viewer_copy_frame.
viewer_copy_image :: () {
index := viewer_selected();
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
capture := *viewer.captures[index];
#if OS != .WINDOWS {
toast_show(.Error, "Copying images is not available on this system yet");
return;
}
if viewer.copy_started { toast_show(.Error, "Still copying %", viewer.copy_name); return; }
push_allocator(context.default_allocator);
free(viewer.copy_source); free(viewer.copy_name);
viewer.copy_source = copy_string(capture.path);
viewer.copy_name = copy_string(capture.name);
viewer.copy_done = .{};
viewer.copy_busy = true;
// Reading an image takes a few MB of temporary storage, as on the viewer's workers.
viewer.copy_started = thread_init(*viewer.copy_thread, viewer_copy_proc, temporary_storage_size = 1024 * 1024, name = "copy image");
if !viewer.copy_started { viewer.copy_busy = false; toast_show(.Error, "Could not copy %", capture.name); return; }
thread_start(*viewer.copy_thread);
}
viewer_copy_proc :: (thread: *Thread) -> s64 {
context.allocator = context.default_allocator;
defer atomic_swap(*viewer.copy_busy, false);
viewer.copy_done = viewer_decode_full(viewer.copy_source);
return 0;
}
// Each frame: puts what viewer_copy_proc decoded on the clipboard, once it is done.
viewer_copy_frame :: () {
if !viewer.copy_started || viewer.copy_busy return;
thread_is_done(*viewer.copy_thread, -1);
thread_deinit(*viewer.copy_thread);
viewer.copy_started = false;
done := *viewer.copy_done;
defer viewer_free_pixels(done);
if done.failed { toast_show(.Error, "Could not read the image of %; see the Event Log", viewer.copy_name); return; }
pixels := []u8.{cast(s64) done.width * done.height * 4, done.pixels};
if !platform_copy_image_to_clipboard(pixels, done.width, done.height) {
log_error("[viewer] Could not put the image of % on the clipboard.", viewer.copy_source);
toast_show(.Error, "Could not put % on the clipboard", viewer.copy_name);
return;
}
scaled := ifx done.width != done.source_width || done.height != done.source_height then tprint(", scaled down from % x %", done.source_width, done.source_height) else "";
log("[viewer] Copied the image of % (% x %%).", viewer.copy_source, done.width, done.height, scaled);
toast_show(.Success, "Copied % (% x %%)", viewer.copy_name, done.width, done.height, scaled);
}
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 capture.catalog && capture.catalog.media != .IMAGE viewer_draw_media_badge(draw, top_left, cell, capture.catalog);
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; }
// A video or animation: what it is, and its original in the default player.
if capture.catalog && capture.catalog.media != .IMAGE {
entry := capture.catalog;
if entry.media == .VIDEO {
TextColored(app_theme.palette.dim, "Video, %%%", viewer_duration_text(entry.duration), ifx entry.video_codec then tprint(", %", entry.video_codec) else "",
ifx entry.frame_rate > 0 then tprint(", % fps", FF(entry.frame_rate, ifx entry.frame_rate == floor(entry.frame_rate) then 0 else 2)) else "");
} else {
TextColored(app_theme.palette.dim, "Animated GIF, %", viewer_duration_text(entry.duration));
}
SameLine();
busy := viewer.open_started;
if busy BeginDisabled();
if SmallButton(temp_c_string(ifx entry.media == .VIDEO then "Play in the default player" else "Open in the default viewer")) viewer_open_original(viewer.full_index);
if busy EndDisabled();
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported to the temporary folder, and opens it");
if busy { SameLine(); TextColored(app_theme.palette.dim, "Writing it out..."); }
}
ocr_ui_capture_controls(viewer.full_index, capture.path); // app/ocr_ui.jai
labels_ui_capture_line(viewer.full_index); // app/labels_ui.jai
segment_overlay_controls(viewer.full_index); // app/segment_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});
// The segments under the OCR boxes: both cover the image as shown, whatever its size.
segment_overlay_draw(viewer.full_index, GetItemRectMin(), GetItemRectMax());
// 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();
}
#scope_file
Watcher :: #import "File_Watcher";