Files
vectorcraft/crates/ui-egui/src/picks.rs
T
dotCooCoo 468d7b21e9 Save As: on Linux the file type picked in the save dialog decides the format
On Linux the save dialog is the XDG desktop portal's, shown through
rfd. rfd sends no `current_filter` and reads only the file name from
the portal's answer, and the GTK file chooser keeps the typed name
when another file type is picked. With "PDF-compatible .ai" picked,
`poster` typed for a new document was saved as a native
`poster.vectorcraft`, and so was the suggested
`Untitled-1.vectorcraft` left as it was.

When a save offers two or more file types (Save As, Save a Copy, Save
as Template and the first Save of a new document), the app now calls
the portal's SaveFile itself over D-Bus. The dialog starts on the
suggested file's type, and its answer names the type picked. A name
typed without an extension, or the suggested name left as it was,
gets that type's extension. A name typed with another save format's
extension, other than the suggested file's (for Save as Template, a
template format's), is saved in that format, and any other extension
gets the picked type's added. The dialog keeps the window as its
parent on X11 and Wayland, and rfd shows the dialog when the portal
can't. The same code runs on the BSDs; Windows, macOS and the web
build keep their dialogs.

Docs: docs/target-app-parity.md.
2026-10-18 12:36:23 +07:00

427 lines
17 KiB
Rust

