From 7dcfa07c15d675ae7516fe2df28678ca70cd37ff Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:05:44 +0200 Subject: [PATCH] File dialogs (Open, Save As, Export) no longer freeze the window on Linux (#94) (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tests: shapes and pictures can be clicked, resized, dragged and nudged Fixed by #46 but only text boxes were covered. These walk the editor's path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test, select, resize with arrange.bounds, drag to float with Square wrap, nudge, undo. Covers #142 and #82. Co-Authored-By: Claude Opus 5.5 * Font list offers every weight a family installs, named as Word names them (#219) Fonts were listed and matched only by their typographic family name (name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic (Light, Medium, SemiBold, Black, Condensed...) never appeared in the font list, and a document asking for "Roboto Light" fell back to a substitute. Each face now also carries its legacy family name, the one Word lists and stores in documents: name ID 1 for static fonts ("Source Sans 3 Semibold"), or the family plus the style words other than Regular, Bold and Italic for a variable font's named instances ("Inter Light"). The font list includes these names, has_family and face() resolve them (bold and italic still choose or synthesise within the faces the name covers), and asking for a legacy name loads the whole installed family. The system font scan now also reads fvar, so a variable font's instances are listed before it is loaded. Co-Authored-By: Claude Opus 5.5 * Absolutely positioned VML text boxes float instead of sitting inline (#149) A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its style was read as an inline object, so a tall text box in a header made the header as tall as the box and pushed the body down the page. The reader now maps the VML style to the same float model DrawingML anchors use: left/margin-left and top/margin-top offsets, mso-position-*-relative reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-* and w10:wrap (square, tight, through, topAndBottom; none or absent is in front of the text, or behind it at a negative z-index). Shapes without position:absolute stay inline. Saving writes the boxes as DrawingML anchors with the same placement. Co-Authored-By: Claude Opus 5.5 * Reject All removes paragraph breaks inserted with Track Changes on (#229) Enter under Track Changes already recorded the new paragraph mark as an insertion, but accepting or rejecting only cleared that flag, so Reject All left the paragraphs split. Rejecting an inserted mark (or accepting a deleted one) now joins the paragraph with the next one again; Accept All, Reject All, Accept/Reject at the caret or over a selection, Next/Previous Change and the Reviewing Pane all see paragraph marks. Backspace at a paragraph start and Delete at a paragraph end now record a tracked deletion of the mark (or remove a mark the same author inserted) instead of only moving the caret; tracked deletions across paragraphs mark the paragraph marks too. DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the mark's w:rPr), and a tracked mark is drawn in its author's colour when formatting marks are shown. Co-Authored-By: Claude Opus 5.5 * Table Layout: Text Direction turns cell text (#226) The Text Direction button only flipped a flag that layout and rendering ignored (and docx always wrote it as btLr), so pressing it did nothing visible. Cells now carry a text direction (horizontal, top-to-bottom tbRl, bottom-to-top btLr). The button cycles through them like Word's, for every selected cell. Layout runs turned text along the cell's height: an exact row height sets the line length, otherwise the row grows to fit the unwrapped text. Turned lines are drawn in a turned frame on screen and in PDF, and clicks, caret, and selection follow the turn. docx reads and writes w:textDirection with the matching value. Co-Authored-By: Claude Opus 5.5 * File dialogs no longer freeze the window on Linux (#94) Open, Save As, Export and Insert/Change Picture used rfd's blocking FileDialog on the UI thread with no parent window. On Linux the xdg-desktop-portal call blocked the event loop until the dialog closed, and the unparented dialog could open behind WordCraft, so the app looked frozen. The desktop app now shows rfd::AsyncFileDialog parented to the window (set_parent with eframe's Frame, no unsafe) and waits for the answer on a background thread, which hands it back through a channel and wakes the UI. ui-egui gains a backend-agnostic Services::file_dialog hook: the app remembers what the dialog was for and finishes it when the answer arrives (open the file, save, export, insert or change the picture, turn AutoSave on). "Save changes?" -> Save on a new document now runs the held-back New/Open/Close only once the later Save As has saved; a cancelled Save As cancels it. Only one file dialog is open at a time; further clicks are ignored until it is answered. Co-Authored-By: Claude Opus 5.5 * Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241) The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was unaffected because it uses the system font fallback. - Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd. - With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no Jpan or Hans font, so an outdated pin can't ship silently again. - When no embedded face covers the interface language, the UI font families get an installed CJK font (desktop only): a short per-platform list of files in the system font folders (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi), header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK collection), so egui never sees a file it can't parse. Co-Authored-By: Claude Opus 5.5 * Add a System interface theme that follows the OS appearance (#115) The interface theme is now a three-way choice: Use system setting, Light or Dark, in File > Options > General and in a new Interface Theme menu in View > Dark Mode. System follows the light/dark appearance egui reports (winit on macOS, Windows and Linux; prefers-color-scheme on the web) at launch and live, and falls back to light when the OS reports none. egui's own theme is now pinned to ours, so an OS change no longer swaps in egui's unstyled style. Saved preferences migrate: the old "dark" switch becomes Dark or Light and is never written again. Light stays the default for new installs. New ui.theme command (light/dark/system); ui.dark still sets Light/Dark. The dark page (View > Switch Modes) is unchanged. Co-Authored-By: Claude Opus 5.5 * #248: read the installed CJK UI font only when CJK text is shown A build without craft-fonts read a large system CJK font on the first frame even for an English interface. Now it is loaded only for a CJK interface language, or once File > Options shows the language list (names in their own scripts). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- Cargo.lock | 1 + apps/wordcraft/Cargo.toml | 3 + apps/wordcraft/src/file_dialogs.rs | 79 ++++++++ apps/wordcraft/src/main.rs | 43 ++-- crates/ui-egui/src/backstage.rs | 9 +- crates/ui-egui/src/chrome.rs | 5 +- crates/ui-egui/src/file_dialogs.rs | 310 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 67 +++---- 8 files changed, 446 insertions(+), 71 deletions(-) create mode 100644 apps/wordcraft/src/file_dialogs.rs create mode 100644 crates/ui-egui/src/file_dialogs.rs 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 {