[0.0.3] Viewer: delete a capture from its thumbnail, to the Recycle Bin or permanently after overwriting it

This commit is contained in:
Musa Mahmood 2026-10-08 10:11:08 +01:00
parent fac42e3462
commit 8c9870ac01
5 changed files with 202 additions and 12 deletions

View File

@ -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; 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 `-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. 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 - **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 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. refer to captures by index). By hand for now; File_Watcher can call `viewer_refresh` later.

View File

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

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.0.2"; VERSION :: "0.0.3";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";

View File

@ -44,6 +44,7 @@ USING_WINDOWS :: OS == .WINDOWS;
#import "Sort"; #import "Sort";
#import "Unicode"; #import "Unicode";
#import "File"; #import "File";
#import "Random"; // the noise viewer_overwrite_and_delete writes
#import "File_Utilities"; #import "File_Utilities";
#import "Math"; #import "Math";
#import "System"; #import "System";

View File

@ -107,6 +107,9 @@ viewer: struct {
visible_rebuilt_at: Apollo_Time; visible_rebuilt_at: Apollo_Time;
catalog_loaded: s64; // entries put in place, or found unreadable catalog_loaded: s64; // entries put in place, or found unreadable
removed_count: s64; // captures marked removed 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) catalog_generation: s64; // counts changes to catalog entries (loading, an OCR reading stored)
visible_generation: s64; // counts rebuilds of visible visible_generation: s64; // counts rebuilds of visible
sorted_by_date: bool; sorted_by_date: bool;
@ -430,14 +433,8 @@ viewer_refresh :: () {
for * capture, index: viewer.captures { for * capture, index: viewer.captures {
if capture.removed continue; if capture.removed continue;
if table_contains(*present, capture.path) { table_set(*known, capture.path, true); 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; 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); lock(*viewer.mutex);
@ -452,7 +449,6 @@ viewer_refresh :: () {
unlock(*viewer.mutex); unlock(*viewer.mutex);
if added signal(*viewer.wake); // their catalog entries if added signal(*viewer.wake); // their catalog entries
viewer.removed_count += removed;
free(viewer.scan_error); free(viewer.scan_error);
viewer.scan_error = ""; viewer.scan_error = "";
if !viewer_capture_count() viewer.scan_error = sprint("No .% files in %.", MCAP_EXTENSION, viewer_folder()); 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); 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. // The captures not removed.
viewer_capture_count :: () -> s64 { viewer_capture_count :: () -> s64 {
return viewer.captures.count - viewer.removed_count; return viewer.captures.count - viewer.removed_count;
@ -659,10 +801,36 @@ viewer_ui :: () { using ImGui;
BeginChild("grid"); BeginChild("grid");
viewer_grid(); viewer_grid();
EndChild(); EndChild();
viewer_delete_popup();
viewer_full_window(); 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; viewer_grid :: () { using ImGui;
style := GetStyle(); style := GetStyle();
cell := viewer.thumb_size; cell := viewer.thumb_size;
@ -677,6 +845,7 @@ viewer_grid :: () { using ImGui;
outline := GetColorU32(.ButtonHovered); outline := GetColorU32(.ButtonHovered);
selected := GetColorU32(.CheckMark); selected := GetColorU32(.CheckMark);
text_color := GetColorU32(.Text); 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; first_row, last_row: s64 = 0x7FFF_FFFF_FFFF_FFFF, -1;
clipper: ListClipper; 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); 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); 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_min := ImVec2.{top_left.x, top_left.y + cell};
label_max := ImVec2.{top_left.x + cell, top_left.y + cell + label}; label_max := ImVec2.{top_left.x + cell, top_left.y + cell + label};
draw.PushClipRect(draw, label_min, label_max, true); draw.PushClipRect(draw, label_min, label_max, true);
draw.AddText(draw, label_min, text_color, capture.name); draw.AddText(draw, label_min, text_color, capture.name);
draw.PopClipRect(draw); draw.PopClipRect(draw);
if hovered SetTooltip("%", capture.name); if on_trash {
if clicked viewer_request_full(cast(s32) index); 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);
} }
} }
} }