diff --git a/Cargo.lock b/Cargo.lock index dd94469..fff9355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4870,6 +4870,7 @@ dependencies = [ "fmv-macos-events", "getrandom 0.3.4", "log", + "pollster 0.4.0", "rfd", "serde_json", "winresource", diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 0451617..f006c2b 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -34,5 +34,8 @@ fmv-macos-events = "=0.1.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); +# rfd itself already depends on it. +pollster = "0.4" # The control server's random key. getrandom = "0.3" diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs new file mode 100644 index 0000000..cbe4000 --- /dev/null +++ b/apps/wordcraft/src/file_dialogs.rs @@ -0,0 +1,79 @@ +//! Native file dialogs (Open, Save As, Export, pictures) that don't freeze the window (#94). +//! +//! The blocking `rfd::FileDialog` ran on the UI thread: on Linux the xdg-desktop-portal call +//! blocked the event loop until the dialog closed, and with no parent window the dialog could open +//! behind WordCraft, which then sat there frozen. Now the app's request ([`Services::file_dialog`]) +//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the +//! window, and a background thread waits for the answer, hands it to the app and wakes the UI. +//! +//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog + +use std::future::Future; +use std::pin::Pin; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use wordcraft_ui_egui::FileDialogRequest; + +/// A dialog the app asked for, with the channel its answer goes back through. +type Request = (FileDialogRequest, Sender>); + +/// The `Services::file_dialog` hook. +pub type Hook = Box Receiver>>; + +/// A picker's answer, waited for off the UI thread. +type Picking = Pin> + Send>>; + +/// Shows the dialogs the app queued; lives in the eframe app, which has the window. +pub struct Launcher { + queue: Receiver, + ctx: egui::Context, +} + +/// The hook for [`Services::file_dialog`](wordcraft_ui_egui::Services::file_dialog) and the +/// launcher that shows what it queues. +pub fn hook(ctx: &egui::Context) -> (Hook, Launcher) { + let (queue_tx, queue) = channel::(); + let hook: Hook = Box::new(move |req| { + let (answer, rx) = channel(); + // If the launcher is gone, the dropped answer reads as "cancelled" in the app. + let _ = queue_tx.send((req, answer)); + rx + }); + (hook, Launcher { queue, ctx: ctx.clone() }) +} + +impl Launcher { + /// Show the queued dialogs with the window as their parent, so the portal (or the system) + /// keeps them on top of it. Call after every pass that may ask for one. + pub fn show(&self, parent: &eframe::Frame) { + while let Ok((req, answer)) = self.queue.try_recv() { + // Built here, on the UI thread: macOS shows the panel as a sheet on the window now. + let dialog = rfd::AsyncFileDialog::new().set_parent(parent); + let picking: Picking = match req { + FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()), + FileDialogRequest::Save { name } => Box::pin(dialog.set_file_name(name).save_file()), + }; + let ctx = self.ctx.clone(); + let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || { + let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string()); + let _ = answer.send(picked); + ctx.request_repaint(); + }); + // The answer's sender went with the closure, so the app sees the dialog as cancelled. + if let Err(e) = waiting { + log::error!("couldn't wait for the file dialog: {e}"); + } + } + } +} + +/// The file types Open offers: pictures for Insert/Change Picture, documents otherwise. +fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog { + if purpose == "picture" { + d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) + } else { + d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) + .add_filter("Word document", &["docx"]) + .add_filter("All files", &["*"]) + } +} diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 9fdd258..2a0991f 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -12,6 +12,7 @@ #[cfg(target_os = "macos")] mod apple_events; mod control_server; +mod file_dialogs; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging; @@ -23,14 +24,22 @@ use wordcraft_ui_egui::{ }; /// The app, the restored window geometry until the first frame has checked it, the control -/// server's key file (removed on exit) and, on macOS, the documents opened from Finder. -struct App(WordApp, Option, Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); +/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the +/// documents opened from Finder. +struct App( + WordApp, + Option, + Option, + file_dialogs::Launcher, + #[cfg(target_os = "macos")] fmv_macos_events::Inbox, +); impl eframe::App for App { - fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] - apple_events::poll(&self.3, &mut self.0, ctx); + apple_events::poll(&self.4, &mut self.0, ctx); self.0.logic(ctx); + self.3.show(frame); let prev = self.0.ui.window; self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) { @@ -47,8 +56,9 @@ impl eframe::App for App { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.0.ui(ui); + self.3.show(frame); } fn on_exit(&mut self) { save_prefs(&self.0); @@ -108,22 +118,9 @@ fn save_prefs(app: &WordApp) { } } -fn services() -> Services { - Services { - pick_open: Some(Box::new(|purpose: &str| { - let d = rfd::FileDialog::new(); - let d = if purpose == "picture" { - d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"]) - } else { - d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) - .add_filter("Word document", &["docx"]) - .add_filter("All files", &["*"]) - }; - d.pick_file().map(|p| p.to_string_lossy().to_string()) - })), - pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), - ..Default::default() - } +/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94). +fn services(file_dialog: file_dialogs::Hook) -> Services { + Services { file_dialog: Some(file_dialog), ..Default::default() } } /// Window, Dock and taskbar icon. @@ -206,7 +203,8 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() }; - let mut app = WordApp::new(Session::new(doc), services()); + let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); + let mut app = WordApp::new(Session::new(doc), services(file_dialog)); load_prefs(&mut app); app.ui.window = restored; app.integrated_titlebar = cfg!(target_os = "macos"); @@ -226,6 +224,7 @@ fn main() -> eframe::Result { app, restored, key_file, + dialogs, #[cfg(target_os = "macos")] apple_events.connect(&cc.egui_ctx), ))) diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 83b15f3..155e77b 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -321,14 +321,7 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) { ("Page image (*.png)", "png"), ] { if ui.add(egui::Button::new(egui::RichText::new(tl!(label)).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() { - let name = format!("{}.{ext}", app.title_stem()); - let picked = app.services.pick_save.as_ref().and_then(|f| f(&name)); - if let Some(path) = picked { - let r = if ext == "png" { app.run("file.exportPng", json!({"path": path})) } else { app.run("file.saveAs", json!({"path": path})) }; - if r.is_ok() { - app.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)])); - } - } + app.export_dialog(ext); } ui.add_space(4.0); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 70cedc1..4d99318 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -80,8 +80,9 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { if resp.on_hover_text(tl!("AutoSave saves every change to the file (needs a saved document)")).clicked() { if saved_here { app.toggle_autosave(); - } else if app.save_as_dialog() { - app.autosave = true; + } else { + // AutoSave comes on once Save As has saved (maybe on a later frame, #94). + let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave); } } ui.add_space(8.0); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs new file mode 100644 index 0000000..62649e6 --- /dev/null +++ b/crates/ui-egui/src/file_dialogs.rs @@ -0,0 +1,310 @@ +//! File dialogs (Open, Save As, Export, Insert/Change Picture) that don't freeze the window (#94). +//! +//! A host with [`Services::file_dialog`](crate::Services::file_dialog) shows the native dialog +//! without blocking the UI thread and sends the answer back later; [`WordApp::logic`] picks it up +//! on a later frame and finishes what the dialog was for (open the file, save and carry on with a +//! pending New/Open/Close, export, insert the picture). A host with only the blocking +//! `pick_open` / `pick_save` gets the same follow-up straight away. One dialog at a time: asking +//! for another while one is open does nothing. + +use std::sync::mpsc::{Receiver, TryRecvError}; + +use serde_json::{Value, json}; + +use crate::WordApp; + +/// A file dialog the app asks the host to show ([`Services::file_dialog`](crate::Services::file_dialog)). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FileDialogRequest { + /// Pick a file to open; `purpose` says what for (`document`, `picture`), as for `pick_open`. + Open { purpose: String }, + /// Pick a path to save to, given a suggested file name. + Save { name: String }, +} + +/// What to do with the path picked in a dialog. +pub(crate) enum AfterPick { + /// Open the picked document. + OpenDocument, + /// Insert the picked picture, or replace the pending Change Picture target. + Picture, + /// Save the document there, then carry on. + SaveAs(AfterSave), + /// Save a copy there in this format (`pdf`, `docx`, `png`…). + Export { ext: String }, +} + +/// What to do once Save As has written the document. +pub(crate) enum AfterSave { + Nothing, + /// The AutoSave switch asked for the save: turn AutoSave on. + AutoSave, + /// "Save changes?" answered Save: run the command it held back (New, Open, Close…), as long as + /// the document is safely written and is still the one the prompt asked about. + Continue { + then: String, + params: Value, + document: u64, + }, +} + +/// A dialog the host is showing; the answer arrives on `rx` (`None`: cancelled). +pub(crate) struct PendingDialog { + rx: Receiver>, + after: AfterPick, +} + +/// How asking for a file went. +pub(crate) enum Asked { + /// Another file dialog is still open; nothing happened. + Busy, + /// The host shows the dialog; the answer is handled on a later frame. + Pending, + /// The host answered straight away and the follow-up ran. + Done(Result), +} + +impl WordApp { + /// Whether a file dialog is open (its answer hasn't arrived yet). + pub fn file_dialog_open(&self) -> bool { + self.file_dialog.is_some() + } + + /// Ask the host for a file and do `after` with the answer: now for a blocking dialog, on a + /// later frame for an asynchronous one. Ignored while another dialog is open. + pub(crate) fn ask_file(&mut self, req: FileDialogRequest, after: AfterPick) -> Asked { + if self.file_dialog.is_some() { + log::debug!("file dialog already open; ignoring {req:?}"); + self.status(tl!("A file dialog is already open.")); + return Asked::Busy; + } + if let Some(show) = &self.services.file_dialog { + let rx = show(req); + self.file_dialog = Some(PendingDialog { rx, after }); + // A host that answered at once (or failed to show anything) is handled right away. + return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done); + } + let picked = match &req { + FileDialogRequest::Open { purpose } => self.services.pick_open.as_ref().and_then(|f| f(purpose)), + FileDialogRequest::Save { name } => self.services.pick_save.as_ref().and_then(|f| f(name)), + }; + Asked::Done(self.finish_pick(after, picked)) + } + + /// Pick up the answer of an open file dialog, if it has arrived (every frame, from `logic`), + /// and finish what it was for. `None` while there is nothing to finish. + pub(crate) fn poll_file_dialog(&mut self) -> Option> { + let picked = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) { + None | Some(Err(TryRecvError::Empty)) => return None, + Some(Ok(picked)) => picked, + // The host dropped the dialog without an answer: as good as cancelled. + Some(Err(TryRecvError::Disconnected)) => None, + }; + let d = self.file_dialog.take()?; + Some(self.finish_pick(d.after, picked)) + } + + /// Do what the dialog was for with the picked path (`None`: the user cancelled). + fn finish_pick(&mut self, after: AfterPick, picked: Option) -> Result { + match after { + AfterPick::OpenDocument => { + let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; + let r = self.run("file.open", json!({"path": path})); + self.ui.backstage = false; + r + } + AfterPick::Picture => { + let Some(path) = picked else { + self.change_picture_target = None; + return Ok(json!({"cancelled": true})); + }; + self.insert_or_change_picture(json!({"path": path})) + } + AfterPick::Export { ext } => { + let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; + let r = if ext == "png" { self.run("file.exportPng", json!({"path": path})) } else { self.run("file.saveAs", json!({"path": path})) }; + if r.is_ok() { + self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)])); + } + r + } + AfterPick::SaveAs(after) => { + let saved = picked.is_some_and(|path| { + self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true)) + }); + match after { + AfterSave::Nothing => Ok(json!({"saved": saved})), + AfterSave::AutoSave => { + if saved { + self.autosave = true; + } + Ok(json!({"saved": saved})) + } + AfterSave::Continue { then, params, document } => { + if !self.safely_saved(saved) || document != self.session.document_id() { + return Ok(json!({"done": false})); + } + self.execute(&then, params).map(|r| json!({"done": true, "result": r})) + } + } + } + } + } + + /// Ask where to save, then save there (File › Save As). + pub fn save_as_dialog(&mut self) { + let _ = self.save_as(AfterSave::Nothing); + } + + /// Ask where to save, save there, then do `after`. + pub(crate) fn save_as(&mut self, after: AfterSave) -> Asked { + let name = self.default_save_name(); + self.ask_file(FileDialogRequest::Save { name }, AfterPick::SaveAs(after)) + } + + /// File › Export: ask where to save a copy in this format, then write it. + pub(crate) fn export_dialog(&mut self, ext: &str) { + let name = format!("{}.{ext}", self.title_stem()); + let _ = self.ask_file(FileDialogRequest::Save { name }, AfterPick::Export { ext: ext.to_string() }); + } + + pub(crate) fn open_dialog(&mut self) { + if let Some(f) = &self.services.open_async { + f("document"); + return; + } + let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument); + } + + pub(crate) fn pick_picture(&mut self) { + if let Some(f) = &self.services.open_async { + f("picture"); + return; + } + let _ = self.ask_file(FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::mpsc::{Sender, channel}; + + use super::*; + use crate::Services; + use wordcraft_engine::Session; + + const UNSAVED: &str = "My unsaved novel chapter"; + + /// Dialogs the fake host was asked to show, each with the channel its answer goes through. + type Shown = Rc>)>>>; + + /// An app whose host shows dialogs asynchronously: nothing is answered until the test does. + fn async_app() -> (WordApp, Shown) { + let shown: Shown = Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |req| { + let (tx, rx) = channel(); + log.borrow_mut().push((req, tx)); + rx + })), + ..Default::default() + }; + (WordApp::new(Session::new(wordcraft_doc::Document::new()), services), shown) + } + + /// Answer the dialog the host is showing (the last one asked for). + fn answer(shown: &Shown, picked: Option<&str>) { + let (_, tx) = shown.borrow_mut().pop().unwrap(); + tx.send(picked.map(str::to_string)).unwrap(); + } + + fn typed(a: &mut WordApp) { + a.run("text.insert", json!({"text": UNSAVED})).unwrap(); + assert!(a.session.dirty); + } + + fn body_text(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) + } + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-ui-dialogs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn saved_text(path: &std::path::Path) -> String { + wordcraft_engine::io::open_path(path).unwrap().plain_text(wordcraft_doc::StoryRef::Body) + } + + /// Save As returns at once (the UI keeps running); the frame loop saves once the answer arrives. + #[test] + fn save_as_answer_arriving_later_is_saved() { + let dir = scratch("later"); + let path = dir.join("novel.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + a.save_as_dialog(); + assert!(a.file_dialog_open()); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Save { name: "Document1.docx".into() }); + let ctx = egui::Context::default(); + let frame = |a: &mut WordApp| ctx.run_ui(egui::RawInput::default(), |ui| a.logic(ui.ctx())).drop_without_applying_deltas(); + frame(&mut a); + assert!(a.session.dirty, "nothing saved before the user answers"); + answer(&shown, Some(&path.to_string_lossy())); + frame(&mut a); + assert!(!a.file_dialog_open()); + assert!(!a.session.dirty); + assert!(saved_text(&path).contains(UNSAVED)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// While a dialog is open, more Save As / Open / Export / Picture clicks don't open a second one. + #[test] + fn a_second_dialog_while_one_is_open_is_ignored() { + let (mut a, shown) = async_app(); + a.save_as_dialog(); + a.save_as_dialog(); + a.open_dialog(); + a.export_dialog("pdf"); + a.pick_picture(); + assert_eq!(shown.borrow().len(), 1, "only the first dialog was shown"); + answer(&shown, None); + a.poll_file_dialog(); + assert!(!a.file_dialog_open()); + a.open_dialog(); + assert_eq!(shown.borrow().len(), 1, "a new dialog once the first was answered"); + assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() }); + } + + /// "Save changes?" → Save on a new document: the held-back Close runs only once the later + /// Save As has actually saved; a cancelled Save As cancels the held-back New. + #[test] + fn save_changes_waits_for_the_save_as_answer() { + let dir = scratch("prompt"); + let path = dir.join("novel.docx"); + let (mut a, shown) = async_app(); + typed(&mut a); + a.run("file.new", json!({})).unwrap(); + a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + answer(&shown, None); + a.poll_file_dialog(); + assert!(body_text(&a).contains(UNSAVED), "cancelled Save As keeps the document"); + assert!(a.session.dirty); + + a.run("file.close", json!({})).unwrap(); + let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); + assert_eq!(r["pending"], "saveAs"); + a.poll_file_dialog(); + assert!(!a.quit_requested, "nothing closes before the save"); + answer(&shown, Some(&path.to_string_lossy())); + a.poll_file_dialog(); + assert!(a.quit_requested, "closed once saved"); + assert!(saved_text(&path).contains(UNSAVED)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1107bda..455b5cc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,6 +20,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod equation_tab; +pub mod file_dialogs; pub mod frame; pub mod i18n; pub mod icons; @@ -41,6 +42,7 @@ use serde_json::{Value, json}; use wordcraft_engine::Session; pub use control::ControlRequest; +pub use file_dialogs::FileDialogRequest; /// Platform services injected by the host (file dialogs, file I/O). #[derive(Default)] @@ -49,6 +51,11 @@ pub struct Services { pub pick_open: Option Option>>, /// Pick a path to save to, given a suggested name. pub pick_save: Option Option>>, + /// Desktop: show a native file dialog without blocking the UI thread (#94); used instead of + /// `pick_open` / `pick_save` when set. The host sends the picked path (`None` when cancelled) + /// through the returned channel once the user answers, and wakes the UI; a dropped sender + /// counts as cancelled. The app shows one dialog at a time ([`file_dialogs`]). + pub file_dialog: Option std::sync::mpsc::Receiver>>>, /// Web: open a file picker; the file arrives later through `inbox`. pub open_async: Option>, /// Web: files (name, bytes) delivered asynchronously (picker, drag and drop). @@ -175,6 +182,8 @@ pub struct WordApp { /// there. A file that was merely opened isn't rewritten until the user saves it: saving drops /// whatever WordCraft can't represent (content controls, charts, macros…). autosave_path: Option, + /// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]). + file_dialog: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -232,6 +241,7 @@ impl WordApp { read_aloud_error: None, autosave_path: None, change_picture_target: None, + file_dialog: None, } } @@ -358,6 +368,15 @@ impl WordApp { let go = match choice { SaveChoice::Cancel => false, SaveChoice::DontSave => true, + // A new document: ask where through Save As; the command runs once that has saved, + // which with the desktop's non-blocking dialog is on a later frame (#94). + SaveChoice::Save if self.session.path.is_none() && self.services.download.is_none() => { + return match self.save_as(file_dialogs::AfterSave::Continue { then, params, document }) { + file_dialogs::Asked::Done(r) => r, + file_dialogs::Asked::Pending => Ok(json!({"pending": "saveAs"})), + file_dialogs::Asked::Busy => Ok(json!({"done": false})), + }; + } SaveChoice::Save => self.save_for_prompt(), }; if !go { @@ -371,14 +390,15 @@ impl WordApp { /// image, plain text) writes a copy and leaves the document unsaved, so it doesn't count: /// the command is cancelled and the document stays. fn save_for_prompt(&mut self) -> bool { - let saved = if self.session.path.is_none() && self.services.download.is_none() { - self.save_as_dialog() - } else { - match self.execute("file.save", json!({})) { - Ok(v) => v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(), - Err(_) => false, - } + let saved = match self.execute("file.save", json!({})) { + Ok(v) => v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(), + Err(_) => false, }; + self.safely_saved(saved) + } + + /// Whether a save before New/Open/Close counts: it happened, and kept everything. + fn safely_saved(&mut self, saved: bool) -> bool { if saved && self.session.dirty { self.status(tl!("That format doesn't keep everything, so the document is still open. Save it as a Word document (.docx) to go on.")); return false; @@ -566,25 +586,6 @@ impl WordApp { self.status_msg = Some((s.into(), now_ms())); } - fn open_dialog(&mut self) { - if let Some(f) = &self.services.open_async { - f("document"); - return; - } - let picked = self.services.pick_open.as_ref().and_then(|f| f("document")); - if let Some(path) = picked { - let _ = self.run("file.open", json!({"path": path})); - self.ui.backstage = false; - } - } - - /// Ask where to save, then save there. True once the document is written. - pub fn save_as_dialog(&mut self) -> bool { - let name = self.default_save_name(); - let picked = self.services.pick_save.as_ref().and_then(|f| f(&name)); - picked.is_some_and(|path| self.run("file.save", json!({"path": path})).is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true))) - } - /// Media key of the selected picture, if any. pub(crate) fn selected_picture_media(&self) -> Option { match wordcraft_engine::cmd::objects::selected(&self.session) { @@ -614,19 +615,6 @@ impl WordApp { } } - fn pick_picture(&mut self) { - if let Some(f) = &self.services.open_async { - f("picture"); - return; - } - let picked = self.services.pick_open.as_ref().and_then(|f| f("picture")); - if let Some(path) = picked { - let _ = self.insert_or_change_picture(json!({"path": path})); - } else { - self.change_picture_target = None; - } - } - /// The name Save suggests: the open file's own Word format (so a .docm keeps its macros), /// otherwise .docx. fn default_save_name(&self) -> String { @@ -682,6 +670,7 @@ impl WordApp { zotero::poll(self, ctx); read_aloud::poll(self, ctx); self.clear_stale_change_picture(); + let _ = self.poll_file_dialog(); self.drain_inbox(); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty {