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 8d827d24..d1375a8b 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; @@ -485,9 +483,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 => {