From 8c9870ac011ab7c146966b0a3fc7cf255b920d66 Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Thu, 8 Oct 2026 10:11:08 +0100 Subject: [PATCH] [0.0.3] Viewer: delete a capture from its thumbnail, to the Recycle Bin or permanently after overwriting it --- README.md | 5 ++ check_targets.jai | 2 +- first.jai | 2 +- src/App.jai | 1 + src/app/viewer.jai | 204 ++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 202 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7f74e9c..7ed7556 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ of previews, and one capture at full size in the Capture window. so it can pause, cancel and pick up again. `-library FOLDER` shows another folder of `.mcap` files; `-scan` scans it with the chosen model once it has loaded and exits (0, or 1 if any capture failed), for a scan without the UI, such as on a copy. +- **Delete:** hovering a thumbnail shows a trash button in its corner. The confirmation offers moving + the file to the Recycle Bin (on Linux and macOS, to a `deleted` folder in the library folder) or + deleting it permanently: overwritten with random bytes, flushed to the disk, renamed and deleted. + Copies elsewhere (the source `.snagx`, backups, synced versions) are untouched, and an SSD may keep + old blocks. The choice, and "don't ask again", last until the app restarts. Not while OCR is running. - **Refresh** above the grid looks at the folder again: captures whose file was deleted leave the grid, and new files are added (each capture keeps its index, as the workers, the OCR scan and Stats refer to captures by index). By hand for now; File_Watcher can call `viewer_refresh` later. diff --git a/check_targets.jai b/check_targets.jai index 8e0f7b3..199ab5c 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -29,7 +29,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; GIT_BRANCH :: "check"; GIT_HASH :: "-"; -VERSION :: "0.0.2"; +VERSION :: "0.0.3"; BUILD_DATE :: "-"; DEBUG :: true; COMPILE_TESTS :: true; diff --git a/first.jai b/first.jai index c50da55..eb6f5ad 100644 --- a/first.jai +++ b/first.jai @@ -8,7 +8,7 @@ APP_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; -VERSION :: "0.0.2"; +VERSION :: "0.0.3"; // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // builds never write over the ones you run by hand. build_directory := "bin"; diff --git a/src/App.jai b/src/App.jai index 9598a6b..0195706 100644 --- a/src/App.jai +++ b/src/App.jai @@ -44,6 +44,7 @@ USING_WINDOWS :: OS == .WINDOWS; #import "Sort"; #import "Unicode"; #import "File"; +#import "Random"; // the noise viewer_overwrite_and_delete writes #import "File_Utilities"; #import "Math"; #import "System"; diff --git a/src/app/viewer.jai b/src/app/viewer.jai index b0b9fe3..3dab66e 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -107,6 +107,9 @@ viewer: struct { visible_rebuilt_at: Apollo_Time; catalog_loaded: s64; // entries put in place, or found unreadable removed_count: s64; // captures marked removed + delete_index: s32 = -1; // the capture whose delete is being confirmed + delete_without_asking: bool; // until the app restarts + delete_mode: Viewer_Delete_Mode; // the last chosen, until the app restarts catalog_generation: s64; // counts changes to catalog entries (loading, an OCR reading stored) visible_generation: s64; // counts rebuilds of visible sorted_by_date: bool; @@ -430,14 +433,8 @@ viewer_refresh :: () { for * capture, index: viewer.captures { if capture.removed continue; if table_contains(*present, capture.path) { table_set(*known, capture.path, true); continue; } - capture.removed = true; + viewer_mark_removed(cast(s32) index); removed += 1; - if capture.texture.handle { - gpu_texture_release(*capture.texture); - found, position := array_find(viewer.with_texture, cast(s32) index); - if found array_ordered_remove_by_index(*viewer.with_texture, position); - } - if viewer.full_index == index viewer.full_open = false; } lock(*viewer.mutex); @@ -452,7 +449,6 @@ viewer_refresh :: () { unlock(*viewer.mutex); if added signal(*viewer.wake); // their catalog entries - viewer.removed_count += removed; free(viewer.scan_error); viewer.scan_error = ""; if !viewer_capture_count() viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder()); @@ -464,6 +460,152 @@ viewer_refresh :: () { toast_show(.Success, "% removed, % added", removed, added); } +// Takes a capture out of everything shown, as though its file had never been there; its index stays. +viewer_mark_removed :: (index: s32) { + capture := *viewer.captures[index]; + if capture.removed return; + capture.removed = true; + viewer.removed_count += 1; + if capture.texture.handle { + gpu_texture_release(*capture.texture); + found, position := array_find(viewer.with_texture, index); + if found array_ordered_remove_by_index(*viewer.with_texture, position); + } + if viewer.full_index == index viewer.full_open = false; + 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; @@ -659,10 +801,36 @@ viewer_ui :: () { using ImGui; BeginChild("grid"); viewer_grid(); EndChild(); + viewer_delete_popup(); viewer_full_window(); } +// The trash button's square in a thumbnail whose top left is at top_left. +viewer_trash_rect :: (top_left: ImGui.ImVec2, cell: float) -> ImGui.ImVec2, ImGui.ImVec2 { + size := clamp(cell * 0.16, 18, 30); + margin: float = 4; + min := ImGui.ImVec2.{top_left.x + cell - size - margin, top_left.y + margin}; + return min, .{min.x + size, min.y + size}; +} + +// A trash can: lid, handle and a body with two ribs, on a dark square, red when the mouse is on it. +viewer_draw_trash :: (draw: *ImGui.ImDrawList, p0: ImGui.ImVec2, p1: ImGui.ImVec2, active: bool) { + using ImGui; + background := ifx active then ImGui.ColorConvertFloat4ToU32(.{0.80, 0.15, 0.15, 0.95}) else ImGui.ColorConvertFloat4ToU32(.{0, 0, 0, 0.65}); + ink := ImGui.ColorConvertFloat4ToU32(.{1, 1, 1, 1}); + draw.AddRectFilled(draw, p0, p1, background, 4); + w := p1.x - p0.x; + x0, x1 := p0.x + w * 0.28, p1.x - w * 0.28; + lid := p0.y + w * 0.30; + thick := max(1.0, w / 14); + draw.AddLine(draw, .{p0.x + w * 0.20, lid}, .{p1.x - w * 0.20, lid}, ink, thick); // lid + draw.AddRect(draw, .{p0.x + w * 0.40, p0.y + w * 0.18}, .{p1.x - w * 0.40, lid}, ink, 0, thick); // handle + draw.AddRect(draw, .{x0, lid + thick}, .{x1, p1.y - w * 0.18}, ink, 0, thick); // body + middle := (x0 + x1) / 2; + draw.AddLine(draw, .{middle, lid + w * 0.14}, .{middle, p1.y - w * 0.28}, ink, thick); // rib +} + viewer_grid :: () { using ImGui; style := GetStyle(); cell := viewer.thumb_size; @@ -677,6 +845,7 @@ viewer_grid :: () { using ImGui; 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; @@ -716,14 +885,29 @@ viewer_grid :: () { using ImGui; 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 hovered SetTooltip("%", capture.name); - if clicked viewer_request_full(cast(s32) index); + 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); } } }