Files
vectorcraft/crates/ui-egui/src/picks.rs
T
dotCooCoo b4fef5dc98 Library panels: a persistent panel whose file is gone stays listed as missing
At startup, a persistent swatch or graphic style library panel whose
file could not be read was closed and left out of the preferences,
with only a status-bar note. The next preferences save dropped it, so
moving or renaming the file once lost the panel and its settings.

Such a panel now reopens where the saved layout had it and stays in
the preferences. Its tab shows the file's name without the extension,
and its body shows "Missing:" with the file's path and a Locate
Library… button. The new command ui.libraryPanel.locate {panel,
path?} loads the library from the file given, or from one picked in
an open dialog, and shows it in the same panel, which keeps its view,
Show Find Field and Persistent. When the file's library is open in
another panel, the missing panel closes and that panel is brought
forward and made persistent.

A persistent panel whose User Defined library file is deleted while
the app runs shows the library as missing the same way; other panels
still close. window.swatchLibrary and window.graphicStyleLibrary take
a missing library's id, so a closed missing panel opens again.
Previous Library and Next Library are dimmed in a missing panel's
menu.

Docs: docs/mcp.md, docs/ui-parity.md, docs/target-app-parity.md.
2026-10-21 23:14:32 +07:00

428 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",
"ui.libraryPanel.locate",
"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);
}
}