From 5571c21d0bf7c2cbcc8f62a5821b5c30d2b3b57b Mon Sep 17 00:00:00 2001 From: Musa Mahmood Date: Fri, 16 Oct 2026 15:15:04 -0400 Subject: [PATCH] [0.1.26] macOS phase 2: folder picker, the Trash, the temporary folder, copying images --- README.md | 9 +-- check_targets.jai | 2 +- first.jai | 2 +- src/app/import.jai | 4 +- src/app/open_file.jai | 4 +- src/app/screen_capture.jai | 2 +- src/app/screen_record.jai | 3 +- src/app/viewer.jai | 28 +++++---- src/base/platform/platform_linux.jai | 7 +++ src/base/platform/platform_macos.jai | 87 ++++++++++++++++++++++++++ src/base/platform/platform_windows.jai | 7 +++ 11 files changed, 129 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 5c3b325..c8b0950 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/check_targets.jai b/check_targets.jai index 2aceba0..4c9e43e 100644 --- a/check_targets.jai +++ b/check_targets.jai @@ -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; diff --git a/first.jai b/first.jai index a137a67..08729d9 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.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"; diff --git a/src/app/import.jai b/src/app/import.jai index 42d6951..d4906dc 100644 --- a/src/app/import.jai +++ b/src/app/import.jai @@ -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); diff --git a/src/app/open_file.jai b/src/app/open_file.jai index f8b07ef..b253204 100644 --- a/src/app/open_file.jai +++ b/src/app/open_file.jai @@ -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; } diff --git a/src/app/screen_capture.jai b/src/app/screen_capture.jai index b35d4c1..a458690 100644 --- a/src/app/screen_capture.jai +++ b/src/app/screen_capture.jai @@ -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; } } diff --git a/src/app/screen_record.jai b/src/app/screen_record.jai index a8ecf3e..0e6525d 100644 --- a/src/app/screen_record.jai +++ b/src/app/screen_record.jai @@ -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 { diff --git a/src/app/viewer.jai b/src/app/viewer.jai index 5bcd35b..9767465 100644 --- a/src/app/viewer.jai +++ b/src/app/viewer.jai @@ -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; } diff --git a/src/base/platform/platform_linux.jai b/src/base/platform/platform_linux.jai index e9303c0..1b0f4fa 100644 --- a/src/base/platform/platform_linux.jai +++ b/src/base/platform/platform_linux.jai @@ -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"] { diff --git a/src/base/platform/platform_macos.jai b/src/base/platform/platform_macos.jai index 953d8a1..7ace8d6 100644 --- a/src/base/platform/platform_macos.jai +++ b/src/base/platform/platform_macos.jai @@ -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"; diff --git a/src/base/platform/platform_windows.jai b/src/base/platform/platform_windows.jai index 4493d7f..0100a43 100644 --- a/src/base/platform/platform_windows.jai +++ b/src/base/platform/platform_windows.jai @@ -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 {