[0.1.26] macOS phase 2: folder picker, the Trash, the temporary folder, copying images

This commit is contained in:
Musa Mahmood 2026-10-16 15:15:04 -04:00
parent 12d0f30fc4
commit 5571c21d0b
11 changed files with 129 additions and 26 deletions

View File

@ -57,14 +57,15 @@ see [Screen capture](#screen-capture).
what search needs from every file, is still loading: a line beside the count says so). **Size** what search needs from every file, is still loading: a line beside the count says so). **Size**
sets the thumbnails' size. Click a thumbnail to open it in the Capture window. sets the thumbnails' size. Click a thumbnail to open it in the Capture window.
- **Delete:** hover a thumbnail and click the trash button in its corner. You are asked whether to - **Delete:** hover a thumbnail and click the trash button in its corner. You are asked whether to
move the file to the Recycle Bin or to delete it permanently, overwriting it with random bytes move the file to the Recycle Bin (the Trash on macOS, a `deleted` folder in the library on Linux)
or to delete it permanently, overwriting it with random bytes
first; "Don't ask again" keeps the choice until the app restarts. Only the `.mcap` goes: the first; "Don't ask again" keeps the choice until the app restarts. Only the `.mcap` goes: the
`.snagx` it was converted from is untouched. `.snagx` it was converted from is untouched.
- **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then - **Copy:** click a thumbnail to select it (it is outlined, and shown in the Capture window), then
**Ctrl-C** copies its image, as the Capture window shows it (annotations and all; a video's **Ctrl-C** copies its image, as the Capture window shows it (annotations and all; a video's
frame), and **Ctrl-Shift-C** its full path. Neither acts while you type in the search box. They frame), and **Ctrl-Shift-C** its full path. Neither acts while you type in the search box. They
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config`. Copying images is Windows are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config` (**Cmd** for Ctrl on macOS).
only so far. Copying images works on Windows and macOS, not yet on Linux.
- **Ctrl-F** puts the keyboard in the search box, its text selected (`focus_search` in `[hotkeys]`). - **Ctrl-F** puts the keyboard in the search box, its text selected (`focus_search` in `[hotkeys]`).
- **Files added or deleted outside the app** (in Explorer, by another program) appear in the grid - **Files added or deleted outside the app** (in Explorer, by another program) appear in the grid
or leave it on their own, folders added, renamed or deleted too: the library is watched. or leave it on their own, folders added, renamed or deleted too: the library is watched.
@ -245,7 +246,7 @@ register the key, the Event Log says so; choose another, such as `Ctrl-Shift-S
**Import** (above the grid) brings a folder of screenshots, photos and videos into the library: **Import** (above the grid) brings a folder of screenshots, photos and videos into the library:
1. **Import from:** the folder, typed or chosen with **Browse...**. Everything under it is looked 1. **Import from:** the folder, typed or chosen with **Browse...** (Windows and macOS). Everything under it is looked
at, its folders too (not hidden ones, such as `.thumbnails`). at, its folders too (not hidden ones, such as `.thumbnails`).
2. **Into library folder:** the folder in the library it goes into, such as `SnagitLibraryWork` or 2. **Into library folder:** the folder in the library it goes into, such as `SnagitLibraryWork` or
`Phone`. It starts as the name of the folder imported from, or the library folder that folder went `Phone`. It starts as the name of the folder imported from, or the library folder that folder went

View File

@ -49,7 +49,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.1.25"; VERSION :: "0.1.26";
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.1.25"; VERSION :: "0.1.26";
// 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

@ -1038,9 +1038,9 @@ import_window :: () { using ImGui;
import_source_changed(); import_source_changed();
} }
SameLine(); SameLine();
#if OS == .WINDOWS { #if OS == .WINDOWS || OS == .MACOS {
if Button("Browse...") { if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to import"); chosen := platform_choose_folder("Choose a folder to import");
if chosen { if chosen {
count := min(chosen.count, importer.source_buffer.count - 1); count := min(chosen.count, importer.source_buffer.count - 1);
memcpy(importer.source_buffer.data, chosen.data, count); memcpy(importer.source_buffer.data, chosen.data, count);

View File

@ -183,7 +183,7 @@ open_file_list_folders :: () {
array_reset_keeping_memory(*open_file.folders); array_reset_keeping_memory(*open_file.folders);
visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) { visit_files(viewer_folder(), false, *open_file.folders, (info: *File_Visit_Info, folders: *[..] string) {
if !info.is_directory || !import_name_is_valid(info.short_name) return; // not hidden ones (.thumbnails) if !info.is_directory || !import_name_is_valid(info.short_name) return; // not hidden ones (.thumbnails)
#if OS != .WINDOWS if info.short_name == "deleted" return; // where deleted captures go there #if OS == .LINUX if info.short_name == "deleted" return; // where deleted captures go there
array_add(folders, copy_string(info.short_name,, context.default_allocator)); array_add(folders, copy_string(info.short_name,, context.default_allocator));
}, visit_files = false, visit_directories = true); }, visit_files = false, visit_directories = true);
quick_sort(open_file.folders, compare); quick_sort(open_file.folders, compare);
@ -359,7 +359,7 @@ open_file_copy_path :: () {
} }
open_file_copy_image :: () { open_file_copy_image :: () {
#if OS != .WINDOWS { #if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet"); toast_show(.Error, "Copying images is not available on this system yet");
return; return;
} }

View File

@ -237,7 +237,7 @@ screen_capture_out_path :: (name: string) -> path: string, error: string {
return .{}, false, "not available on this system yet"; return .{}, false, "not available on this system yet";
} }
platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool { #if OS == .LINUX platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool {
return false; return false;
} }
} }

View File

@ -190,8 +190,7 @@ screen_record_leftover_date :: (path: string) -> string {
} }
screen_record_folder :: () -> string { screen_record_folder :: () -> string {
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME); return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
else return tprint("/tmp/%", APP_FOLDER_NAME);
} }
#if OS == .WINDOWS { #if OS == .WINDOWS {

View File

@ -653,13 +653,13 @@ viewer_list_files :: () -> [..] Listed_File {
} }
// Whether path, a file or folder, is where the library keeps captures: under it, and not hidden or // 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. // in the deleted folder viewer_delete_capture moves files to on Linux.
viewer_is_library_path :: (path: string) -> bool { viewer_is_library_path :: (path: string) -> bool {
folder := viewer_folder(); folder := viewer_folder();
if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false; if path.count <= folder.count + 1 || !begins_with(path, folder) || path[folder.count] != #char "/" return false;
relative := advance(path, folder.count + 1); relative := advance(path, folder.count + 1);
if begins_with(relative, ".") || contains(relative, "/.") return false; if begins_with(relative, ".") || contains(relative, "/.") return false;
#if OS != .WINDOWS if relative == "deleted" || begins_with(relative, "deleted/") return false; #if OS == .LINUX if relative == "deleted" || begins_with(relative, "deleted/") return false;
return true; return true;
} }
@ -837,13 +837,13 @@ viewer_mark_removed :: (index: s32) {
} }
Viewer_Delete_Mode :: enum u8 { Viewer_Delete_Mode :: enum u8 {
RECYCLE; // to the Recycle Bin, or the deleted folder elsewhere: can be undone RECYCLE; // to the Recycle Bin, the Trash, or the deleted folder on Linux: can be undone
OVERWRITE; // overwritten with random bytes, renamed, then deleted for good 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 // 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 // (Windows asks first when the drive has none and it would be deleted for good), the Trash on macOS,
// deleted folder beside it. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file // and on Linux into a deleted folder in the library. OVERWRITE: viewer_overwrite_and_delete. False, with why, when the file
// is still there. // is still there.
viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string { viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, error: string {
capture := *viewer.captures[index]; capture := *viewer.captures[index];
@ -860,6 +860,9 @@ viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, err
result := SHFileOperationW(*operation); result := SHFileOperationW(*operation);
if operation.fAnyOperationsAborted return false, "cancelled"; if operation.fAnyOperationsAborted return false, "cancelled";
if result != 0 return false, tprint("the shell could not delete it (error 0x%)", formatInt(result, base = 16)); if result != 0 return false, tprint("the shell could not delete it (error 0x%)", formatInt(result, base = 16));
} else #if OS == .MACOS {
ok, error := macos_move_to_trash(capture.path);
if !ok return false, tprint("it could not be moved to the Trash: %", error);
} else { } else {
folder := tprint("%/deleted", viewer_folder()); folder := tprint("%/deleted", viewer_folder());
make_directory_if_it_does_not_exist(folder); make_directory_if_it_does_not_exist(folder);
@ -920,8 +923,9 @@ viewer_delete_now :: (index: s32) {
else toast_show(.Success, "Overwrote and deleted %", name); else toast_show(.Success, "Overwrote and deleted %", name);
} }
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin"; #if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else VIEWER_TRASH_NAME :: "deleted folder"; else #if OS == .MACOS VIEWER_TRASH_NAME :: "Trash";
else VIEWER_TRASH_NAME :: "deleted folder";
// The confirmation for viewer.delete_index, as a modal popup. // The confirmation for viewer.delete_index, as a modal popup.
viewer_delete_popup :: () { using ImGui; viewer_delete_popup :: () { using ImGui;
@ -1274,9 +1278,7 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
exporting := viewer.open_folder.count > 0; exporting := viewer.open_folder.count > 0;
folder := viewer.open_folder; folder := viewer.open_folder;
if !exporting { if !exporting {
#if OS == .WINDOWS base := windows_expand_environment("%TEMP%"); folder = tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
else base := "/tmp";
folder = tprint("%/%", base, APP_FOLDER_NAME);
} }
make_directory_if_it_does_not_exist(folder, recursive = true); 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; } if !is_directory(folder) { viewer.open_error = copy_string(tprint("% is not a folder that can be written to", folder)); return 0; }
@ -1399,10 +1401,10 @@ viewer_export_popup :: () { using ImGui;
if custom { if custom {
SetNextItemWidth(GetFontSize() * 30); SetNextItemWidth(GetFontSize() * 30);
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count); InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
#if OS == .WINDOWS { #if OS == .WINDOWS || OS == .MACOS {
SameLine(); SameLine();
if Button("Browse...") { if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to export to"); chosen := platform_choose_folder("Choose a folder to export to");
if chosen viewer_export_set_buffer(chosen); if chosen viewer_export_set_buffer(chosen);
} }
} }
@ -1464,7 +1466,7 @@ viewer_copy_image :: () {
index := viewer_selected(); index := viewer_selected();
if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; } if index < 0 { toast_show(.Error, "Select a capture to copy its image"); return; }
capture := *viewer.captures[index]; capture := *viewer.captures[index];
#if OS != .WINDOWS { #if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet"); toast_show(.Error, "Copying images is not available on this system yet");
return; return;
} }

View File

@ -9,6 +9,13 @@ platform_get_path_config_data :: inline () -> string {
return XDG.get_path(.CONFIG_HOME); return XDG.get_path(.CONFIG_HOME);
} }
// $TMPDIR, else /tmp, without a separator at the end. Temp.
platform_temporary_folder :: () -> string {
folder := posix_getenv("TMPDIR");
if !folder return "/tmp";
return trim_right(copy_string(folder,, temp), "/");
}
// A tag such as ja-JP from the locale variables, or "". Temp. // A tag such as ja-JP from the locale variables, or "". Temp.
platform_preferred_language :: () -> string { platform_preferred_language :: () -> string {
for string.["LC_ALL", "LC_MESSAGES", "LANG"] { for string.["LC_ALL", "LC_MESSAGES", "LANG"] {

View File

@ -70,6 +70,93 @@ platform_cjk_font_candidates :: () -> [] Cjk_Font {
return list; return list;
} }
// ---------------------------------------------------------------- folders, the Trash and the clipboard
// The user's own temporary folder, NSTemporaryDirectory() (under /var/folders, cleared by macOS),
// not /tmp, which every user shares. No separator at the end. Temp.
platform_temporary_folder :: () -> string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
folder := copy_string(to_string(NSTemporaryDirectory()),, temp); // the NSString goes with the pool
return trim_right(folder, "/");
}
// Asks for a folder with the system's open panel, over the app's window: its path (temp), or ""
// when none was chosen. Main thread; it runs modally until the panel is closed.
platform_choose_folder :: (title: string) -> string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
panel := NSOpenPanel.openPanel();
if !panel return "";
func_bool :: #type (*void, Selector, BOOL) -> void #c_call;
func_string :: #type (*void, Selector, *NSString) -> void #c_call;
(cast(func_bool) objc_msgSend)(panel, selector("setCanChooseDirectories:"), YES);
(cast(func_bool) objc_msgSend)(panel, selector("setCanChooseFiles:"), NO);
(cast(func_bool) objc_msgSend)(panel, selector("setAllowsMultipleSelection:"), NO);
(cast(func_bool) objc_msgSend)(panel, selector("setCanCreateDirectories:"), YES);
(cast(func_string) objc_msgSend)(panel, selector("setMessage:"), NSString.getTempString(title));
(cast(func_string) objc_msgSend)(panel, selector("setPrompt:"), NSString.getTempString("Choose"));
save_panel := cast(*NSSavePanel) panel;
if NSSavePanel.runModal(save_panel) != NSModalResponseOK return "";
url := NSSavePanel.URL(save_panel);
if !url return "";
return copy_string(to_string(NSURL.path(url)),, temp);
}
// Moves a file to the Trash, where Finder's Put Back returns it to where it was. Error is "" or why not.
macos_move_to_trash :: (path: string) -> ok: bool, error: string {
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
url := NSURL.fileURLWithPath(NSString.getTempString(path));
error: *NSError;
func :: #type (*void, Selector, *NSURL, **NSURL, **NSError) -> BOOL #c_call;
if (cast(func) objc_msgSend)(NSFileManager.defaultManager(), selector("trashItemAtURL:resultingItemURL:error:"), url, null, *error) == YES return true, "";
why := ifx error then copy_string(to_string(NSError.localizedDescription(error)),, temp) else "no reason given";
return false, why;
}
// Puts RGBA pixels, top row first, on the clipboard as PNG and TIFF, in sRGB and opaque, as the
// Windows version makes them. Every app that pastes images takes one of the two.
platform_copy_image_to_clipboard :: (pixels: [] u8, width: s32, height: s32) -> bool {
if width <= 0 || height <= 0 || pixels.count < cast(s64) width * height * 4 return false;
pool := objc_new(NSAutoreleasePool);
defer NSAutoreleasePool.drain(pool);
// AppKit allocates the pixels (planes null) and frees them with the rep.
rep := NSBitmapImageRep.initWithBitmapDataPlanes(objc_alloc(NSBitmapImageRep), null, width, height, 8, 4, YES, NO, NSDeviceRGBColorSpace, cast(s64) width * 4, 32);
if !rep return false;
autorelease(rep);
out := NSBitmapImageRep.bitmapData(rep);
if !out return false;
memcpy(out, pixels.data, cast(s64) width * height * 4);
for 0..cast(s64) width * height - 1 out[it * 4 + 3] = 255;
// Tagged sRGB, as the pixels are, so a paste doesn't shift their colors on a wide-gamut screen.
func_object :: #type (*void, Selector) -> *NSObject #c_call;
func_retag :: #type (*void, Selector, *NSObject) -> *NSBitmapImageRep #c_call;
srgb := (cast(func_object) objc_msgSend)(objc_getClass("NSColorSpace"), selector("sRGBColorSpace"));
tagged := (cast(func_retag) objc_msgSend)(rep, selector("bitmapImageRepByRetaggingWithColorSpace:"), srgb);
if tagged rep = tagged;
NSBitmapImageFileTypePNG :: 4;
func_png :: #type (*void, Selector, NSUInteger, *NSObject) -> *NSData #c_call;
properties := (cast(func_object) objc_msgSend)(objc_getClass("NSDictionary"), selector("dictionary"));
png := (cast(func_png) objc_msgSend)(rep, selector("representationUsingType:properties:"), NSBitmapImageFileTypePNG, properties);
tiff := cast(*NSData) (cast(func_object) objc_msgSend)(rep, selector("TIFFRepresentation"));
if !png && !tiff return false;
pasteboard := NSPasteboard.generalPasteboard();
NSPasteboard.clearContents(pasteboard);
func_set :: #type (*void, Selector, *NSData, NSPasteboardType) -> BOOL #c_call;
set := false;
if png set = (cast(func_set) objc_msgSend)(pasteboard, selector("setData:forType:"), png, NSPasteboardTypePNG) == YES || set;
if tiff set = (cast(func_set) objc_msgSend)(pasteboard, selector("setData:forType:"), tiff, NSPasteboardTypeTIFF) == YES || set;
return set;
}
NSTemporaryDirectory :: () -> *NSString #foreign macos_foundation;
macos_foundation :: #library,system "Foundation"; // the Objective_C module keeps its own to itself
Mach :: #import "macOS"; // task_info Mach :: #import "macOS"; // task_info
#import "Objective_C"; #import "Objective_C";

View File

@ -846,6 +846,13 @@ os_log_error :: () {
error_code := GetLastError(); error_code := GetLastError();
log_error(" > GetLastError code: %, %", error_code, get_error_string(error_code)); log_error(" > GetLastError code: %, %", error_code, get_error_string(error_code));
} }
// %TEMP%, without a separator at the end. Temp.
platform_temporary_folder :: () -> string {
return trim_right(windows_expand_environment("%TEMP%"), "/\\");
}
platform_choose_folder :: (title: string) -> string { return windows_choose_folder(title); }
// Asks for a folder with Explorer's own picker, over the app's window: its path (temp), or "" when // Asks for a folder with Explorer's own picker, over the app's window: its path (temp), or "" when
// none was chosen. // none was chosen.
windows_choose_folder :: (title: string) -> string { windows_choose_folder :: (title: string) -> string {