[0.0.3] Viewer: delete a capture from its thumbnail, to the Recycle Bin or permanently after overwriting it
This commit is contained in:
parent
fac42e3462
commit
8c9870ac01
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user