File dialogs (Open, Save As, Export) no longer freeze the window on Linux (#94) (#246)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* #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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-10 19:05:44 +02:00 committed by GitHub
parent 8b49ea7546
commit 7dcfa07c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 446 additions and 71 deletions

1
Cargo.lock generated
View File

@ -4870,6 +4870,7 @@ dependencies = [
"fmv-macos-events",
"getrandom 0.3.4",
"log",
"pollster 0.4.0",
"rfd",
"serde_json",
"winresource",

View File

@ -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"

View File

@ -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<Option<String>>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
pub struct Launcher {
queue: Receiver<Request>,
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::<Request>();
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", &["*"])
}
}

View File

@ -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<WindowGeometry>, Option<control_server::KeyFile>, #[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<WindowGeometry>,
Option<control_server::KeyFile>,
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),
)))

View File

@ -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);
}

View File

@ -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);

View File

@ -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<Option<String>>,
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<Value, String>),
}
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<Result<Value, String>> {
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<String>) -> Result<Value, String> {
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<RefCell<Vec<(FileDialogRequest, Sender<Option<String>>)>>>;
/// 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);
}
}

View File

@ -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<Box<dyn Fn(&str) -> Option<String>>>,
/// Pick a path to save to, given a suggested name.
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// 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<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<Option<String>>>>,
/// Web: open a file picker; the file arrives later through `inbox`.
pub open_async: Option<Box<dyn Fn(&str)>>,
/// 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<std::path::PathBuf>,
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
file_dialog: Option<file_dialogs::PendingDialog>,
}
/// 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<String> {
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 {