mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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.
This commit is contained in:
1 parent
8fd0612069
commit
a60f7e1858
2 files changed
+3
-141
No files matched your search
@@ -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<NSSavePanel>,
|
||||
filters: Vec<Filter>,
|
||||
}
|
||||
|
||||
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::<NSView>().as_ref() };
|
||||
let Some(sheet) = view.window().and_then(|w| w.attachedSheet()) else { return };
|
||||
let Some(panel) = sheet.downcast_ref::<NSSavePanel>() 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<FormatAccessory> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
Reference in new issue
Block a user