[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**
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
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
`.snagx` it was converted from is untouched.
- **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
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
only so far.
are `copy_image` and `copy_path` in `[hotkeys]` in `settings.config` (**Cmd** for Ctrl on macOS).
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]`).
- **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.
@ -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:
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`).
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

View File

@ -49,7 +49,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check";
GIT_HASH :: "-";
VERSION :: "0.1.25";
VERSION :: "0.1.26";
BUILD_DATE :: "-";
DEBUG :: true;
COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "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
// builds never write over the ones you run by hand.
build_directory := "bin";

View File

@ -1038,9 +1038,9 @@ import_window :: () { using ImGui;
import_source_changed();
}
SameLine();
#if OS == .WINDOWS {
#if OS == .WINDOWS || OS == .MACOS {
if Button("Browse...") {
chosen := windows_choose_folder("Choose a folder to import");
chosen := platform_choose_folder("Choose a folder to import");
if chosen {
count := min(chosen.count, importer.source_buffer.count - 1);
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);
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 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));
}, visit_files = false, visit_directories = true);
quick_sort(open_file.folders, compare);
@ -359,7 +359,7 @@ open_file_copy_path :: () {
}
open_file_copy_image :: () {
#if OS != .WINDOWS {
#if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet");
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";
}
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;
}
}

View File

@ -190,8 +190,7 @@ screen_record_leftover_date :: (path: string) -> string {
}
screen_record_folder :: () -> string {
#if OS == .WINDOWS return tprint("%/%", windows_expand_environment("%TEMP%"), APP_FOLDER_NAME);
else return tprint("/tmp/%", APP_FOLDER_NAME);
return tprint("%/%", platform_temporary_folder(), APP_FOLDER_NAME);
}
#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
// 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 {
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;
#if OS == .LINUX if relative == "deleted" || begins_with(relative, "deleted/") return false;
return true;
}
@ -837,13 +837,13 @@ viewer_mark_removed :: (index: s32) {
}
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
}
// 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
// (Windows asks first when the drive has none and it would be deleted for good), the Trash on macOS,
// and on Linux into a deleted folder in the library. 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];
@ -860,6 +860,9 @@ viewer_delete_capture :: (index: s32, mode: Viewer_Delete_Mode) -> ok: bool, err
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 #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 {
folder := tprint("%/deleted", viewer_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);
}
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else VIEWER_TRASH_NAME :: "deleted folder";
#if OS == .WINDOWS VIEWER_TRASH_NAME :: "Recycle Bin";
else #if OS == .MACOS VIEWER_TRASH_NAME :: "Trash";
else VIEWER_TRASH_NAME :: "deleted folder";
// The confirmation for viewer.delete_index, as a modal popup.
viewer_delete_popup :: () { using ImGui;
@ -1274,9 +1278,7 @@ viewer_open_proc :: (thread: *Thread) -> s64 {
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);
folder = tprint("%/%", platform_temporary_folder(), 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; }
@ -1399,10 +1401,10 @@ viewer_export_popup :: () { using ImGui;
if custom {
SetNextItemWidth(GetFontSize() * 30);
InputTextWithHint("##folder", "The folder to export to", viewer.export_buffer.data, viewer.export_buffer.count);
#if OS == .WINDOWS {
#if OS == .WINDOWS || OS == .MACOS {
SameLine();
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);
}
}
@ -1464,7 +1466,7 @@ 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 {
#if OS == .LINUX {
toast_show(.Error, "Copying images is not available on this system yet");
return;
}

View File

@ -9,6 +9,13 @@ platform_get_path_config_data :: inline () -> string {
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.
platform_preferred_language :: () -> string {
for string.["LC_ALL", "LC_MESSAGES", "LANG"] {

View File

@ -70,6 +70,93 @@ platform_cjk_font_candidates :: () -> [] Cjk_Font {
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
#import "Objective_C";

View File

@ -846,6 +846,13 @@ os_log_error :: () {
error_code := GetLastError();
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
// none was chosen.
windows_choose_folder :: (title: string) -> string {