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
@@ -6650,6 +6650,7 @@ name = "wordcraft" version = "0.11.0" dependencies = [ "arboard",+ "block2 0.6.2", "chrono", "clipboard-win", "craft-print",@@ -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"@@ -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")))]@@ -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;new file mode 100644@@ -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");+ }+}