bin/gpu-bench times every GPU model (OCR mobile and server, the label image encoder, segmentation; optionally the CPU and the label text encoder) over a fixed 30-image corpus in tools/gpu_bench/corpus. Every corpus file is checked against corpus.txt's SHA-256 before anything runs; each model's files and models.config entry are fingerprinted; the GPU, driver, power limit, CUDA runtime, cuDNN and onnxruntime are recorded, and NVML samples memory, load, power and clocks during the timed passes. Results go to bin/gpu-bench-results as text files. The GPU Bench button opens a window that runs gpu-bench in its own process (no console) and lays results files from several machines side by side against a baseline, marking a different corpus or model.
1672 lines
75 KiB
Plaintext
1672 lines
75 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;
|
|
focus_search: bool; // Ctrl-F (focus_search in [hotkeys]): the search box takes the keyboard next frame
|
|
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 capture's original, written out to open in the default player (viewer_open_original), or
|
|
// exported to a folder (viewer_export_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_folder: string; // where to export it; "" to write it to the temporary folder and open it
|
|
open_result: string; // the file written, or ""; heap, the open thread's until it ends
|
|
open_error: string;
|
|
|
|
// The export being asked about (viewer_export_popup).
|
|
export_index: s32 = -1; // the capture to export, while the popup is up
|
|
export_choice: s64; // which of viewer_export_choices, or -1 for Custom...
|
|
export_buffer: [1024] u8; // the folder typed in for Custom...
|
|
export_add: bool; // Custom...: add the folder to [export]
|
|
export_make_default: bool;
|
|
|
|
// 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;
|
|
}
|
|
return viewer_decoded_from_image(path, image);
|
|
}
|
|
|
|
// image, which this takes, as a texture can hold it: scaled down when too large for one.
|
|
viewer_decoded_from_image :: (path: string, image_in: Image) -> Decoded {
|
|
result: Decoded;
|
|
result.failed = true;
|
|
image := image_in;
|
|
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 !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;
|
|
}
|
|
|
|
if viewer.focus_search {
|
|
viewer.focus_search = false;
|
|
SetWindowFocus(); // SetKeyboardFocusHere alone leaves another window in front
|
|
SetKeyboardFocusHere(); // tabbing in: its text is selected, so typing replaces the query
|
|
}
|
|
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("GPU Bench") gpu_bench_ui.window_open = !gpu_bench_ui.window_open; // app/gpu_bench_ui.jai
|
|
if IsItemHovered() SetTooltip("Time every GPU model over a fixed corpus, and compare machines");
|
|
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_export_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) {
|
|
viewer_write_original(index, "");
|
|
}
|
|
|
|
// Writes the capture's original as it was imported into folder, on a thread of its own, as it may be
|
|
// large; viewer_open_frame says where it went.
|
|
viewer_export_original :: (index: s32, folder: string) {
|
|
viewer_write_original(index, folder);
|
|
}
|
|
|
|
viewer_write_original :: (index: s32, folder: string) {
|
|
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_folder); free(viewer.open_result); free(viewer.open_error);
|
|
viewer.open_source = copy_string(capture.path);
|
|
viewer.open_name = copy_string(capture.name);
|
|
viewer.open_folder = copy_string(folder);
|
|
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 = "write original");
|
|
if !viewer.open_started { viewer.open_busy = false; toast_show(.Error, "Could not write % out", 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; }
|
|
|
|
exporting := viewer.open_folder.count > 0;
|
|
folder := viewer.open_folder;
|
|
if !exporting {
|
|
#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);
|
|
if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; }
|
|
extension := path_extension(m.original_name);
|
|
if !extension extension = "bin";
|
|
path := tprint("%/%.%", folder, viewer.open_name, extension);
|
|
_, size, exists := file_modtime_and_size(path);
|
|
// Written out before, it is used again. An export does not overwrite another file of that name.
|
|
if exporting && exists && size != original.size {
|
|
for 2..1000 {
|
|
path = tprint("%/% (%).%", folder, viewer.open_name, it, extension);
|
|
_, size, exists = file_modtime_and_size(path);
|
|
if !exists || size == original.size break;
|
|
}
|
|
}
|
|
if !exists || size != original.size {
|
|
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, or says where it was exported, 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 toast_show(.Error, "Could not write % out: %", viewer.open_name, viewer.open_error);
|
|
else if viewer.open_folder toast_show(.Success, "Exported % to %", viewer.open_name, viewer.open_result);
|
|
else platform_open_file_in_default_program(viewer.open_result, path_strip_filename(viewer.open_result));
|
|
}
|
|
|
|
// The folders Export offers: the [export] folder lines in settings.config, else the Downloads folder.
|
|
viewer_export_choices :: () -> [] string {
|
|
if app_user_settings.export_folders return app_user_settings.export_folders;
|
|
#if OS == .WINDOWS downloads := tprint("%/Downloads", windows_expand_environment("%USERPROFILE%"));
|
|
else downloads := posix_expand_home("~/Downloads");
|
|
choices := NewArray(1, string,, temp);
|
|
choices[0] = downloads;
|
|
return choices;
|
|
}
|
|
|
|
// Asks where to export the capture's original, starting from the default folder.
|
|
viewer_export_ask :: (index: s32) {
|
|
viewer.export_index = index;
|
|
viewer.export_choice = 0;
|
|
viewer.export_add = false;
|
|
viewer.export_make_default = false;
|
|
}
|
|
|
|
viewer_export_set_buffer :: (folder: string) {
|
|
count := min(folder.count, viewer.export_buffer.count - 1);
|
|
memcpy(viewer.export_buffer.data, folder.data, count);
|
|
viewer.export_buffer[count] = 0;
|
|
}
|
|
|
|
// Puts folder in [export]: first when it is to be the default, else at the end if it is not there yet.
|
|
viewer_export_remember :: (folder: string, make_default: bool) {
|
|
folders := *app_user_settings.export_folders;
|
|
changed := false;
|
|
if !folders.count {
|
|
// The list was only the Downloads folder: it stays offered.
|
|
array_add(folders, copy_string(viewer_export_choices()[0],, context.default_allocator));
|
|
changed = true;
|
|
}
|
|
at := -1;
|
|
for folders.* if it == folder { at = it_index; break; }
|
|
if make_default && at != 0 {
|
|
copy := copy_string(folder,, context.default_allocator);
|
|
if at > 0 { free(folders.*[at],, context.default_allocator); array_ordered_remove_by_index(folders, at); }
|
|
array_insert_at(folders, copy, 0);
|
|
changed = true;
|
|
} else if at < 0 {
|
|
array_add(folders, copy_string(folder,, context.default_allocator));
|
|
changed = true;
|
|
}
|
|
if changed app_save_user_settings_file_and_toast("the export folders");
|
|
}
|
|
|
|
// The folder for viewer.export_index, as a modal popup.
|
|
viewer_export_popup :: () { using ImGui;
|
|
POPUP :: "Export capture";
|
|
if viewer.export_index >= 0 && !IsPopupOpen(POPUP) OpenPopup(POPUP);
|
|
if !BeginPopupModal(POPUP, null, .AlwaysAutoResize) return;
|
|
index := viewer.export_index;
|
|
if index < 0 || index >= viewer.captures.count || viewer.captures[index].removed {
|
|
viewer.export_index = -1;
|
|
CloseCurrentPopup();
|
|
EndPopup();
|
|
return;
|
|
}
|
|
capture := *viewer.captures[index];
|
|
Text("Export %", capture.name);
|
|
TextColored(app_theme.palette.dim, "Writes the file as it was imported, unchanged, into the folder.");
|
|
Spacing();
|
|
|
|
choices := viewer_export_choices();
|
|
if viewer.export_choice >= choices.count viewer.export_choice = 0;
|
|
custom := viewer.export_choice < 0;
|
|
SetNextItemWidth(GetFontSize() * 30);
|
|
if BeginCombo("##folders", temp_c_string(ifx custom then "Custom..." else choices[viewer.export_choice])) {
|
|
for choices {
|
|
if Selectable(temp_c_string(it), it_index == viewer.export_choice) viewer.export_choice = it_index;
|
|
if it_index == 0 && IsItemHovered() SetTooltip("The default");
|
|
}
|
|
Separator();
|
|
if Selectable("Custom...", custom) && !custom {
|
|
viewer.export_choice = -1;
|
|
viewer_export_set_buffer(choices[0]); // somewhere to start from
|
|
}
|
|
EndCombo();
|
|
}
|
|
SameLine();
|
|
Text("Folder");
|
|
custom = viewer.export_choice < 0;
|
|
|
|
folder: string;
|
|
if custom {
|
|
SetNextItemWidth(GetFontSize() * 30);
|
|
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
|
|
#if OS == .WINDOWS {
|
|
SameLine();
|
|
if Button("Browse...") {
|
|
chosen := windows_choose_folder("Choose a folder to export to");
|
|
if chosen viewer_export_set_buffer(chosen);
|
|
}
|
|
}
|
|
folder = trim(to_string(viewer.export_buffer.data));
|
|
Checkbox("Add it to the list", *viewer.export_add);
|
|
if IsItemHovered() SetTooltip("Adds a folder line under [export] in %", APP_USER_SETTINGS_FILE_NAME);
|
|
} else {
|
|
folder = choices[viewer.export_choice];
|
|
}
|
|
if custom || viewer.export_choice > 0 {
|
|
Checkbox("Make it the default", *viewer.export_make_default);
|
|
if IsItemHovered() SetTooltip("Puts it first under [export] in %", APP_USER_SETTINGS_FILE_NAME);
|
|
}
|
|
Spacing();
|
|
|
|
// Enter and Escape are the open dropdown's while it is up.
|
|
keys := IsWindowFocused();
|
|
if !folder BeginDisabled();
|
|
if Button("Export") || (folder && keys && IsKeyPressed(.Enter)) {
|
|
viewer.export_index = -1;
|
|
CloseCurrentPopup();
|
|
if (custom || viewer.export_choice > 0) && viewer.export_make_default viewer_export_remember(folder, true);
|
|
else if custom && viewer.export_add viewer_export_remember(folder, false);
|
|
viewer_export_original(index, folder);
|
|
}
|
|
if !folder EndDisabled();
|
|
SameLine();
|
|
if Button("Cancel") || (keys && IsKeyPressed(.Escape)) {
|
|
viewer.export_index = -1;
|
|
CloseCurrentPopup();
|
|
}
|
|
EndPopup();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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..."); }
|
|
SameLine();
|
|
exporting := viewer.open_started;
|
|
if exporting BeginDisabled();
|
|
if SmallButton("Export...") viewer_export_ask(viewer.full_index);
|
|
if exporting EndDisabled();
|
|
if IsItemHovered(.AllowWhenDisabled) SetTooltip("Writes the file as it was imported, unchanged, to a folder");
|
|
if exporting && viewer.open_folder { SameLine(); TextColored(app_theme.palette.dim, "Exporting..."); }
|
|
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 && !viewer.open_folder { 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";
|