collective

Browse / github.com/storytold/wordcraft / patch

macOS: Save As has a File Format menu, as in Word for Mac

macOS: Save As has a File Format menu, as in Word for Mac Refs #1399. ### What On Windows and Linux, Save As lists the formats WordCraft saves (#1399): Word Document, PDF, Rich Text Format, OpenDocument Text and so on. On the Mac it lists nothing. rfd hands the Mac save panel its filters as allowed file types only (`setAllowedFileTypes`), and NSSavePanel shows no menu for those. Saving as PDF or RTF on the Mac meant knowing to type the extension. Word for Mac's Save As has a **File Format** menu below the name. ### Change - New `apps/wordcraft/src/save_panel.rs` (macOS only): Save As shows its own NSSavePanel, as a sheet on the window, with a "Fi

Status
open
Score
0.00
Adopters
0 other operators
Author
jpc7k1 (imported from pull request #4512, open)
Base
d859c673c1e93bccaa6536fa3653b902ea877cea
Size
+136 −2 in 5 files
Review
pending
Shared
2026-10-28
Id
65fb3029179b60db56810d85

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/wordcraft/Cargo.toml apps/wordcraft/src/file_dialogs.rs apps/wordcraft/src/main.rs apps/wordcraft/src/save_panel.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -6650,6 +6650,7 @@ name = "wordcraft" version = "0.11.0" dependencies = [  "arboard",+ "block2 0.6.2",  "chrono",  "clipboard-win",  "craft-print",diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml--- a/apps/wordcraft/Cargo.toml+++ b/apps/wordcraft/Cargo.toml@@ -39,12 +39,15 @@ fmv-macos-events = "=0.1.0" # NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard, # at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the # traffic lights, the window, button and view ones.-objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard", "NSSavePanel", "NSPanel", "NSPopUpButton", "NSMenuItemCell", "NSButtonCell", "NSActionCell", "NSCell", "NSTextField", "NSStackView", "block2", "objc2-core-foundation"] } objc2 = "0.6" # A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside # arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in # the build through arboard, at this version; this adds only the NSString bindings.-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSArray", "NSURL", "NSGeometry", "objc2-core-foundation"] }+# Save As's File Format menu (`save_panel`): the Save panel's completion handler is a block.+# Already in the build through objc2-app-kit, at this version.+block2 = "0.6" # Closing the window must quit through NSApplication rather than by dropping the NSWindow # inside the AppKit run loop (see `schedule_macos_terminate`). dispatch2 = "0.3"diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs--- a/apps/wordcraft/src/file_dialogs.rs+++ b/apps/wordcraft/src/file_dialogs.rs@@ -59,6 +59,14 @@ impl Launcher {     /// keeps them on top of it. Call after every pass that may ask for one.     pub fn show(&self, parent: &eframe::Frame) {         while let Ok((req, answer)) = self.queue.try_recv() {+            // macOS: Save As has Word for Mac's File Format menu, which rfd's panel lacks.+            #[cfg(target_os = "macos")]+            if let FileDialogRequest::Save { name, dir } = &req+                && no_system_dialog().is_none()+            {+                crate::save_panel::ask(name, dir.as_deref(), save_filters(name), answer, self.ctx.clone());+                continue;+            }             // Built here, on the UI thread: macOS shows the panel as a sheet on the window now.             let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);             #[cfg(all(unix, not(target_os = "macos")))]diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs--- a/apps/wordcraft/src/main.rs+++ b/apps/wordcraft/src/main.rs@@ -22,6 +22,8 @@ mod logging; mod print; mod region; #[cfg(target_os = "macos")]+mod save_panel;+#[cfg(target_os = "macos")] mod traffic_lights;  use wordcraft_engine::Session;diff --git a/apps/wordcraft/src/save_panel.rs b/apps/wordcraft/src/save_panel.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft/src/save_panel.rs@@ -0,0 +1,120 @@+//! Save As on macOS, with Word for Mac's File Format menu below the name.+//!+//! rfd gives the Mac panel the save formats as allowed file types only, which shows no menu: saving+//! as PDF or Rich Text meant knowing to type the extension. These calls are safe in the+//! `objc2-app-kit` bindings (our workspace forbids unsafe). The menu can't rename the file while it+//! changes, as Word's does (that needs a target-action or a notification observer, both unsafe);+//! the format picked applies when Save is clicked ([`chosen_path`]).++use std::path::Path;+use std::sync::mpsc::Sender;++use block2::RcBlock;+use objc2::{MainThreadMarker, MainThreadOnly};+use objc2_app_kit::{NSApplication, NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSStackView, NSTextField, NSView};+use objc2_foundation::{NSArray, NSEdgeInsets, NSPoint, NSRect, NSSize, NSString, NSURL};+use wordcraft_ui_egui::FileDialogAnswer;++/// Show the Save panel as a sheet on the app's window, the suggested `name` in `dir`, with a File+/// Format menu of `formats` (label and extensions, the suggested one first). The path picked, with+/// the chosen format's extension, goes back through `answer`; nothing, when cancelled.+pub fn ask(name: &str, dir: Option<&str>, formats: Vec<(String, Vec<String>)>, answer: Sender<FileDialogAnswer>, ctx: egui::Context) {+    let Some(mtm) = MainThreadMarker::new() else {+        let _ = answer.send(Err("the Save panel must be shown on the main thread".into()));+        return;+    };+    let panel = NSSavePanel::savePanel(mtm);+    panel.setNameFieldStringValue(&NSString::from_str(name));+    if let Some(dir) = dir.filter(|d| Path::new(d).is_dir()) {+        panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&NSString::from_str(dir), true)));+    }+    panel.setExtensionHidden(false);++    let menu =+        NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(260.0, 26.0)), false);+    for (label, _) in &formats {+        menu.addItemWithTitle(&NSString::from_str(label));+    }+    menu.selectItemAtIndex(0);+    let label = NSTextField::labelWithString(&NSString::from_str(wordcraft_ui_egui::tl!("File Format:")), mtm);+    let views: [&NSView; 2] = [&label, &menu];+    let row = NSStackView::stackViewWithViews(&NSArray::from_slice(&views), mtm);+    row.setSpacing(8.0);+    row.setEdgeInsets(NSEdgeInsets { top: 10.0, left: 20.0, bottom: 10.0, right: 20.0 });+    panel.setAccessoryView(Some(&row));++    let shown = panel.clone();+    let handler = RcBlock::new(move |response: NSModalResponse| {+        let picked = (response == NSModalResponseOK).then(|| shown.URL().and_then(|u| u.path())).flatten();+        let Some(path) = picked else {+            let _ = answer.send(Ok(Vec::new()));+            ctx.request_repaint();+            return;+        };+        let chosen = usize::try_from(menu.indexOfSelectedItem()).ok().and_then(|i| formats.get(i)).and_then(|(_, e)| e.first());+        let changed = menu.indexOfSelectedItem() != 0;+        let path = path.to_string();+        let file = chosen.map_or_else(|| path.clone(), |ext| chosen_path(&path, ext, changed));+        // The panel asked about replacing the file it named; a format changed in the menu names+        // another, which may be there too: ask again at that name, so the panel asks first.+        if file != path && Path::new(&file).exists() {+            let (name, dir) = split(&file);+            let mut formats = formats.clone();+            let i = formats.iter().position(|(_, e)| e.first() == chosen).unwrap_or(0);+            let first = formats.remove(i);+            formats.insert(0, first);+            let (answer, ctx) = (answer.clone(), ctx.clone());+            dispatch2::DispatchQueue::main().exec_async(move || ask(&name, dir.as_deref(), formats, answer, ctx));+            return;+        }+        let _ = answer.send(Ok(vec![file]));+        ctx.request_repaint();+    });+    match NSApplication::sharedApplication(mtm).keyWindow().or_else(|| NSApplication::sharedApplication(mtm).mainWindow()) {+        Some(window) => panel.beginSheetModalForWindow_completionHandler(&window, &handler),+        None => panel.beginWithCompletionHandler(&handler),+    }+}++/// A path's file name and folder.+fn split(path: &str) -> (String, Option<String>) {+    let p = Path::new(path);+    let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();+    (name, p.parent().map(|d| d.to_string_lossy().into_owned()))+}++/// The file to save: `path` from the panel, with the extension of format `ext`. A format changed+/// in the menu decides: it replaces a save format's extension in the name ("Brief.docx" as PDF is+/// "Brief.pdf") or is added after any other ("Smith v. Jones" is "Smith v. Jones.pdf"). With the+/// menu left alone, a name typed with a save format's extension keeps it, and one without gets+/// the menu's.+pub fn chosen_path(path: &str, ext: &str, changed: bool) -> String {+    let p = Path::new(path);+    let typed = p+        .extension()+        .and_then(std::ffi::OsStr::to_str)+        .map(str::to_ascii_lowercase)+        .filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()));+    match typed {+        Some(_) if !changed => path.to_string(),+        Some(_) => p.with_extension(ext).to_string_lossy().into_owned(),+        None => format!("{path}.{ext}"),+    }+}++#[cfg(test)]+mod tests {+    use super::chosen_path;++    /// The File Format menu decides the extension when it was changed; left alone, a typed+    /// extension stands.+    #[test]+    fn the_file_format_menu_picks_the_extension() {+        assert_eq!(chosen_path("/d/Brief.docx", "pdf", true), "/d/Brief.pdf");+        assert_eq!(chosen_path("/d/Brief.docx", "rtf", true), "/d/Brief.rtf");+        assert_eq!(chosen_path("/d/Brief.pdf", "docx", false), "/d/Brief.pdf", "a typed extension stands");+        assert_eq!(chosen_path("/d/Brief", "docx", false), "/d/Brief.docx");+        assert_eq!(chosen_path("/d/Smith v. Jones", "pdf", true), "/d/Smith v. Jones.pdf", "a dot in the name is no extension");+        assert_eq!(chosen_path("/d/Order.PDF", "docx", false), "/d/Order.PDF");+    }+}