From a60f7e185833316ef514092f363399df2e1237d6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 06:29:26 +0700 Subject: [PATCH] Fix #1800: macOS Save As shows its sheet again The Format menu added to the save sheet after rfd began it (#866) made recent macOS versions (26.6.2 and 27.0.1 reported) dismiss the sheet at once: Save As, Save a Copy and Save of an untitled document flashed and answered as cancelled, while panels without it (Open, Export As) worked. The accessory is withdrawn until it can be set before the sheet is presented; the format still follows the extension typed in the name field, as before #866. --- apps/vectorcraft/src/mac_save_format.rs | 137 ------------------------ apps/vectorcraft/src/main.rs | 7 +- 2 files changed, 3 insertions(+), 141 deletions(-) delete mode 100644 apps/vectorcraft/src/mac_save_format.rs diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs deleted file mode 100644 index 2bb468e1..00000000 --- a/apps/vectorcraft/src/mac_save_format.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Format menu on macOS save sheets (#866). rfd's Cocoa backend combines the extensions of -//! every filter but doesn't show their names. The accessory uses the same filters and updates -//! the filename, whose extension already determines the format in the save command. -//! -//! AppKit owns the accessory until the sheet closes; its target is the accessory itself. The -//! panel reference is weak, so closing the sheet releases both without a retain cycle. -//! Native view handles and Objective-C subclassing require the scoped unsafe allowance. -#![allow(unsafe_code)] - -use std::path::Path; - -use objc2::rc::{Retained, Weak}; -use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; -use objc2_app_kit::{NSPopUpButton, NSSavePanel, NSTextField, NSView}; -use objc2_foundation::{NSObjectProtocol, NSPoint, NSRect, NSSize, NSString}; -use raw_window_handle::{HasWindowHandle, RawWindowHandle}; -use vectorcraft_ui_egui::{FilePick, i18n}; - -type Filter = (&'static str, &'static [&'static str]); - -struct Ivars { - panel: Weak, - filters: Vec, -} - -define_class!( - // SAFETY: NSView has no extra subclassing requirements; the subclass implements no Drop. - #[unsafe(super = NSView)] - #[thread_kind = MainThreadOnly] - #[ivars = Ivars] - #[name = "VectorcraftSaveFormatAccessory"] - struct FormatAccessory; - - // SAFETY: NSObjectProtocol has no additional requirements. - unsafe impl NSObjectProtocol for FormatAccessory {} - - impl FormatAccessory { - // SAFETY: AppKit's target/action sends the popup as the single object argument. - #[unsafe(method(chooseFormat:))] - fn choose_format(&self, sender: &NSPopUpButton) { - let Some(panel) = self.ivars().panel.load() else { return }; - let Some(index) = usize::try_from(sender.indexOfSelectedItem()).ok() else { return }; - let Some((_, extensions)) = self.ivars().filters.get(index) else { return }; - let Some(extension) = extensions.first() else { return }; - let name = panel.nameFieldStringValue().to_string(); - let new = filename(&name, extension, &self.ivars().filters); - panel.setNameFieldStringValue(&NSString::from_str(&new)); - } - } -); - -/// The suggested file's type, or the request's default (the first filter). -fn initial_format(name: &str, filters: &[Filter]) -> usize { - let extension = Path::new(name).extension().and_then(|e| e.to_str()); - filters.iter().position(|(_, extensions)| extension.is_some_and(|e| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))).unwrap_or(0) -} - -/// Replace a known format suffix; keep other dots (e.g. a version number) as part of the name. -fn filename(name: &str, extension: &str, filters: &[Filter]) -> String { - if name.is_empty() { - return name.to_owned(); - } - let path = Path::new(name); - if path - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| filters.iter().any(|(_, extensions)| extensions.iter().any(|x| e.eq_ignore_ascii_case(x)))) - { - path.with_extension(extension).to_string_lossy().into_owned() - } else { - format!("{name}.{extension}") - } -} - -/// Add a Format menu to the save sheet rfd just began on `window`. Open panels and requests -/// without several named file types are left to rfd. -pub fn attach(window: &winit::window::Window, pick: &FilePick) { - let Some(mtm) = MainThreadMarker::new() else { return }; - let filters: Vec<_> = pick.filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect(); - if filters.len() < 2 { - return; - } - let Ok(handle) = window.window_handle() else { return }; - let RawWindowHandle::AppKit(handle) = handle.as_raw() else { return }; - // SAFETY: winit keeps its content view alive while this window reference is in use. - let view = unsafe { handle.ns_view.cast::().as_ref() }; - let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return }; - let Some(panel) = sheet.downcast_ref::() else { return }; - let selected = initial_format(&pick.name, &filters); - let this = FormatAccessory::alloc(mtm).set_ivars(Ivars { panel: Weak::new(panel), filters }); - // SAFETY: NSView's designated initializer is called once on the allocated subclass. - let accessory: Retained = unsafe { msg_send![super(this), initWithFrame: NSRect::new(NSPoint::ZERO, NSSize::new(360.0, 38.0))] }; - let label = NSTextField::labelWithString(&NSString::from_str(i18n::t("Format:")), mtm); - label.setFrame(NSRect::new(NSPoint::new(0.0, 10.0), NSSize::new(80.0, 20.0))); - let popup = - NSPopUpButton::initWithFrame_pullsDown(NSPopUpButton::alloc(mtm), NSRect::new(NSPoint::new(82.0, 6.0), NSSize::new(278.0, 28.0)), false); - for (name, _) in &accessory.ivars().filters { - popup.addItemWithTitle(&NSString::from_str(i18n::t(name))); - } - // The filter list comes from the built-in format registry, small enough for NSInteger. - if let Ok(index) = isize::try_from(selected) { - popup.selectItemAtIndex(index); - } - // SAFETY: the accessory owns the popup and AppKit keeps the accessory while the sheet is - // open. The selector above has exactly the target/action signature expected by NSControl. - unsafe { - popup.setTarget(Some(&accessory)); - popup.setAction(Some(sel!(chooseFormat:))); - accessory.addSubview(&label); - accessory.addSubview(&popup); - } - panel.setExtensionHidden(false); - panel.setAccessoryView(Some(&accessory)); -} - -#[cfg(test)] -mod tests { - use super::*; - - const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft", "drawcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])]; - - #[test] - fn current_suffix_selects_the_format_and_an_unknown_one_uses_the_default() { - for (name, index) in [("Untitled-1", 0), ("旧形式.drawcraft", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0)] { - assert_eq!(initial_format(name, FILTERS), index); - } - } - - #[test] - fn changing_format_keeps_the_name_and_avoids_duplicate_extensions() { - for (name, expected) in - [("図面 1.vectorcraft", "図面 1.svg"), ("a.PDF", "a.svg"), ("art", "art.svg"), ("art.v2", "art.v2.svg"), ("art.svg", "art.svg"), ("", "")] - { - assert_eq!(filename(name, "svg", FILTERS), expected); - } - } -} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e5a3736d..a8dd16ea 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -35,8 +35,6 @@ mod mac_fonts; #[cfg(target_os = "macos")] mod mac_menu; #[cfg(target_os = "macos")] -mod mac_save_format; -#[cfg(target_os = "macos")] mod mac_window; #[cfg(target_os = "macos")] mod open_documents; @@ -480,9 +478,10 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti let picked = dialog.pick_files(); Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) } - PickRequest::Save(pick) => { + PickRequest::Save(_) => { + // Nothing is added to the sheet once rfd has begun it: an accessory view set then made + // recent macOS versions dismiss the sheet at once, so nothing could be saved (#1800). let picked = dialog.save_file(); - mac_save_format::attach(window, &pick); Box::pin(async move { picked.await.iter().map(path).collect() }) } PickRequest::Folder => {