//! File dialogs that leave the window running (#592, #867).
//!
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
//! closes. On Windows the dialog runs the window's events meanwhile. On Linux nothing does, so a
//! Wayland compositor finds the window not answering and offers to kill it (#592); on macOS the
//! panel's modal loop runs inside winit's event handler, and an event winit had held back meanwhile
//! re-enters it and aborts the app (#867). There the host shows dialogs off the UI thread (Linux)
//! or as sheets (macOS) ([`Services::start_pick`]): asking returns no path for now, and when the
//! dialog answers, what asked (the command, the dialog's OK, a panel's button: the *entry*) runs
//! again, on the document it asked for, and gets the picked path at once. Everything that asks
//! must run as an entry ([`as_entry`]): outside one the dialog is shown in line, which macOS
//! refuses.
//!
//! While a dialog is open ([`VectorcraftApp::file_dialog_open`]) another is refused; on macOS,
//! where it is a sheet on the window, the menu bar does nothing and the app neither quits nor
//! closes documents, as under the modal dialog before.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use serde_json::Value;
use crate::state::Dialog;
use crate::{FilePick, VectorcraftApp};
/// A file dialog to show.
#[derive(Clone, Debug, PartialEq)]
pub enum PickRequest {
/// An open dialog for one file.
Open(FilePick),
/// An open dialog for files to place.
OpenMany,
/// A save dialog.
Save(FilePick),
/// A folder picker.
Folder,
}
/// The paths a dialog shown off the UI thread picked (none: cancelled). A save dialog that reports
/// which of its file types was picked (the XDG portal's on Linux and the BSDs) answers that type's
/// first extension after the path.
pub type PickAnswer = Vec<String>;
/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and
/// the dialog is then shown in line.
pub type StartPick = Box<dyn FnMut(PickRequest) -> Option<Receiver<PickAnswer>>>;
/// What runs again with the picked path.
#[derive(Clone)]
pub(crate) enum Entry {
/// A command (`app.run`).
Command(String, Value),
/// A dialog's OK (`dialogs::confirm`), on the dialog as it was.
Confirm(Box<Dialog>),
/// A panel's or a dialog's button.
Call(Rc<dyn Fn(&mut VectorcraftApp)>),
}
/// Commands that ask for a file but act on no document: their answer goes on even when the
/// document active at the time has closed meanwhile.
const DOCUMENT_FREE: &[&str] = &[
"file.open",
"file.newFromTemplate",
"ui.installPlugin",
"ui.checkScript",
"window.swatchLibrary.other",
"window.graphicStyleLibrary.other",
"shortcuts.import",
"shortcuts.export",
"ui.shortcutImportDialog",
];
impl Entry {
/// The document it acts on when it runs again: the active one, unless it acts on none.
fn document(&self, app: &VectorcraftApp) -> Option<u64> {
match self {
Entry::Command(id, _) if DOCUMENT_FREE.contains(&id.as_str()) => None,
_ => app.session.active().map(|d| d.uid),
}
}
}
/// A dialog shown off the UI thread, waiting for its answer.
struct Waiting {
request: PickRequest,
answer: Receiver<PickAnswer>,
entry: Entry,
/// The active document when it was asked for ([`vectorcraft_engine::DocState::uid`]).
doc: Option<u64>,
}
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
#[derive(Default)]
pub(crate) struct Picks {
/// What is running now and asks for a path, the outermost one.
entry: Option<Entry>,
waiting: Option<Waiting>,
/// A picked answer for the entry running again: the dialog it asks for gets it.
answer: Option<(PickRequest, PickAnswer)>,
/// Another dialog was refused while this one is open ([`busy`]).
refused: bool,
/// A save dialog's answer waiting for the question whether to replace the file it names
/// ([`ask_to_replace`]).
replacing: Option<Replacing>,
/// The file that question's OK allows to be replaced while what asked runs again.
replace_ok: Option<String>,
}
/// A save dialog's answer that names, once the app gave it an extension, a file that is there.
struct Replacing {
request: PickRequest,
answer: PickAnswer,
/// The file it names.
path: String,
entry: Entry,
/// The active document when it was asked for ([`vectorcraft_engine::DocState::uid`]).
doc: Option<u64>,
}
impl Picks {
/// Nothing asking for file dialogs is running.
pub(crate) fn is_entry_free(&self) -> bool {
self.entry.is_none()
}
/// A dialog shown off the UI thread is open.
pub(crate) fn is_waiting(&self) -> bool {
self.waiting.is_some()
}
/// After a panic the guard caught (a bug): no entry is running any more, and an answer kept for
/// one is stale. The open dialog's answer still arrives.
pub(crate) fn recover(&mut self) {
self.entry = None;
self.answer = None;
self.replace_ok = None;
}
/// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`).
pub(crate) fn waiting_kind(&self) -> Option<&'static str> {
self.waiting.as_ref().map(|w| match w.request {
PickRequest::Open(_) => "open",
PickRequest::OpenMany => "place",
PickRequest::Save(_) => "save",
PickRequest::Folder => "folder",
})
}
}
/// What the status bar says when a dialog is asked for while another is open.
pub(crate) fn busy() -> &'static str {
tl!("A file dialog is open: choose there first")
}
/// What the status bar says when the dialog whose button asked closed before the file dialog
/// answered.
pub(crate) fn dialog_gone() -> &'static str {
tl!("The dialog was closed before its file dialog answered")
}
/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it
/// answers. Inside another entry, that one runs again instead.
pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
if app.picks.entry.is_some() {
return f(app);
}
app.picks.entry = Some(entry());
let r = f(app);
app.picks.entry = None;
r
}
/// [`as_entry`] for a panel's or a dialog's button that `f` handles.
pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) {
let f = Rc::new(f);
let again = f.clone();
as_entry(app, move || Entry::Call(again), |app| f(app));
}
/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown
/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind.
/// A question whether to replace a file that `f` asks ([`ask_to_replace`]) takes `d`'s place, with
/// `d` under it.
pub(crate) fn in_dialog(
app: &mut VectorcraftApp,
d: &mut Dialog,
f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static,
) -> Result<(), String> {
let f = Rc::new(move |app: &mut VectorcraftApp, d: &mut Dialog| {
let r = f(app, d);
if let Some(mut question) = app.ui.dialog.take_if(|q| q.kind == crate::dialogs::confirm::REPLACE) {
question.fields.insert(crate::dialogs::PARENT.into(), serde_json::to_value(&*d).unwrap_or_default());
*d = question;
}
r
});
let (again, kind) = (f.clone(), d.kind.clone());
let entry = move || {
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
let Some(mut d) = app.ui.dialog.take_if(|d| d.kind == kind) else {
app.status(dialog_gone());
return;
};
let r = again(app, &mut d);
app.ui.dialog = Some(d);
if let Err(e) = r {
app.status(e);
}
}))
};
as_entry(app, entry, |app| f(app, d))
}
/// A dialog body's folder button: the folder picked into field `key` of `d` ([`in_dialog`]).
pub(crate) fn folder_field(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) {
let r = in_dialog(app, d, move |app, d| {
if let Some(f) = folder(app) {
d.fields.insert(key.into(), serde_json::json!(f));
}
Ok(())
});
// Picking a folder doesn't fail.
let _ = r;
}
/// A path from an open dialog for `pick` (none: cancelled, or shown off the UI thread for now).
pub(crate) fn open(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
ask(app, PickRequest::Open(pick.clone()))?.into_iter().next()
}
/// A path from a save dialog for `pick`.
pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
ask(app, PickRequest::Save(pick.clone()))?.into_iter().next()
}
/// A path from a save dialog for `pick`, and the extension of the file type picked in it when the
/// dialog reports one ([`PickAnswer`]).
pub(crate) fn save_with_type(app: &mut VectorcraftApp, pick: &FilePick) -> Option<(String, Option<String>)> {
let mut answer = ask(app, PickRequest::Save(pick.clone()))?.into_iter();
Some((answer.next()?, answer.next()))
}
/// A folder from the folder picker.
pub(crate) fn folder(app: &mut VectorcraftApp) -> Option<String> {
ask(app, PickRequest::Folder)?.into_iter().next()
}
/// Paths from the open dialog for files to place (none: cancelled).
pub(crate) fn open_many(app: &mut VectorcraftApp) -> Vec<String> {
ask(app, PickRequest::OpenMany).unwrap_or_default()
}
/// Whether the host shows `request`'s kind of dialog at all.
pub(crate) fn can(app: &VectorcraftApp, request: &PickRequest) -> bool {
let s = &app.services;
match request {
PickRequest::Open(_) => s.pick_open.is_some(),
PickRequest::OpenMany => s.pick_open_multi.is_some() || s.pick_open.is_some(),
PickRequest::Save(_) => s.pick_save.is_some(),
PickRequest::Folder => s.pick_folder.is_some(),
}
}
/// The paths `request`'s dialog picked: the answer kept for it, else shown off the UI thread (no
/// paths for now), else shown in line.
fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
// One for another dialog is dropped: the entry took another way this time.
if let Some((asked, answer)) = app.picks.answer.take()
&& asked == request
{
return Some(answer);
}
if !can(app, &request) {
return None;
}
if app.services.start_pick.is_some() && app.picks.waiting.is_some() {
app.status(busy());
app.picks.refused = true;
return None;
}
match (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
(Some(entry), Some(start)) => {
if let Some(answer) = start(request.clone()) {
let doc = entry.document(app);
app.picks.waiting = Some(Waiting { request, answer, entry, doc });
return None;
}
}
// Shown in line it holds the window (Linux) or is refused (macOS): what asks must run as
// an entry.
(None, Some(_)) => log::warn!("a file dialog asked for outside a command, a dialog's OK or a button: {request:?}"),
_ => {}
}
let s = &mut app.services;
let one = |p: Option<String>| Some(p.into_iter().collect());
match &request {
PickRequest::Open(pick) => one(s.pick_open.as_mut().and_then(|f| f(pick))),
PickRequest::Save(pick) => one(s.pick_save.as_mut().and_then(|f| f(pick))),
PickRequest::Folder => one(s.pick_folder.as_mut().and_then(|f| f())),
PickRequest::OpenMany => match (s.pick_open_multi.as_mut(), s.pick_open.as_mut()) {
(Some(many), _) => Some(many()),
(None, Some(f)) => one(f(&FilePick::default())),
(None, None) => None,
},
}
}
/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again
/// with them. While it is open, look again shortly.
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Clear the "cancelled" that a save leaves in the status bar when it stops to ask whether to
// replace a file ([`ask_to_replace`]).
if app.ui.status == "cancelled" && app.ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::confirm::REPLACE) {
app.ui.status.clear();
}
let Some(w) = &app.picks.waiting else { return };
let answer = match w.answer.try_recv() {
Ok(answer) => answer,
Err(TryRecvError::Empty) => {
// What asked gave up for now: no "cancelled" while the dialog is open, unless it was
// another dialog, refused.
if app.ui.status == "cancelled" {
app.ui.status = if std::mem::take(&mut app.picks.refused) { busy().into() } else { String::new() };
}
// The host wakes the window when the dialog answers; this is in case it can't.
ctx.request_repaint_after(std::time::Duration::from_secs(1));
return;
}
// The dialog's thread is gone: as cancelled.
Err(TryRecvError::Disconnected) => vec![],
};
let Some(Waiting { request, entry, doc, .. }) = app.picks.waiting.take() else { return };
app.picks.refused = false;
// Once more: what waited for the dialog (files from the Finder, the menu bar) goes on.
ctx.request_repaint();
if answer.is_empty() {
return;
}
if !on_document(app, doc) {
return;
}
app.picks.answer = Some((request, answer));
rerun(app, entry);
// An entry that took another way this time leaves the answer unused.
app.picks.answer = None;
}
/// Make the document an entry asked for (`doc`, by its uid) active again before it runs again:
/// another may be active by now (opened from the Finder, by an agent), or it may be gone → false
/// when it can't run (the status bar says why).
fn on_document(app: &mut VectorcraftApp, doc: Option<u64>) -> bool {
let Some(uid) = doc else { return true };
let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else {
app.status(tl!("The document was closed while its file dialog was open"));
return false;
};
if app.session.active_index() != Some(i)
&& let Err(e) = app.run("document.activate", serde_json::json!({ "index": i }))
{
app.status(e);
return false;
}
true
}
/// `picked`, a save dialog's answer to `request` (with `file_type`, the extension of the file type
/// picked in it when the dialog reports one), names `path`, another file, once the app gave it
/// an extension: the dialog checked only `picked` for an existing file, not `path`. When a file is
/// at `path`, ask before replacing it, over the dialog open now → true while the question is open
/// (nothing is written meanwhile). OK runs what asked again with the same answer, and `path` is
/// written then ([`replace`]); Cancel writes nothing. Nothing is asked while that OK runs what
/// asked, or outside an entry, which nothing could run again.
pub(crate) fn ask_to_replace(app: &mut VectorcraftApp, request: PickRequest, picked: &str, file_type: Option<&str>, path: &str) -> bool {
if app.picks.replace_ok.as_deref() == Some(path) || !app.services.exists.as_ref().is_some_and(|exists| exists(path)) {
return false;
}
let Some(entry) = app.picks.entry.clone() else { return false };
let doc = entry.document(app);
let answer = std::iter::once(picked).chain(file_type).map(str::to_string).collect();
app.picks.replacing = Some(Replacing { request, answer, path: path.to_string(), entry, doc });
crate::dialogs::confirm::ask_replace(app, path, picked);
true
}
/// The OK of the question whether to replace the file at `path` ([`ask_to_replace`]): the dialog
/// it was asked over shows again, and what asked runs again with the same answer, on the document
/// it asked for, replacing the file.
pub(crate) fn replace(app: &mut VectorcraftApp, path: &str) -> Result<Value, String> {
app.ui.dialog = app.ui.dialog.take().and_then(|d| crate::dialogs::parent(&d));
let Some(Replacing { request, answer, path, entry, doc }) = app.picks.replacing.take().filter(|r| r.path == path) else {
return Ok(Value::Null);
};
if !on_document(app, doc) {
return Ok(Value::Null);
}
app.picks.answer = Some((request, answer));
app.picks.replace_ok = Some(path);
// A dialog it asks for next runs what asked again, not this question.
let outer = app.picks.entry.replace(entry.clone());
rerun(app, entry);
app.picks.entry = outer;
app.picks.answer = None;
app.picks.replace_ok = None;
Ok(Value::Null)
}
/// Run `entry` again.
fn rerun(app: &mut VectorcraftApp, entry: Entry) {
let r = match entry {
Entry::Command(id, params) => app.run(&id, params).map(|_| ()),
Entry::Confirm(d) => {
app.ui.dialog = Some(*d);
crate::dialogs::confirm(app).map(|_| ())
}
// An entry again, so a dialog it asks for next is shown off the UI thread too.
Entry::Call(f) => {
let again = f.clone();
as_entry(app, move || Entry::Call(again), |app| f(app));
Ok(())
}
};
if let Err(e) = r {
app.status(e);
}
}