Files
wordcraft/crates/ui-egui/src/lib.rs
T
Valentin KleinandClaude Opus 5.5 4e710a920b Footnote and Endnote options per section (#385) (#390)
- Model: NoteProps (pos, numFmt, numStart, numRestart) in the settings and
  each section; Document::note_options resolves section > document > defaults.
- DOCX: w:footnotePr / w:endnotePr read and written in settings and every
  w:sectPr in schema order; hostile numStart clamped (ECMA-376 §17.11).
- Layout: footnotes below the text, endnotes at the end of each section,
  numbering restarted per section or per page (from where notes land, at
  most three passes), per-section number formats.
- Commands: references.noteOptions takes kind/pos/numFmt/numStart/
  numRestart/scope; references.convertNotes (footnote/endnote/swap, one
  undo step); references.footnote/endnote take a custom mark.
- UI: Footnote and Endnote dialog from the Footnotes group launcher;
  strings in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:10:17 -04:00

2737 lines
131 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! WordCraft's egui front end: a Word-style window (title bar with Quick Access Toolbar, ribbon,
//! rulers, page canvas, panes, status bar, Backstage, dialogs) over `wordcraft-engine`.
//!
//! The UI is thin: it reads `Session` state and acts through [`WordApp::run`] (command ids), so
//! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// An English UI string in the current interface language ([`i18n::t`]).
#[macro_export]
macro_rules! tl {
($s:expr) => {
$crate::i18n::t($s)
};
}
pub mod backstage;
pub mod canvas;
pub mod chrome;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod dialogs_insert;
pub mod dialogs_keyboard;
pub mod dialogs_lists;
pub mod dialogs_notes;
pub mod dialogs_para;
pub mod dialogs_ribbon;
pub mod equation_tab;
pub mod file_dialogs;
pub mod frame;
pub mod i18n;
pub mod icons;
pub mod ink;
pub mod keys;
pub mod keytips;
pub mod mini_toolbar;
pub mod objects;
pub mod panes;
pub mod paste_picture;
pub mod previews;
pub mod read_aloud;
pub mod ribbon;
pub mod scroll;
pub mod table_pen;
pub mod theme;
pub mod widgets;
pub mod window_geometry;
pub mod zotero;
use serde::{Deserialize, Serialize};
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)]
pub struct Services {
/// Pick a file to open; the argument says what for (`document`, `picture`).
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).
pub inbox: Option<Inbox>,
/// Web: hand bytes to the browser as a download. An error means no download started (the
/// browser can't tell the page whether the user then kept the file).
pub download: Option<Box<dyn Fn(&str, &[u8]) -> Result<(), String>>>,
/// Web: told whether the document has unsaved changes after each pass, for the browser's
/// leave-page guard (`beforeunload` runs between frames and can't ask the app).
pub on_dirty: Option<Box<dyn Fn(bool)>>,
/// Hand PDF bytes to the system's print flow. Web: the browser's print dialog on that PDF (no
/// download, no intermediate file). Desktop: the PDF opens in the system's viewer (a temporary
/// file), where the user prints (#15). An error means nothing was opened.
pub print: Option<Box<dyn Fn(&[u8]) -> Result<(), String>>>,
/// Desktop: the picture on the system clipboard, if any — egui's paste only carries text
/// (#45). Read when Paste finds no text; see [`paste_picture`].
pub clipboard_picture: Option<Box<dyn Fn() -> Option<paste_picture::ClipboardPicture>>>,
}
/// Files delivered asynchronously.
pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<(String, Vec<u8>)>>>;
/// UI state that persists between runs (and that agents can read and set).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
pub tab: String,
pub backstage: bool,
pub backstage_page: String,
pub ribbon_collapsed: bool,
pub recent: Vec<String>,
/// Symbols inserted from the Symbol dialog, most recent first (#321).
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
/// Interface theme: light, dark, or follow the OS appearance (#115).
pub theme: theme::Appearance,
/// Interface size in percent (#475): scales the whole interface on top of the system's
/// display scale. Junk or out-of-range saved values read as 100 % or are clamped to 80–200 %.
pub interface_size: theme::InterfaceSize,
/// The dark-mode switch `ui.json` held before `theme` (#115): read once to migrate, never written.
#[serde(rename = "dark", skip_serializing)]
pub(crate) legacy_dark: Option<bool>,
pub nav_tab: String,
pub show_discord: bool,
/// User name (File › Options) for comments and tracked changes; empty keeps the default.
pub author: String,
/// Desktop: the main window's size and position, restored at the next launch.
pub window: Option<window_geometry::WindowGeometry>,
/// Interface language: `auto` (follow the system) or a code from [`i18n::LANGUAGES`].
pub language: String,
/// Editing preferences; the session owns them while running (see [`WordApp::prefs`]).
pub editing: wordcraft_engine::Prefs,
/// Read Aloud speed (1 = normal) and whether it skips citations and bibliographies.
pub read_aloud_rate: f32,
pub read_aloud_skip_citations: bool,
/// Keytips (Alt) state; not persisted, it resets each session.
#[serde(skip)]
pub keytips: crate::keytips::Phase,
/// Since the last Alt press, another key or a mouse button was pressed: the Alt release
/// that follows ends a chord (Alt+click, Alt+drag column selection), not a keytip tap.
#[serde(skip)]
pub alt_chord_used: bool,
/// View › Switch Modes: show pages dark (white text on black), kept between runs. Only the
/// pages: the interface follows [`UiState::theme`] (#312).
pub dark_page: bool,
/// Custom keyboard shortcuts (Customize Keyboard, #368); the session owns them while running.
pub keyboard: wordcraft_engine::KeyMap,
/// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon,
/// #379); the session owns them while running.
pub ribbon: wordcraft_engine::RibbonLayout,
}
impl Default for UiState {
fn default() -> Self {
UiState {
tab: "Home".into(),
backstage: false,
backstage_page: "home".into(),
ribbon_collapsed: false,
recent: Vec::new(),
recent_symbols: Vec::new(),
theme: theme::Appearance::default(),
interface_size: theme::InterfaceSize::default(),
legacy_dark: None,
nav_tab: "headings".into(),
show_discord: true,
author: String::new(),
window: None,
language: i18n::AUTO.into(),
editing: wordcraft_engine::Prefs::default(),
read_aloud_rate: 1.0,
read_aloud_skip_citations: true,
keytips: crate::keytips::Phase::Off,
alt_chord_used: false,
dark_page: false,
keyboard: wordcraft_engine::KeyMap::default(),
ribbon: wordcraft_engine::RibbonLayout::default(),
}
}
}
/// The application.
pub struct WordApp {
pub session: Session,
pub ui: UiState,
pub services: Services,
pub canvas: canvas::CanvasState,
pub dialog: Option<dialogs::Dialog>,
pub status_msg: Option<(String, f64)>,
pub previews: previews::Previews,
/// Media key of the picture a pending Change Picture replaces (#147).
pub change_picture_target: Option<String>,
/// Web: a recipient list was asked for (Select Recipients › Use an Existing List…), so the
/// next text file from the picker loads as recipients instead of opening (#240).
pub recipient_list_pending: bool,
/// macOS: the window has no title bar; leave room for the traffic lights.
pub integrated_titlebar: bool,
control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
shot_token: u64,
queued_shots: Vec<(u64, f64, u32)>,
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
pub(crate) synthetic: Vec<egui::Event>,
/// Badge rects recorded during layout for the current keytip phase (cleared by `logic`, painted by `show`).
pub(crate) keytip_rects: Vec<(egui::Rect, String)>,
styled: bool,
/// The CJK face order the installed UI fonts use (Chinese first, or Japanese first).
fonts_hans: bool,
/// Whether the installed UI fonts include an installed CJK fallback font (#241).
fonts_system_cjk: bool,
/// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in
/// File ▸ Options: load the installed CJK fallback font if no embedded face covers it.
pub(crate) want_system_cjk: bool,
fonts_frames: u32,
/// The interface theme setting last installed in egui ([`theme::apply`]).
applied_theme: Option<theme::Appearance>,
pub frame_ms: f64,
/// The window title last sent; a viewport command schedules a repaint, so only send changes.
sent_title: String,
pub quit_requested: bool,
pub autosave: bool,
pub word_count: (u64, usize),
last_autosave: f64,
/// The tab shown before the Equation tab came up (restored when editing ends).
pub(crate) equation_prev_tab: Option<String>,
/// Zotero commands in flight (`ui.zotero.*`).
pub zotero: zotero::ZoteroLink,
/// The egui context, once the first frame has run (background work wakes the UI with it).
pub(crate) ctx: Option<egui::Context>,
/// Read Aloud: the start of the sentence the caret was last moved to, and the last error shown.
pub(crate) read_aloud_at: Option<wordcraft_engine::doc::Pos>,
pub(crate) read_aloud_error: Option<String>,
/// The file the document was last explicitly saved to in this session; AutoSave writes only
/// 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>,
/// File › Info: the property field being typed in and the document revision after its last
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
/// between, such as an Undo, starts a new one).
pub(crate) info_editing: Option<(&'static str, u64)>,
}
/// The answer to "Do you want to save changes?" (`ui.saveChanges`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SaveChoice {
Save,
DontSave,
Cancel,
}
impl SaveChoice {
pub fn parse(s: &str) -> Option<SaveChoice> {
Some(match s {
"save" => SaveChoice::Save,
"dontSave" => SaveChoice::DontSave,
"cancel" => SaveChoice::Cancel,
_ => return None,
})
}
}
impl WordApp {
pub fn new(session: Session, services: Services) -> Self {
WordApp {
session,
ui: UiState::default(),
services,
canvas: canvas::CanvasState::default(),
dialog: None,
status_msg: None,
previews: previews::Previews::default(),
integrated_titlebar: false,
control_rx: None,
shot_token: 0,
queued_shots: Vec::new(),
pending_shots: Vec::new(),
synthetic: Vec::new(),
keytip_rects: Vec::new(),
styled: false,
fonts_hans: false,
fonts_system_cjk: false,
want_system_cjk: false,
fonts_frames: 0,
applied_theme: None,
frame_ms: 0.0,
sent_title: String::new(),
quit_requested: false,
autosave: true,
word_count: (0, 0),
last_autosave: 0.0,
equation_prev_tab: None,
zotero: zotero::ZoteroLink::default(),
ctx: None,
read_aloud_at: None,
read_aloud_error: None,
autosave_path: None,
change_picture_target: None,
recipient_list_pending: false,
file_dialog: None,
info_editing: None,
}
}
pub fn with_control(mut self, rx: std::sync::mpsc::Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// Preferences to save between runs: the UI state plus the current user name and editing
/// preferences.
pub fn prefs(&self) -> UiState {
let mut ui = self.ui.clone();
ui.author = self.session.author.clone();
ui.editing = self.session.prefs.clone();
ui.read_aloud_rate = self.session.read_aloud.rate();
ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations;
ui.dark_page = self.session.view.dark_mode;
ui.keyboard = self.session.keymap.clone();
ui.ribbon = self.session.ribbon.clone();
ui
}
/// Restore preferences saved by [`WordApp::prefs`].
pub fn apply_prefs(&mut self, ui: UiState) {
self.ui = ui;
self.ui.backstage = false;
// A `ui.json` from before the theme setting: its dark-mode switch picks Dark or Light.
if let Some(dark) = self.ui.legacy_dark.take() {
self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light };
}
// The session owns the name from here on; `prefs` copies it back when saving.
let author = std::mem::take(&mut self.ui.author);
if !author.trim().is_empty() {
self.session.author = author;
}
self.session.prefs = std::mem::take(&mut self.ui.editing);
self.session.read_aloud.set_rate(self.ui.read_aloud_rate);
self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations;
self.session.view.dark_mode = self.ui.dark_page;
// Keys for commands this version doesn't have are dropped.
self.session.keymap = std::mem::take(&mut self.ui.keyboard);
let reg = self.session.registry.clone();
self.session.keymap.retain_known(&reg);
// So are ribbon and toolbar commands.
self.session.ribbon = std::mem::take(&mut self.ui.ribbon);
self.session.ribbon.retain_known(&reg);
}
/// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open,
/// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask
/// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
// A button whose command needs input the user hasn't given yet opens its dialog (#240).
if let Some(name) = input_dialog(id, &params)
&& widgets::enabled(self, id)
{
self.dialog = dialogs::Dialog::open(name, self);
return Ok(json!({"pending": name}));
}
if self.session.dirty && discards_document(id, &params) {
let name =
self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem());
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
return Ok(json!({"pending": "saveChanges"}));
}
// Paste without system clipboard text: a picture there is newer than our own copy (copying
// here replaces it with text), so it wins (#45).
if id == "edit.paste"
&& params.get("text").is_none()
&& let Some(r) = paste_picture::paste(self)
{
return r;
}
let r = self.execute_user(id, params);
// Match Fields and Check for Errors show what they found.
if let Ok(v) = &r
&& let Some(d) = dialogs::Dialog::report(id, v)
{
self.dialog = Some(d);
}
r
}
/// Run a command for a script or an agent (control channel, MCP bridge): never asks first.
/// UI-level commands (`ui.*`) are handled here, the rest by the engine.
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, String> {
// A pending Change Picture only survives until the next action (#147).
if !matches!(id, "ui.changePicture" | "picture.change" | "insert.picture") {
self.change_picture_target = None;
}
if !matches!(id, "ui.openRecipientList" | "mailings.recipients") {
self.recipient_list_pending = false;
}
if let Some(r) = self.ui_command(id, &params) {
return r;
}
if id == "file.autosave" {
return self.set_autosave(&params);
}
if let Some(r) = table_pen::toggle(self, id, &params) {
return r;
}
let ctx = self.ctx.clone();
if let Some(r) = zotero::command(self, id, &params, ctx.as_ref()) {
if let Err(e) = &r {
self.status(e.clone());
}
return r;
}
// Web: saving and exporting become downloads.
if self.services.download.is_some() && matches!(id, "file.save" | "file.saveAs" | "file.exportPdf" | "file.exportPng") {
let name = params.get("path").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| self.default_save_name());
let name = if id == "file.exportPdf" && !name.ends_with(".pdf") { format!("{name}.pdf") } else { name };
// A save with a password encrypts this and later saves (`file.save`'s `password`).
if matches!(id, "file.save" | "file.saveAs")
&& let Some(pw) = params.get("password").filter(|v| !v.is_null())
{
if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) {
return Err("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password, and it can't be empty".into());
}
self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?;
}
// A save is a save, download or not: advance the revision and modified stamp (#262).
if matches!(id, "file.save" | "file.saveAs") {
self.session.stamp_save();
}
// Word documents are encrypted with the document's password, if it has one.
let password =
if matches!(id, "file.save" | "file.saveAs") { self.session.password.as_ref().map(wordcraft_engine::Password::as_str) } else { None };
let bytes = wordcraft_engine::io::save_bytes_with(&name, &self.session.doc, password)?;
if let Some(d) = &self.services.download
&& let Err(e) = d(&name, &bytes)
{
let msg = i18n::fmt(tl!("Couldn't download {name}: {error}"), &[("name", &name), ("error", &e)]);
log::error!("download of {name} failed: {e}");
self.status(msg.clone());
return Err(msg);
}
// An export is a copy, and so is a save in a format that doesn't keep everything (a
// page image, plain text): the document itself still has unsaved changes.
if matches!(id, "file.save" | "file.saveAs") && keeps_everything(&name) {
self.session.dirty = false;
}
return Ok(json!({"downloaded": name, "bytes": bytes.len()}));
}
let document = self.session.document_id();
let r = self.session.run(id, &params).map_err(|e| e.to_string());
// When the document has been replaced (not when Open only showed its picker), a pending
// "Save changes?" and AutoSave's go-ahead were both about the old one.
if self.session.document_id() != document {
self.autosave_path = None;
if matches!(self.dialog, Some(dialogs::Dialog::SaveChanges { .. })) {
self.dialog = None;
}
}
// An explicit save to the document's own file lets AutoSave keep writing there.
if let Ok(v) = &r
&& matches!(id, "file.save" | "file.saveAs")
&& v.get("saved").and_then(Value::as_bool) == Some(true)
&& v.get("path").and_then(Value::as_str).map(std::path::Path::new) == self.session.path.as_deref()
{
self.autosave_path = self.session.path.clone();
}
self.after_command(id);
if let Err(e) = &r {
self.status(e.clone());
}
r
}
/// Answer the Save Changes prompt: Save (through Save As for a new document) and carry on,
/// carry on without saving, or cancel. A failed or cancelled save cancels too.
fn answer_save_changes(&mut self, choice: SaveChoice) -> Result<Value, String> {
let Some(dialogs::Dialog::SaveChanges { then, params, document, .. }) = self.dialog.take() else {
return Err("no Save Changes prompt is open".into());
};
if document != self.session.document_id() {
return Err("the document the prompt asked about has been replaced".into());
}
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 {
return Ok(json!({"done": false}));
}
self.execute_user(&then, params).map(|r| json!({"done": true, "result": r}))
}
/// [`WordApp::execute`] for something the user did: a document that turns out to be
/// password-protected asks for the password (and opens with it) instead of failing (#55).
pub(crate) fn execute_user(&mut self, id: &str, params: Value) -> Result<Value, String> {
let retry = (id == "file.open").then(|| params.clone());
let r = self.execute(id, params);
if let (Err(e), Some(params)) = (&r, retry)
&& wordcraft_engine::io::needs_password(e)
{
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
return Ok(json!({"pending": "password"}));
}
r
}
/// Save before New/Open/Close (and the mailings that replace the document): true once the
/// document is safely written. A save in a format that doesn't keep everything (a page
/// 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 = 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;
}
saved
}
/// The window's close button (or the system) asked to quit. Returns true to let the window
/// close; with unsaved changes it asks first and returns false.
pub fn close_requested(&mut self) -> bool {
if self.quit_requested || !self.session.dirty {
return true;
}
let _ = self.run("file.close", json!({}));
false
}
fn after_command(&mut self, id: &str) {
self.canvas.caret_visible_since = now_ms();
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") {
self.canvas.scroll_to_caret = true;
}
for req in std::mem::take(&mut self.session.ui_requests) {
self.handle_request(&req);
}
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string())
&& self.ui.recent.first() != Some(&p)
{
self.ui.recent.retain(|r| *r != p);
self.ui.recent.insert(0, p);
self.ui.recent.truncate(12);
}
if !self.session.status.is_empty() {
let s = std::mem::take(&mut self.session.status);
self.status(s);
}
}
/// Requests commands make of the UI (open a dialog…).
fn handle_request(&mut self, req: &Value) {
if let Some(what) = req.get("open").and_then(Value::as_str) {
match what {
"openFile" => self.open_dialog(),
"saveAs" => {
self.save_as_dialog();
}
"insertPicture" => self.pick_picture(),
"print" => {
self.ui.backstage = true;
self.ui.backstage_page = "print".into();
}
"options" => {
self.ui.backstage = true;
self.ui.backstage_page = "options".into();
}
"pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)),
other => self.dialog = dialogs::Dialog::open(other, self),
}
}
if req.get("close").is_some() {
self.quit_requested = true;
}
}
/// Whether the interface theme setting currently resolves to dark (`System` asks the OS).
pub fn ui_is_dark(&self) -> bool {
self.ui.theme.is_dark(self.ctx.as_ref().and_then(egui::Context::system_theme))
}
/// Commands that live in the UI layer.
fn ui_command(&mut self, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str);
Some(Ok(match id {
"ui.tab" => {
let tab = s("tab").unwrap_or("Home").to_string();
if tab == "File" {
self.ui.backstage = true;
} else {
self.ui.tab = tab;
self.ui.backstage = false;
}
json!({"tab": self.ui.tab})
}
"ui.backstage" => {
self.ui.backstage = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.backstage);
if let Some(pg) = s("page") {
self.ui.backstage_page = pg.into();
}
json!({"backstage": self.ui.backstage})
}
"ui.dialog" => {
self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self);
json!({"dialog": self.dialog.as_ref().map(|d| d.name())})
}
"ui.closeDialog" => {
self.dialog = None;
json!({})
}
"ui.changePicture" => {
let Some(media) = self.selected_picture_media() else {
return Some(Err("select a picture first".into()));
};
self.change_picture_target = Some(media);
self.pick_picture();
json!({"pending": self.change_picture_target.is_some()})
}
"ui.collapseRibbon" => {
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
json!({"collapsed": self.ui.ribbon_collapsed})
}
"ui.dark" => {
// A manual Light/Dark switch (toggles what is shown when no value is given).
let dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui_is_dark());
self.ui.theme = if dark { theme::Appearance::Dark } else { theme::Appearance::Light };
json!({"dark": dark, "theme": self.ui.theme.code()})
}
"ui.theme" => {
// `light`, `dark` or `system` (follow the OS appearance); no value reads it.
if let Some(v) = s("value") {
match theme::Appearance::from_code(v) {
Some(a) => self.ui.theme = a,
None => return Some(Err(format!("unknown theme {v:?}; use light, dark or system"))),
}
}
json!({"theme": self.ui.theme.code(), "dark": self.ui_is_dark()})
}
"ui.interfaceSize" => {
// Interface size in percent, 80–200 (clamped); no value reads it. Document zoom is
// `view.zoom` and stays as it is.
if let Some(v) = p.get("percent").or_else(|| p.get("value")) {
match v.as_f64().and_then(theme::InterfaceSize::from_percent) {
Some(size) => self.ui.interface_size = size,
None => {
return Some(Err(format!(
"`percent` must be a number from {} to {}",
theme::InterfaceSize::MIN,
theme::InterfaceSize::MAX
)));
}
}
}
json!({"percent": self.ui.interface_size.percent()})
}
"ui.language" => {
// `auto` (follow the system) or a language code; anything else is an error.
if let Some(v) = s("value") {
match i18n::normalize_pref(v) {
Some(code) => self.ui.language = code.to_string(),
None => {
let codes: Vec<&str> = i18n::Lang::all().map(i18n::Lang::code).collect();
return Some(Err(format!("unknown language `{v}`; use auto or one of {}", codes.join(", "))));
}
}
}
let lang = i18n::Lang::from_pref(&self.ui.language);
json!({"language": self.ui.language, "effective": lang.code(), "available": i18n::Lang::all().map(|l| json!({"code": l.code(), "name": l.name()})).collect::<Vec<_>>()})
}
"ui.saveChanges" => {
let Some(choice) = s("answer").and_then(SaveChoice::parse) else {
return Some(Err("`answer` must be save, dontSave or cancel".into()));
};
return Some(self.answer_save_changes(choice));
}
"ui.openFileDialog" => {
self.open_dialog();
json!({})
}
// Mailings › Select Recipients › Use an Existing List…: pick a CSV/TSV/text file and
// load it as the mail-merge recipients. Opens system UI, like `ui.openFileDialog`.
"ui.openRecipientList" => {
self.pick_recipient_list();
json!({"pending": self.recipient_list_pending})
}
// Send the document to the system print flow (web, desktop). `file.print` opens the Print
// page; this is the button on it, and the one programmatic call that opens system UI
// here, like `ui.openFileDialog` — only when the host can print.
"ui.print" => return Some(self.print_to_system()),
"ui.discord" => {
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
json!({})
}
// Shape Format › Text › Create Link: the next click on an empty text box links the
// selected box to it (`shape.link` with that box as the target); Esc cancels.
"ui.linkTextBox" => {
let from = wordcraft_engine::cmd::objects::text_box_target(&self.session).filter(|_| widgets::enabled(self, "shape.link"));
let Some((pos, _)) = from else { return Some(Err("select a text box that isn't linked yet first".into())) };
self.canvas.link_from = Some(pos);
self.status(tl!("Click the empty text box the text should continue in (Esc cancels)"));
json!({"pending": "link"})
}
_ => return None,
}))
}
/// `ui.print`: export the document to PDF in memory and hand it to the host's print hook.
/// An error when the host can't print (File › Print then only saves a PDF).
fn print_to_system(&mut self) -> Result<Value, String> {
if self.services.print.is_none() {
return Err("printing to the system print dialog isn't available here; export a PDF instead".into());
}
let result = wordcraft_engine::io::save_bytes("document.pdf", &self.session.doc)
.and_then(|bytes| self.services.print.as_ref().map_or(Ok(()), |print| print(&bytes)).map(|()| bytes.len()));
match result {
Ok(len) => {
self.status(tl!("Opening print dialog…"));
Ok(json!({"printing": true, "bytes": len}))
}
Err(e) => {
log::error!("print failed: {e}");
let msg = i18n::fmt(tl!("Print failed: {error}"), &[("error", &e)]);
self.status(msg.clone());
Err(msg)
}
}
}
pub fn status(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms()));
}
/// 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) {
Some((_, wordcraft_doc::para::InlineObject::Image { media, .. })) => Some(media),
_ => None,
}
}
/// A picked image replaces the pending Change Picture target, else it is inserted.
fn insert_or_change_picture(&mut self, params: Value) -> Result<Value, String> {
let target = std::mem::take(&mut self.change_picture_target);
match (target, self.selected_picture_media()) {
(Some(t), Some(m)) if t == m => self.run("picture.change", params),
_ => self.run("insert.picture", params),
}
}
/// Drop a pending Change Picture once the selection moved to another picture.
fn clear_stale_change_picture(&mut self) {
let stale = match (&self.change_picture_target, self.selected_picture_media()) {
(Some(t), Some(m)) => t != &m,
(Some(_), None) => true,
_ => false,
};
if stale {
self.change_picture_target = None;
}
}
/// Pick a recipient list (desktop: through the file dialog hook; web: it arrives through the
/// inbox).
fn pick_recipient_list(&mut self) {
if let Some(f) = &self.services.open_async {
f("recipients");
self.recipient_list_pending = true;
return;
}
let _ = self.ask_file(file_dialogs::FileDialogRequest::Open { purpose: "recipients".into() }, file_dialogs::AfterPick::Recipients);
}
/// Load mail-merge recipients and say how many there are. A workbook with several sheets
/// shows Select Table to choose one first (#334).
pub(crate) fn load_recipients(&mut self, params: Value) -> Result<Value, String> {
let r = self.run("mailings.recipients", params.clone());
if let Ok(v) = &r
&& v.get("chooseSheet").and_then(Value::as_bool) == Some(true)
{
let sheets = v
.get("sheets")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
self.dialog = Some(dialogs::Dialog::SelectTable { sheets, selected: 0, headers: true, message: String::new(), params });
} else if let Ok(v) = &r {
let records = v.get("records").and_then(Value::as_u64).unwrap_or(0).to_string();
self.status(i18n::fmt(tl!("Recipients: {count}"), &[("count", &records)]));
}
r
}
/// 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 {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
_ => format!("{}.docx", self.title_stem()),
}
}
/// Document title for the title bar (and the suggested file name). An untitled document is
/// named in the interface language, as `Document1` is in English.
pub fn title_stem(&self) -> String {
match &self.session.path {
Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()),
None => {
if self.session.doc.core.title.is_empty() {
tl!("Document1").into()
} else {
self.session.doc.core.title.clone()
}
}
}
}
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
pub fn logic(&mut self, ctx: &egui::Context) {
let lang = i18n::Lang::from_pref(&self.ui.language);
i18n::set_current(lang);
// Chinese text wants the Chinese face before the Japanese one (one glyph style per line).
// An installed CJK font is read only once CJK text is shown (#241): never for an English
// interface that doesn't open the language list.
let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk();
if !self.styled || lang.prefers_hans() != self.fonts_hans || system_cjk != self.fonts_system_cjk {
theme::install_fonts_with(ctx, lang.prefers_hans(), system_cjk);
self.fonts_hans = lang.prefers_hans();
self.fonts_system_cjk = system_cjk;
// Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing);
// egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
self.styled = true;
}
// Both palettes are installed and egui picks one: with `System` it follows an OS appearance
// change live (it reports it and repaints, #311). The interface follows the Interface theme
// setting alone; Dark page only changes how pages are drawn (#312).
if self.applied_theme != Some(self.ui.theme) {
theme::apply(ctx, self.ui.theme);
self.applied_theme = Some(self.ui.theme);
}
// Interface size (#475): egui's zoom factor multiplies the display scale the system reports
// (native pixels per point), so it adds to it rather than replacing it. Keyboard zoom is
// off (above), so nothing else changes the factor; `set_zoom_factor` repaints only on change.
let zoom = self.ui.interface_size.zoom_factor();
if ctx.zoom_factor() != zoom {
ctx.set_zoom_factor(zoom);
}
if self.ctx.is_none() {
self.ctx = Some(ctx.clone());
}
self.drain_control(ctx);
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 {
ctx.request_repaint_after(std::time::Duration::from_millis(1000));
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
{
let path = f.path().to_string_lossy().to_string();
let lp = path.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e));
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lp.ends_with(e));
let _ = if img {
self.run("insert.picture", json!({"path": path}))
} else if sheet {
// A spreadsheet isn't a document: it's a recipient list (#334).
self.load_recipients(json!({"path": path}))
} else {
self.run("file.open", json!({"path": path}))
};
}
}
self.report_dirty();
}
/// Tell the host whether the document has unsaved changes ([`Services::on_dirty`]).
fn report_dirty(&self) {
if let Some(f) = &self.services.on_dirty {
f(self.session.dirty);
}
}
/// Whether the document's file is one the user saved to in this session.
pub fn saved_here(&self) -> bool {
self.session.path.is_some() && self.session.path == self.autosave_path
}
/// Why AutoSave can't cover the document, or `None` when it can: it writes only a file the
/// user saved to in this session, in a format that keeps everything, and never in the browser
/// (a page can't write to the user's files; saving there is a download, #176).
pub fn autosave_block(&self) -> Option<AutoSaveBlock> {
if self.services.download.is_some() {
return Some(AutoSaveBlock::Browser);
}
let Some(path) = &self.session.path else { return Some(AutoSaveBlock::Unsaved) };
if !keeps_everything(&path.to_string_lossy()) {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
return Some(AutoSaveBlock::Format { name });
}
if !self.saved_here() {
return Some(AutoSaveBlock::NotSavedHere);
}
None
}
/// Whether AutoSave covers the document: it is on and nothing blocks it ([`Self::autosave_block`]).
pub fn autosaves(&self) -> bool {
self.autosave && self.autosave_block().is_none()
}
/// `file.autosave` (the AutoSave switch, and scripts): `value` turns AutoSave on or off;
/// without it, it toggles what the switch shows. Turning it on for a document AutoSave can't
/// cover is an error that says why, instead of a switch that is on but saves nothing (#196).
fn set_autosave(&mut self, params: &Value) -> Result<Value, String> {
let on = params.get("value").and_then(Value::as_bool).unwrap_or(!self.autosaves());
if on && let Some(block) = self.autosave_block() {
return Err(block.reason());
}
self.autosave = on;
self.session.autosave = on;
Ok(json!({"value": on}))
}
/// AutoSave: write a saved document a couple of seconds after the last change. A failure is
/// shown in the status bar and turns AutoSave off for the file until the user saves it again.
fn autosave_tick(&mut self, now: f64) {
if self.autosaves() && self.session.dirty && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 {
self.last_autosave = now;
if let Err(e) = self.session.run("file.save", &json!({})) {
log::warn!("AutoSave failed: {e}");
self.autosave_path = None;
self.status(i18n::fmt(tl!("AutoSave failed: {error}. Save the document to turn AutoSave back on."), &[("error", &e.to_string())]));
}
}
}
/// Inject synthetic input events from the control channel (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
}
/// Lay out the window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = now_ms();
let ctx = ui.ctx().clone();
i18n::set_current(i18n::Lang::from_pref(&self.ui.language));
// Fonts installed by `logic` take effect on the next frame.
if self.fonts_frames < 2 {
self.fonts_frames += 1;
ctx.request_repaint();
return;
}
let t = theme::Tokens::get(&ctx);
// Keytips are read before layout so the frame paints the new state; badges paint after
// the ribbon so they sit on top of it.
crate::keytips::logic(self, &ctx);
if self.ui.backstage {
backstage::show(self, ui);
} else {
chrome::title_bar(self, ui);
ribbon::show(self, ui);
chrome::status_bar(self, ui);
panes::show(self, ui);
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
canvas::show(self, ui);
});
}
dialogs::show(self, &ctx);
zotero::show_alert(self, &ctx);
read_aloud::show(self, &ctx);
crate::keytips::show(self, &ctx, ui);
keys::global_shortcuts(self, &ctx);
if let Some(url) = self.canvas.open_url.take() {
ctx.open_url(egui::OpenUrl::new_tab(url));
}
let title = format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" });
if title != self.sent_title {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.sent_title = title;
}
self.frame_ms = now_ms() - t0;
// Typing and formatting land here, after `logic` has reported.
self.report_dirty();
}
/// Files that arrived asynchronously (web picker, drops): documents open, pictures insert,
/// recipient lists (CSV/TSV, spreadsheets, or a text file asked for as one) load as recipients.
fn drain_inbox(&mut self) {
let Some(inbox) = self.services.inbox.clone() else { return };
let files = std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner()));
for (name, bytes) in files {
let lower = name.to_ascii_lowercase();
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lower.ends_with(e));
let list =
lower.ends_with(".csv") || lower.ends_with(".tsv") || (lower.ends_with(".txt") && std::mem::take(&mut self.recipient_list_pending));
let sheet = [".xlsx", ".xlsm", ".ods"].iter().any(|e| lower.ends_with(e));
let r = if sheet {
self.recipient_list_pending = false;
self.load_recipients(json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
} else if list {
self.load_recipients(json!({"csv": wordcraft_engine::io::decode_text(&bytes)}))
} else if img {
self.insert_or_change_picture(json!({"data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
} else {
self.run("file.open", json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))
};
if r.is_ok() {
self.ui.backstage = false;
}
}
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.shot_token += 1;
let token = self.shot_token;
self.queued_shots.push((token, now_ms() + 120.0, 0));
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
}
}
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
self.queued_shots.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_shots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = self.pending_shots.remove(i);
let _ = reply.send(control::save_screenshot(&image, path.as_deref()));
}
}
let now = now_ms();
self.pending_shots.retain(|(_, _, reply, deadline)| {
if now < *deadline {
return true;
}
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"}));
false
});
}
}
/// Why AutoSave can't cover the document ([`WordApp::autosave_block`]); the switch is greyed out
/// and its tooltip gives [`AutoSaveBlock::reason`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AutoSaveBlock {
/// The web app: saving is a download, there is no file to keep writing to.
Browser,
/// A new document that has never been saved.
Unsaved,
/// A file in a format that can't keep everything (Markdown, plain text, HTML…), by file name.
Format { name: String },
/// A file that was opened but not yet saved in this session.
NotSavedHere,
}
impl AutoSaveBlock {
/// What the user (or a script) is told, in the interface language.
pub fn reason(&self) -> String {
match self {
AutoSaveBlock::Browser => {
tl!("AutoSave isn't available in the browser: it can't write to your files. Save downloads a copy.").to_string()
}
AutoSaveBlock::Unsaved => tl!("Save the document to turn on AutoSave").to_string(),
AutoSaveBlock::Format { name } => i18n::fmt(
tl!("AutoSave only keeps .docx, .odt and .rtf files; {name} can't keep all formatting. Save as .docx to turn on AutoSave."),
&[("name", name)],
),
AutoSaveBlock::NotSavedHere => {
tl!("Save the document to turn on AutoSave (a file you have only opened isn't rewritten until you save it)").to_string()
}
}
}
/// Whether Save As can lift the block (everywhere but the browser).
pub fn save_as_helps(&self) -> bool {
!matches!(self, AutoSaveBlock::Browser)
}
}
/// Whether saving to `name` keeps the whole document (the formats `file.save` treats as the
/// document's own file, including macro-enabled documents and templates); anything else is a
/// copy that leaves it unsaved. AutoSave writes only these formats.
fn keeps_everything(name: &str) -> bool {
let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json")
}
/// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get
/// the command's own error or default instead): Select Recipients and Edit Recipient List without
/// data, Insert Merge Field without a field, Find Recipient without text, the If and Skip
/// Record If rules (or Rules with no rule at all) without a field, Table Properties without
/// settings, Symbol and Field without a character or code, and Enclose Characters without a shape.
fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null());
let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase);
match id {
"mailings.rules" if !has("field") => match rule.as_deref() {
None | Some("IF") => Some("ruleIf"),
Some("SKIPIF") => Some("ruleSkipIf"),
_ => None,
},
"mailings.recipients" if !["csv", "path", "data", "rows"].into_iter().any(has) => Some("recipientList"),
"mailings.editRecipients" if !has("rows") => Some("recipientList"),
"mailings.insertField" if !has("field") => Some("insertMergeField"),
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
// Symbol without a character and Field without a code show their dialogs (#321).
"insert.symbol" if !has("char") => Some("symbol"),
"insert.field" if !has("instr") => Some("field"),
// Edit Field Code shows the selected field's code (#339).
"fields.setCode" if !has("code") => Some("editFieldCode"),
// Table Properties without settings shows the dialog (with settings it applies them).
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
"para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"),
"para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"),
"design.pageBorders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("pageBorders"),
// `null` is an answer here: it removes the password.
"file.encrypt" if params.get("password").is_none() => Some("encryptPassword"),
// Define New Multilevel List and Track Changes Options without settings show their dialogs.
"list.define" if params.get("levels").is_none() => Some("defineList"),
"review.trackingOptions" if params.as_object().is_none_or(|m| m.is_empty()) => Some("trackChangesOptions"),
"insert.bookmark" if !has("name") => Some("bookmark"),
// Word Count from the keyboard or command search shows its dialog, as the ribbon does.
"review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"),
// Enclose Characters without a shape (null is one: it takes the enclosure away).
"format.enclose" if params.get("shape").is_none() => Some("enclose"),
// Footnote and Endnote without settings shows its dialog (#385).
"references.noteOptions" if params.as_object().is_none_or(|m| m.is_empty()) => Some("noteOptions"),
_ => None,
}
}
/// User commands that replace or close the document (`file.open` without a path only shows the
/// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a
/// new document in its place; a merge written to a file (`path`) leaves it alone.
fn discards_document(id: &str, params: &Value) -> bool {
match id {
"file.new" | "file.close" | "mailings.envelopes" | "mailings.labels" => true,
"file.open" => params.get("path").is_some(),
"mailings.finish" => params.get("path").and_then(Value::as_str).is_none(),
_ => false,
}
}
/// Wall-clock milliseconds since the Unix epoch: the system clock, or the browser's on the web.
pub fn now_ms() -> f64 {
#[cfg(not(target_arch = "wasm32"))]
{
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
#[cfg(target_arch = "wasm32")]
{
let ms = js_sys::Date::now();
if ms.is_finite() && ms > 0.0 { ms } else { 0.0 }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mod_key(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }
}
/// Issue #14: Mod+- (optional hyphen) also zoomed the whole window out, so it read as "zoom out".
#[test]
fn word_shortcuts_do_not_zoom_the_window() {
let ctx = egui::Context::default();
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let mut frame = |events: Vec<egui::Event>| {
let input = egui::RawInput { events, ..Default::default() };
// No painter here: dropping the font atlas delta unapplied trips an epaint debug assertion.
ctx.run_ui(input, |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
};
frame(Vec::new());
frame(vec![mod_key(egui::Key::Minus)]);
frame(vec![mod_key(egui::Key::Minus)]);
frame(Vec::new());
assert_eq!(ctx.zoom_factor(), 1.0);
}
/// Issue #117: a viewport command schedules a repaint, so resending the title every frame kept
/// the app redrawing at the monitor's refresh rate while idle.
#[test]
fn window_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let mut app = app();
let titles = |app: &mut WordApp| {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
let n = out.viewport_output.values().flat_map(|v| v.commands.iter()).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count();
out.drop_without_applying_deltas();
n
};
// The first frames only install fonts; the title goes out once, on the first full frame.
let first: usize = (0..4).map(|_| titles(&mut app)).sum();
assert_eq!(first, 1);
assert_eq!(titles(&mut app), 0);
assert_eq!(titles(&mut app), 0);
app.session.dirty = true;
assert_eq!(titles(&mut app), 1);
}
fn app() -> WordApp {
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
}
/// Insert › Icons (#309) opens the icon picker from the ribbon; an id inserts without it.
#[test]
fn icons_button_opens_the_picker_and_ids_insert_directly() {
let mut a = app();
a.run("insert.icon", json!({})).unwrap();
assert_eq!(a.dialog.as_ref().map(dialogs::Dialog::name), Some("icons"));
a.dialog = None;
let r = a.run("insert.icon", json!({"id": "sun", "size": 36})).unwrap();
assert_eq!(r["icon"], "sun");
assert!(a.dialog.is_none(), "an id inserts without the dialog");
assert!(a.selected_picture_media().is_some());
}
/// Table Layout › Properties (#44) opens the dialog from the ribbon; OK sends only what
/// changed, as one `table.properties` step. Scripts with settings never see the dialog.
#[test]
fn table_properties_button_opens_the_dialog() {
let mut a = app();
a.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap();
a.run("table.properties", json!({})).unwrap();
let Some(dialogs::Dialog::TableProperties { form, basis }) = a.dialog.clone() else { panic!("no dialog: {:?}", a.dialog) };
assert_eq!(form.changes(&basis), json!({}), "untouched, nothing changes");
let mut f = (*form).clone();
f.align = "center".into();
f.row_height_on = true;
f.row_height = 0.5;
f.row_exact = true;
f.header_row = true;
let changes = f.changes(&basis);
assert_eq!(changes, json!({"align": "center", "rowHeight": 36.0, "rowHeightRule": "exact", "headerRow": true}));
a.dialog = None;
a.run("table.properties", changes).unwrap();
assert!(a.dialog.is_none(), "settings apply without the dialog");
let (tp, _, _) = a.session.sel.focus.path.cell().unwrap();
let t = a.session.doc.table(a.session.sel.focus.story, &tp).unwrap();
assert_eq!(t.props.align, Some(wordcraft_doc::props::Align::Center));
assert_eq!((t.rows[0].props.height, t.rows[0].props.header), (Some(36.0), true));
assert_eq!(dialogs::TableForm::read(&a).unwrap().changes(&f), json!({}), "the dialog reopens with the new values");
// Table direction (#362) is one more setting of the same step.
let mut g = dialogs::TableForm::read(&a).unwrap();
let basis = g.clone();
g.rtl = true;
assert_eq!(g.changes(&basis), json!({"rtl": true}));
}
/// Home › Font › Enclose Characters (#297): the button opens the dialog on the selected
/// character; the dialog's OK is `format.enclose`, and it reopens showing the enclosure.
#[test]
fn enclose_characters_button_opens_the_dialog() {
let mut a = app();
a.run("text.insert", json!({"text": "a 字 b"})).unwrap();
a.run("select.text", json!({"text": "字"})).unwrap();
a.run("format.enclose", json!({})).unwrap();
let Some(dialogs::Dialog::Enclose { style, text, shape }) = a.dialog.clone() else { panic!("no dialog: {:?}", a.dialog) };
assert_eq!((style.as_str(), text.as_str(), shape.as_str()), ("shrink", "字", "circle"));
a.dialog = None;
a.run("format.enclose", json!({"shape": "square", "style": "enlarge", "text": "字"})).unwrap();
assert!(a.dialog.is_none(), "a shape applies without the dialog");
a.run("format.enclose", json!({})).unwrap();
let Some(dialogs::Dialog::Enclose { style, shape, .. }) = a.dialog.clone() else { panic!("no dialog") };
assert_eq!((style.as_str(), shape.as_str()), ("enlarge", "square"));
}
/// Issue #139: Ctrl+wheel over the page didn't zoom.
#[test]
fn ctrl_wheel_over_canvas_zooms() {
let ctx = egui::Context::default();
let mut a = app();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
// The first frames install fonts; the canvas appears after them.
for _ in 0..3 {
frame(&mut a, Vec::new());
}
let over_page = a.canvas.canvas_rect.unwrap().center();
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
let before = a.canvas.scale;
let wheel = |y: f32| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, y),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
};
frame(&mut a, vec![wheel(1.0)]);
for _ in 0..30 {
frame(&mut a, Vec::new());
}
assert!(a.canvas.scale > before * 1.05, "Ctrl+wheel up zooms in: {before} -> {}", a.canvas.scale);
let zoomed = a.canvas.scale;
frame(&mut a, vec![wheel(-1.0)]);
for _ in 0..30 {
frame(&mut a, Vec::new());
}
assert!(a.canvas.scale < zoomed, "Ctrl+wheel down zooms out");
}
/// Issue #67: View › Zoom In from a fit mode zoomed *out*: Page Width showed 163% but Zoom In
/// stepped from the stale manual 100% to 110%.
#[test]
fn view_tab_zoom_steps_from_the_shown_zoom_in_fit_modes() {
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.tab", json!({"tab": "View"})).unwrap();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0));
let frame = |a: &mut WordApp| {
for _ in 0..4 {
let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
}
a.canvas.scale / canvas::PX_PER_PT
};
let base = frame(&mut a);
assert!((base - 1.0).abs() < 1e-3);
for fit in ["view.pageWidth", "view.onePage", "view.multiplePages"] {
a.run(fit, json!({})).unwrap();
let fitted = frame(&mut a);
assert!((fitted - base).abs() > 0.05, "{fit} changes the zoom");
assert!((a.session.view.zoom - fitted).abs() < 1e-3, "{fit}: session zoom follows the shown zoom");
a.run("view.zoomIn", json!({})).unwrap();
let zin = frame(&mut a);
assert!(zin > fitted, "{fit}: Zoom In zooms in: {fitted} -> {zin}");
a.run(fit, json!({})).unwrap();
frame(&mut a);
a.run("view.zoomOut", json!({})).unwrap();
let zout = frame(&mut a);
assert!(zout < fitted, "{fit}: Zoom Out zooms out: {fitted} -> {zout}");
// The Zoom dialog opens at the shown zoom.
a.run(fit, json!({})).unwrap();
frame(&mut a);
a.run("ui.dialog", json!({"name": "zoom"})).unwrap();
assert!(matches!(a.dialog, Some(dialogs::Dialog::Zoom { percent }) if (percent - (fitted * 100.0).round()).abs() < 1.0));
a.dialog = None;
}
a.run("view.zoom100", json!({})).unwrap();
assert!((frame(&mut a) - 1.0).abs() < 1e-3);
}
/// Issue #122: touchpad deltas scroll the page 1:1 at once; a wheel notch eases in to about
/// three lines.
#[test]
fn touchpad_scrolls_one_to_one_and_wheel_notches_ease_in() {
let ctx = egui::Context::default();
let mut a = app();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 600.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let input = egui::RawInput { events, time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
for _ in 0..3 {
frame(&mut a, Vec::new());
}
let over_page = a.canvas.canvas_rect.unwrap().center();
frame(&mut a, vec![egui::Event::PointerMoved(over_page)]);
let wheel = |unit, y: f32, phase| egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, y), phase, modifiers: egui::Modifiers::NONE };
let start = a.canvas.scroll_offset.y;
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, -40.0, egui::TouchPhase::Start)]);
assert_eq!(a.canvas.scroll_offset.y, start + 40.0, "the first touchpad delta lands in the same frame");
// Fingers lift; the coast after it is left to the unit tests (crate::scroll).
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Point, 0.0, egui::TouchPhase::End)]);
a.canvas.wheel = Default::default();
let at = a.canvas.scroll_offset.y;
frame(&mut a, vec![wheel(egui::MouseWheelUnit::Line, -1.0, egui::TouchPhase::Move)]);
let first = a.canvas.scroll_offset.y - at;
for _ in 0..30 {
frame(&mut a, Vec::new());
}
let notch = crate::scroll::notch_px(a.canvas.scale / crate::canvas::PX_PER_PT);
assert!(first > 0.0 && first < notch, "a notch eases in: {first}");
assert!((a.canvas.scroll_offset.y - at - notch).abs() < 0.5, "a notch scrolls {notch}: {}", a.canvas.scroll_offset.y - at);
}
/// Issue #123: zoomed out, pages sit side by side, and clicks map to the page under the pointer.
#[test]
fn zoomed_out_pages_sit_side_by_side_and_clicks_land_on_them() {
let ctx = egui::Context::default();
let mut a = app();
for _ in 0..3 {
a.run("insert.pageBreak", json!({})).unwrap();
}
a.run("text.insert", json!({"text": "Last page"})).unwrap();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0));
let mut t = 0.0;
let mut frame = |a: &mut WordApp| {
t += 1.0 / 60.0;
let input = egui::RawInput { time: Some(t), screen_rect: Some(screen), ..Default::default() };
ctx.run_ui(input, |ui| {
a.logic(ui.ctx());
a.ui(ui);
})
.drop_without_applying_deltas();
};
for _ in 0..3 {
frame(&mut a);
}
let rects = a.canvas.page_rects.clone();
assert_eq!(rects.len(), 4);
assert!(rects[1].top() > rects[0].bottom(), "100%: one page per row");
a.run("view.zoom", json!({"value": 30})).unwrap();
for _ in 0..3 {
frame(&mut a);
}
let rects = a.canvas.page_rects.clone();
assert_eq!(a.canvas.cols, 4, "30%: all four pages fit across: {rects:?}");
assert!((rects[3].top() - rects[0].top()).abs() < 1.0 && rects[3].left() > rects[2].right(), "{rects:?}");
// A click inside the last page's text puts the caret on that page.
let p = canvas::page_to_screen(&mut a, 3, 100.0, 80.0).unwrap();
assert!(rects[3].contains(p), "{p:?} in {:?}", rects[3]);
let pos = canvas::pos_from_screen(&mut a, p).unwrap();
let caret = a.session.layout().caret_on(&pos, 3).unwrap();
assert_eq!(caret.page, 3);
}
#[test]
fn user_name_survives_restart() {
let mut first = app();
first.run("file.setAuthor", json!({"name": "Ada Lovelace"})).unwrap();
let saved = serde_json::to_vec(&first.prefs()).unwrap();
let mut second = app();
second.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert_eq!(second.session.author, "Ada Lovelace");
// A later rename is what gets saved next, not the name loaded at startup.
second.run("file.setAuthor", json!({"name": "Grace Hopper"})).unwrap();
assert_eq!(second.prefs().author, "Grace Hopper");
}
const UNSAVED: &str = "My unsaved novel chapter";
/// An app whose new document has unsaved typing.
fn typed() -> WordApp {
let mut a = app();
a.run("text.insert", json!({"text": UNSAVED})).unwrap();
assert!(a.session.dirty);
a
}
fn body_text(a: &WordApp) -> String {
a.session.doc.plain_text(wordcraft_doc::StoryRef::Body)
}
fn prompt(a: &WordApp) -> Option<&'static str> {
a.dialog.as_ref().map(|d| d.name())
}
/// A fresh scratch folder for one test.
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-ui-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A .docx on disk that the app didn't write.
fn docx_on_disk(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
let path = dir.join("contract.docx");
let mut s = Session::new(wordcraft_doc::Document::from_text(text));
s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
path
}
/// Surfaces finding 1: Mod+N (and the File › New tiles) replaced unsaved work without asking.
#[test]
fn new_asks_before_discarding_unsaved_work() {
let mut a = typed();
a.run("file.new", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
assert!(body_text(&a).contains(UNSAVED), "the document is untouched while the prompt is up");
assert!(a.session.dirty);
// Cancel: nothing happens.
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
assert_eq!(prompt(&a), None);
assert!(body_text(&a).contains(UNSAVED));
// Don't Save: the new document replaces it.
a.run("file.new", json!({"template": "letter"})).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(prompt(&a), None);
assert!(!body_text(&a).contains(UNSAVED));
assert!(!a.session.dirty);
assert!(!body_text(&a).trim().is_empty(), "the template the user picked was used");
}
#[test]
fn a_clean_document_is_replaced_without_asking() {
let mut a = app();
a.run("file.new", json!({"template": "letter"})).unwrap();
assert_eq!(prompt(&a), None);
assert!(!body_text(&a).trim().is_empty());
}
/// #55: a password-protected document asks for its password (after Save Changes) instead of
/// opening blank; scripts get the error instead of a dialog; Encrypt with Password asks for one.
#[test]
fn password_protected_documents_ask_for_the_password() {
let dir = scratch("password");
let path = dir.join("locked.docx");
let mut s = Session::new(wordcraft_doc::Document::from_text("Locked away"));
s.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
let mut a = typed();
assert!(a.execute("file.open", json!({"path": path.to_string_lossy()})).is_err(), "a script gets the error");
assert_eq!(prompt(&a), None);
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(prompt(&a), Some("password"));
assert!(body_text(&a).contains(UNSAVED), "nothing is replaced until the password fits");
let state = serde_json::to_string(&a.dialog).unwrap();
assert!(state.contains("locked.docx") && !state.contains("sesame"));
let mut b = app();
b.run("file.encrypt", json!({})).unwrap();
assert_eq!(prompt(&b), Some("encryptPassword"));
b.dialog = None;
b.run("file.encrypt", json!({"password": null})).unwrap();
assert_eq!(prompt(&b), None, "null removes the password without asking");
let _ = std::fs::remove_dir_all(&dir);
}
/// Open (after the file is picked), recent files and dropped files all go through `file.open`.
#[test]
fn open_asks_before_discarding_unsaved_work() {
let dir = scratch("open");
let path = docx_on_disk(&dir, "Signed contract");
let mut a = typed();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
assert!(body_text(&a).contains(UNSAVED));
assert_eq!(a.session.path, None);
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert!(body_text(&a).contains("Signed contract"));
assert_eq!(a.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
/// Mod+O without a path only shows the file picker, so it doesn't ask yet.
#[test]
fn open_without_a_path_only_shows_the_picker() {
let mut a = typed();
a.run("file.open", json!({})).unwrap();
assert_eq!(prompt(&a), None);
assert!(body_text(&a).contains(UNSAVED));
}
/// Mod+W ran `file.close`, which quit at once.
#[test]
fn close_asks_before_quitting() {
let mut a = typed();
a.run("file.close", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
assert!(!a.quit_requested);
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
assert!(!a.quit_requested);
a.run("file.close", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert!(a.quit_requested);
// A clean document closes straight away.
let mut clean = app();
clean.run("file.close", json!({})).unwrap();
assert_eq!(prompt(&clean), None);
assert!(clean.quit_requested);
}
/// The window's close button quit at once; now it asks (the host cancels the close on false).
#[test]
fn window_close_asks_first() {
assert!(app().close_requested(), "a clean document closes");
let mut a = typed();
assert!(!a.close_requested());
assert_eq!(prompt(&a), Some("saveChanges"));
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
assert!(!a.close_requested(), "asked again on the next click");
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert!(a.quit_requested);
assert!(a.close_requested(), "the close that follows goes ahead");
assert!(body_text(&a).contains(UNSAVED), "nothing was replaced on the way out");
}
/// Enter activates the focused prompt button; it means Save only when no button has focus.
/// (Enter on a focused Cancel or Don't Save used to be overridden by a blanket Enter → Save.)
#[test]
fn enter_answers_with_the_focused_button() {
use egui_kittest::kittest::Queryable;
for (focus, quits, saves) in [(Some("Cancel"), false, false), (Some("Don't Save"), true, false), (None, true, true)] {
let dir = scratch(&format!("enter-{}", focus.map_or(0, str::len)));
let path = docx_on_disk(&dir, "Draft");
let before = std::fs::read(&path).unwrap();
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
a.run("file.close", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
// The dialog's theme fonts are installed on the first frame and usable from the next.
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().build_ui_state(
move |ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
if fonts {
dialogs::show(app, &ctx);
} else {
theme::install_fonts_for(&ctx, false);
fonts = true;
}
},
a,
);
for _ in 0..3 {
h.step();
}
if let Some(label) = focus {
h.get_by_label(label).focus();
h.step();
assert!(h.get_by_label(label).is_focused(), "{label} has focus");
}
h.key_press(egui::Key::Enter);
for _ in 0..3 {
h.step();
}
let a = h.state();
assert_eq!(prompt(a), None, "{focus:?}: answered");
assert_eq!(a.quit_requested, quits, "{focus:?}: quit");
assert_eq!(std::fs::read(&path).unwrap() != before, saves, "{focus:?}: file written");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// `ui.print` hands the document's PDF to the host's print hook, and is an error (with
/// nothing printed) when the host has none.
#[test]
fn ui_print_needs_the_hosts_print_hook() {
let mut a = typed();
assert!(a.run("ui.print", json!({})).is_err(), "no print hook: an error");
let got = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let seen = got.clone();
a.services.print = Some(Box::new(move |bytes| {
seen.borrow_mut().push(bytes.to_vec());
Ok(())
}));
let r = a.run("ui.print", json!({})).unwrap();
assert_eq!(r["printing"], true);
assert_eq!(got.borrow().len(), 1, "printed once");
assert!(got.borrow().first().is_some_and(|b| b.starts_with(b"%PDF")), "the hook got a PDF");
assert!(a.session.dirty, "printing is not a save");
a.services.print = Some(Box::new(|_| Err("blocked by the browser".into())));
a.status_msg = None;
let e = a.run("ui.print", json!({})).unwrap_err();
assert!(e.contains("blocked by the browser"), "{e}");
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.contains("blocked by the browser")), "the status bar says why");
}
/// The Print page shows the Print button only when the host can print, and the button
/// prints through `ui.print`.
#[test]
fn the_print_button_shows_only_with_a_print_hook() {
use egui_kittest::kittest::Queryable;
const BLURB: &str = "Send the document straight to the system print dialog, or save a copy in another format below.";
for hook in [false, true] {
let printed = std::rc::Rc::new(std::cell::Cell::new(0));
let mut a = app();
if hook {
let seen = printed.clone();
a.services.print = Some(Box::new(move |_| {
seen.set(seen.get() + 1);
Ok(())
}));
}
a.run("file.print", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..4 {
h.step();
}
assert!(h.state().ui.backstage && h.state().ui.backstage_page == "print", "the Print page is open");
assert_eq!(h.query_by_label(BLURB).is_some(), hook, "hook {hook}: the Print button's blurb");
assert!(h.query_by_label("PDF document (*.pdf)").is_some(), "hook {hook}: the page rendered its export choices");
if hook {
h.get_by_role_and_label(egui::accesskit::Role::Button, "Print").click();
h.step();
h.step();
assert_eq!(printed.get(), 1, "the button printed");
}
}
}
/// A prompt about document A outlived an agent replacing A with B, and Don't Save then ran
/// the pending New against B, throwing away B's edits under a question naming A.
#[test]
fn a_prompt_is_dropped_when_its_document_is_replaced() {
let dir = scratch("stale");
let path = docx_on_disk(&dir, "Agent document");
let ctx = egui::Context::default();
let mut a = typed();
a.run("file.new", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
let asked = a.dialog.clone();
for command in [
json!({"command": "file.open", "params": {"path": path.to_string_lossy()}}),
json!({"command": "text.insert", "params": {"text": "Edited "}}),
] {
let (req, _reply) = ControlRequest::new("engine.execute", command);
control::handle(&mut a, &ctx, &req);
}
assert_eq!(prompt(&a), None, "the question was about a document that is gone");
// An answer that arrives anyway (a click in that frame, an agent) doesn't touch B.
a.dialog = asked;
assert!(a.run("ui.saveChanges", json!({"answer": "dontSave"})).is_err());
assert!(body_text(&a).contains("Edited Agent document"));
assert_eq!(a.session.path.as_deref(), Some(path.as_path()));
assert!(a.session.dirty);
let _ = std::fs::remove_dir_all(&dir);
}
/// The web shell's leave-page guard learned about unsaved changes only after `logic`, but
/// typing lands in `ui`, so a tab closed right after the first keystroke left without asking.
#[test]
fn the_host_hears_about_edits_made_while_drawing() {
let reported = std::rc::Rc::new(std::cell::Cell::new(None));
let mut a = app();
let seen = reported.clone();
a.services.on_dirty = Some(Box::new(move |d| seen.set(Some(d))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert!(h.state().canvas.focused, "the page has keyboard focus");
assert_eq!(reported.get(), Some(false));
h.event(egui::Event::Text("x".into()));
h.step();
assert!(h.state().session.dirty, "the keystroke reached the document");
assert_eq!(reported.get(), Some(true), "the host was told before the frame ended");
}
/// #330: Word's shortcuts work from the keyboard — Ctrl+Alt+1 (⌘⌥1) applies Heading 1, and
/// an Alt shortcut doesn't also type the character its key would produce.
#[test]
fn word_shortcuts_run_from_the_keyboard() {
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app(),
);
for _ in 0..6 {
h.step();
}
let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
h.event(egui::Event::Text("Kilns".into()));
h.step();
h.event(key(egui::Key::Num1, egui::Modifiers::COMMAND | egui::Modifiers::ALT));
h.step();
let style =
|h: &egui_kittest::Harness<WordApp>| h.state().session.doc.para_at(&wordcraft_doc::Pos::body(0, 0)).and_then(|p| p.props.style.clone());
assert_eq!(style(&h).as_deref(), Some("Heading1"));
// Alt+F7 (next misspelling) can come with key text on some platforms; none is typed.
h.event(key(egui::Key::F7, egui::Modifiers::ALT));
h.event(egui::Event::Text("x".into()));
h.step();
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns");
}
/// Save writes the document first, then carries on with what the user asked for.
#[test]
fn save_then_continue() {
let dir = scratch("save");
let path = docx_on_disk(&dir, "Draft");
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
a.run("file.new", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(prompt(&a), None);
assert_eq!(a.session.path, None, "the new document replaced the saved one");
let saved = wordcraft_engine::io::open_path(&path).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Draft"));
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As to a format that doesn't keep everything (a .png of page 1, plain text) reports
/// `saved` but leaves the document unsaved; the prompt took that as saved and replaced it.
#[test]
fn a_save_that_drops_content_does_not_continue() {
for ext in ["png", "txt"] {
let dir = scratch(&format!("lossy-{ext}"));
let picked = dir.join(format!("novel.{ext}")).to_string_lossy().to_string();
let mut a = typed();
a.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
a.run("file.new", json!({})).unwrap();
a.status_msg = None;
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["done"], false, "{ext}: the New was cancelled");
assert!(dir.join(format!("novel.{ext}")).exists(), "{ext}: the copy was written");
assert_eq!(prompt(&a), None);
assert!(body_text(&a).contains(UNSAVED), "{ext}: the document is kept");
assert!(a.session.dirty);
assert!(a.status_msg.is_some(), "{ext}: the status bar says why");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Web: a download in a format that doesn't keep everything marked the document saved, so
/// the prompt and the leave-page guard let it go.
#[test]
fn web_downloads_that_drop_content_leave_the_document_unsaved() {
for name in ["novel.png", "novel.txt", "novel.md"] {
let mut a = typed();
a.services.download = Some(Box::new(|_, _| Ok(())));
a.execute("file.saveAs", json!({"path": name})).unwrap();
assert!(a.session.dirty, "{name}");
}
for name in ["novel.docx", "novel.docm", "novel.DOTX", "novel.dotm", "novel.odt", "novel.rtf"] {
let mut a = typed();
a.services.download = Some(Box::new(|_, _| Ok(())));
a.execute("file.saveAs", json!({"path": name})).unwrap();
assert!(!a.session.dirty, "{name}");
}
}
/// Macro-enabled documents and templates keep everything (#172), so once saved in this
/// session AutoSave covers them like a .docx.
#[test]
fn autosave_covers_macro_enabled_documents_and_templates() {
for ext in ["docm", "dotx", "dotm"] {
let dir = scratch(&format!("autosave-{ext}"));
let path = dir.join(format!("novel.{ext}"));
let mut a = typed();
a.run("file.save", json!({"path": path.to_string_lossy()})).unwrap();
assert!(a.autosaves(), "{ext}");
a.run("text.insert", json!({"text": "More "})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
assert!(!a.session.dirty, "{ext}: AutoSave wrote the file");
let saved = wordcraft_engine::io::open_path(&path).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("More "), "{ext}");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Web: the download callback couldn't report a failure, so Save marked the document saved
/// and the prompt went ahead even when no download started.
#[test]
fn a_failed_download_is_not_a_save() {
let mut a = typed();
a.services.download = Some(Box::new(|_, _| Err("blocked by the browser".into())));
assert!(a.execute("file.save", json!({})).is_err());
assert!(a.session.dirty);
a.run("file.new", json!({})).unwrap();
a.status_msg = None;
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["done"], false, "the New was cancelled");
assert!(body_text(&a).contains(UNSAVED));
assert!(a.session.dirty);
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(msg.contains("blocked by the browser"), "the status bar says why: {msg:?}");
}
/// A new document has no path: Save goes through Save As, and cancelling that cancels the New.
#[test]
fn save_as_cancelled_keeps_the_document() {
let mut a = typed();
a.services.pick_save = Some(Box::new(|_| None));
a.run("file.new", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(prompt(&a), None);
assert!(body_text(&a).contains(UNSAVED));
assert!(a.session.dirty);
}
#[test]
fn save_as_picked_saves_then_continues() {
let dir = scratch("saveas");
let path = dir.join("novel.docx");
let picked = path.to_string_lossy().to_string();
let mut a = typed();
a.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
a.run("file.close", json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert!(a.quit_requested);
let saved = wordcraft_engine::io::open_path(&path).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains(UNSAVED));
let _ = std::fs::remove_dir_all(&dir);
}
/// Scripts and agents (control channel, MCP bridge) are never asked anything.
#[test]
fn programmatic_commands_never_prompt() {
let ctx = egui::Context::default();
let mut a = typed();
let (req, _reply) = ControlRequest::new("engine.execute", json!({"command": "file.new"}));
control::handle(&mut a, &ctx, &req);
assert_eq!(prompt(&a), None);
assert!(!body_text(&a).contains(UNSAVED));
}
const MAILINGS: [&str; 3] = ["mailings.envelopes", "mailings.labels", "mailings.finish"];
/// Envelopes, Labels and Finish & Merge replace the document with a new one, like New, but
/// the ribbon ran them at once and the unsaved document was gone without a question.
#[test]
fn mailings_ask_before_replacing_unsaved_work() {
for id in MAILINGS {
let mut a = typed();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
let document = a.session.document_id();
a.run(id, json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"), "{id}");
assert_eq!(a.session.document_id(), document, "{id}: untouched while the prompt is up");
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
assert_eq!(prompt(&a), None);
assert_eq!(a.session.document_id(), document, "{id}: Cancel keeps the document");
assert!(body_text(&a).contains(UNSAVED));
assert!(a.session.dirty);
a.run(id, json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(prompt(&a), None);
assert_ne!(a.session.document_id(), document, "{id}: Don't Save makes the new document");
assert_eq!(a.session.path, None);
}
// A `path` that isn't text merges into a new document, like no path at all.
let mut a = typed();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("mailings.finish", json!({"path": null})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
}
/// A clean document is replaced straight away, like New; a merge written to a file
/// (`mailings.finish` with `path`) doesn't replace anything, so it never asks.
#[test]
fn mailings_on_a_clean_document_or_into_a_file_do_not_ask() {
let dir = scratch("merge-to-file");
for id in MAILINGS {
let mut a = app();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("file.save", json!({"path": dir.join("saved.docx").to_string_lossy()})).unwrap();
assert!(!a.session.dirty);
let document = a.session.document_id();
a.run(id, json!({})).unwrap();
assert_eq!(prompt(&a), None, "{id}");
assert_ne!(a.session.document_id(), document, "{id}");
}
let out = dir.join("merged.docx");
let mut a = typed();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("mailings.finish", json!({"path": out.to_string_lossy()})).unwrap();
assert_eq!(prompt(&a), None);
assert!(out.exists());
assert!(body_text(&a).contains(UNSAVED));
let _ = std::fs::remove_dir_all(&dir);
}
/// The result is a new, untitled document: Save, AutoSave and the title never point at the
/// file the original came from, even after Save in the prompt wrote that file.
#[test]
fn mailings_results_never_save_over_the_original() {
for id in MAILINGS {
let dir = scratch(&format!("mailings-{}", id.len()));
let path = docx_on_disk(&dir, "Signed contract");
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
a.run("file.save", json!({})).unwrap();
assert!(a.autosaves());
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("text.insert", json!({"text": "Again "})).unwrap();
a.run(id, json!({})).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(prompt(&a), None);
let saved = std::fs::read(&path).unwrap();
assert!(
wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Again Signed"),
"{id}: Save wrote the document first"
);
assert_eq!(a.session.path, None, "{id}");
assert!(!a.saved_here() && !a.autosaves(), "{id}: AutoSave doesn't cover the result");
assert_ne!(a.title_stem(), "contract", "{id}: titled as a new document");
a.run("text.insert", json!({"text": "Note "})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
a.run("file.save", json!({})).unwrap();
a.run("edit.undo", json!({})).unwrap();
a.run("edit.undo", json!({})).unwrap();
a.run("file.save", json!({})).unwrap();
a.autosave_tick(now_ms() + 20_000.0);
assert_eq!(std::fs::read(&path).unwrap(), saved, "{id}: the original file is never overwritten");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Scripts and agents (control channel, MCP bridge) replace the document without a question.
#[test]
fn programmatic_mailings_never_prompt() {
let ctx = egui::Context::default();
for id in MAILINGS {
let mut a = typed();
a.execute("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
let document = a.session.document_id();
let (req, _reply) = ControlRequest::new("engine.execute", json!({"command": id}));
control::handle(&mut a, &ctx, &req);
assert_eq!(prompt(&a), None, "{id}");
assert_ne!(a.session.document_id(), document, "{id}");
assert_eq!(a.session.path, None, "{id}");
}
}
/// Review finding: with undo kept across the swap, an agent's Undo brought document A back
/// under the id of the mailing result B while a prompt about B was up, and Don't Save then
/// threw away A. The history starts afresh instead, so the prompt only ever discards B.
#[test]
fn undo_after_a_mailing_cannot_redirect_a_pending_prompt() {
for id in MAILINGS {
let mut a = typed();
a.execute("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.execute(id, json!({})).unwrap();
let result = body_text(&a);
let b = a.session.document_id();
assert!(a.session.dirty, "{id}: the result is unsaved");
a.run("file.new", json!({})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"), "{id}");
a.execute("edit.undo", json!({})).unwrap();
assert_eq!(a.session.document_id(), b, "{id}");
assert_eq!(body_text(&a), result, "{id}: nothing from before the mailing comes back");
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_ne!(a.session.document_id(), b, "{id}: Don't Save discarded the document it asked about");
}
}
/// Review finding: with undo kept across the swap, Undo → Save As → Redo put the mailing result
/// under the saved file, and Save or AutoSave then wrote it there.
#[test]
fn redo_after_a_mailing_cannot_put_the_result_under_a_saved_file() {
for id in MAILINGS {
let dir = scratch(&format!("mailings-redo-{}", id.len()));
let path = docx_on_disk(&dir, "Signed contract");
let copy = dir.join("copy.docx");
let on_file = |p: &std::path::Path| wordcraft_engine::io::open_path(p).unwrap().plain_text(wordcraft_doc::StoryRef::Body);
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("file.save", json!({})).unwrap();
a.run(id, json!({})).unwrap();
assert_eq!(prompt(&a), None, "{id}: the document was saved");
let result = body_text(&a);
a.run("edit.undo", json!({})).unwrap();
assert_eq!(body_text(&a), result, "{id}: history starts afresh, like New");
a.run("file.saveAs", json!({"path": copy.to_string_lossy()})).unwrap();
let written = on_file(&copy);
a.run("edit.redo", json!({})).unwrap();
assert_eq!(body_text(&a), result, "{id}: nothing to redo");
a.run("file.save", json!({})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
assert_eq!(on_file(&copy), written, "{id}");
assert!(on_file(&path).contains("Signed contract"), "{id}: the original is untouched");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Surfaces finding 3: AutoSave rewrote any opened .docx ~2.5 s after the first keystroke,
/// deleting whatever the reader doesn't model (content controls, charts, macros…).
#[test]
fn opened_files_are_not_autosaved_until_the_user_saves() {
let dir = scratch("autosave");
let path = docx_on_disk(&dir, "Original");
let before = std::fs::read(&path).unwrap();
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Typed "})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
assert!(std::fs::read(&path).unwrap() == before, "an opened file is never rewritten behind the user's back");
assert!(a.session.dirty);
// After an explicit save, later changes are saved automatically.
a.run("file.save", json!({})).unwrap();
a.run("text.insert", json!({"text": "More "})).unwrap();
assert!(a.session.dirty);
a.autosave_tick(now_ms() + 10_000.0);
assert!(!a.session.dirty);
let saved = wordcraft_engine::io::open_path(&path).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Typed More Original"));
let _ = std::fs::remove_dir_all(&dir);
}
/// #196: a saved .md file showed AutoSave on, but AutoSave writes only formats that keep
/// everything, so edits stayed unsaved. The switch is now off and blocked with a reason, and
/// scripts turning it on get that reason as an error; a .docx saved here is covered.
#[test]
fn autosave_is_blocked_with_a_reason_for_files_it_cannot_keep() {
let dir = scratch("autosave-md");
let md = dir.join("notes.md");
std::fs::write(&md, "# Notes\n\nSome text\n").unwrap();
let mut a = app();
assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Unsaved), "a new document has no file yet");
a.run("file.open", json!({"path": md.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Typed "})).unwrap();
a.run("file.save", json!({})).unwrap();
assert!(a.autosave, "the preference stays on");
assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Format { name: "notes.md".into() }));
assert!(!a.autosaves(), "the switch shows off");
let why = a.autosave_block().map(|b| b.reason()).unwrap_or_default();
assert!(why.contains("notes.md") && why.contains(".docx"), "{why}");
let err = a.run("file.autosave", json!({"value": true})).unwrap_err();
assert_eq!(err, why, "scripts are told why");
assert!(a.run("file.autosave", json!({"value": false})).is_ok(), "turning it off always works");
a.autosave = true;
// Save As .docx (what the greyed switch offers) lifts the block.
let docx = dir.join("notes.docx");
a.run("file.saveAs", json!({"path": docx.to_string_lossy()})).unwrap();
assert_eq!(a.autosave_block(), None);
assert!(a.autosaves());
a.run("text.insert", json!({"text": "More "})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
assert!(!a.session.dirty, "AutoSave wrote the .docx");
assert_eq!(a.run("file.autosave", json!({})).unwrap(), json!({"value": false}), "toggling turns it off");
assert!(!a.autosaves());
let _ = std::fs::remove_dir_all(&dir);
}
/// #176: in the browser saving is a download, so AutoSave can't keep a file up to date; the
/// switch is blocked with a reason instead of being on while nothing is saved.
#[test]
fn autosave_is_unavailable_in_the_browser() {
let services = Services { download: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
a.run("text.insert", json!({"text": "Hello"})).unwrap();
a.run("file.save", json!({"path": "draft.docx"})).unwrap();
assert_eq!(a.autosave_block(), Some(AutoSaveBlock::Browser));
assert!(!a.autosaves());
let err = a.run("file.autosave", json!({"value": true})).unwrap_err();
assert!(err.contains("browser"), "{err}");
}
/// Cancelling the Open picker (Mod+O) left the document as it was but turned AutoSave off.
#[test]
fn cancelling_open_keeps_autosave() {
let dir = scratch("autosave-open");
let path = dir.join("novel.docx");
let mut a = typed();
a.services.pick_open = Some(Box::new(|_| None));
a.run("file.save", json!({"path": path.to_string_lossy()})).unwrap();
assert!(a.autosaves());
a.run("file.open", json!({})).unwrap();
assert!(a.autosaves(), "the picker was cancelled; nothing was replaced");
a.run("text.insert", json!({"text": " continues"})).unwrap();
a.autosave_tick(now_ms() + 10_000.0);
assert!(!a.session.dirty);
let saved = wordcraft_engine::io::open_path(&path).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("chapter continues"));
let _ = std::fs::remove_dir_all(&dir);
}
/// A failing AutoSave used to be silent (`let _ =`) and retried every 2.5 s.
#[test]
fn autosave_failures_are_reported_once() {
let dir = scratch("autosave-fail");
let path = dir.join("gone").join("novel.docx");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut a = typed();
a.run("file.save", json!({"path": path.to_string_lossy()})).unwrap();
// The folder disappears (unplugged drive, network share gone).
std::fs::remove_dir_all(path.parent().unwrap()).unwrap();
a.run("text.insert", json!({"text": "More "})).unwrap();
a.status_msg = None;
a.autosave_tick(now_ms() + 10_000.0);
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(msg.contains("AutoSave"), "the failure shows in the status bar: {msg:?}");
assert!(a.session.dirty);
// Not retried until the user saves again.
a.status_msg = None;
a.autosave_tick(now_ms() + 20_000.0);
assert!(a.status_msg.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
/// Web Save of an opened .docm downloads a .docm, so its macros stay (Codex review, docm lane).
#[test]
fn web_save_keeps_the_opened_word_format() {
let mut doc = wordcraft_doc::Document::new();
doc.passthrough.insert("word/vbaProject.bin".into(), std::sync::Arc::new(vec![0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]));
doc.passthrough.insert("word/vbaData.xml".into(), std::sync::Arc::new(b"<wne:vbaSuppData/>".to_vec()));
// The docx reader's list of parts the project relates to (wordcraft_docx VBA_RELATED).
let related = "http://schemas.microsoft.com/office/2006/relationships/wordVbaData\tword/vbaData.xml\tapplication/vnd.ms-word.vbaData+xml\n";
doc.passthrough.insert("wordcraft:vbaProject.related".into(), std::sync::Arc::new(related.as_bytes().to_vec()));
let got: std::rc::Rc<std::cell::RefCell<Vec<(String, Vec<u8>)>>> = Default::default();
let sink = got.clone();
let services = Services {
download: Some(Box::new(move |n: &str, b: &[u8]| {
sink.borrow_mut().push((n.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
let mut a = WordApp::new(Session::new(doc), services);
for (opened, expect) in
[("m.docm", "m.docm"), ("t.DOTM", "t.DOTM"), ("t.dotx", "t.dotx"), ("notes.odt", "notes.docx"), ("readme.txt", "readme.docx")]
{
a.session.path = Some(opened.into());
a.run("file.save", json!({})).unwrap();
let (name, bytes) = got.borrow_mut().pop().unwrap();
assert_eq!(name, expect);
let back = wordcraft_engine::io::open_bytes(&name, &bytes).unwrap();
let macros = name.to_ascii_lowercase().ends_with('m');
for part in ["word/vbaProject.bin", "word/vbaData.xml"] {
assert_eq!(back.passthrough.contains_key(part), macros, "{opened}: {part}");
}
}
// Save As with an explicit name is a conversion: a .docx can't hold macros.
a.session.path = Some("m.docm".into());
a.run("file.saveAs", json!({"path": "m.docx"})).unwrap();
let (name, bytes) = got.borrow_mut().pop().unwrap();
assert_eq!(name, "m.docx");
assert!(!wordcraft_engine::io::open_bytes(&name, &bytes).unwrap().passthrough.contains_key("word/vbaProject.bin"));
}
#[test]
fn dark_page_survives_restart() {
let mut first = app();
assert!(!first.session.view.dark_mode);
first.run("view.darkMode", json!({"value": true})).unwrap();
let saved = serde_json::to_vec(&first.prefs()).unwrap();
let mut second = app();
second.apply_prefs(serde_json::from_slice(&saved).unwrap());
assert!(second.session.view.dark_mode);
}
#[test]
fn prefs_without_user_name_keep_default() {
let mut a = app();
let default = a.session.author.clone();
a.apply_prefs(serde_json::from_str(r#"{"dark": true, "backstage": true}"#).unwrap());
assert_eq!(a.session.author, default);
assert_eq!(a.ui.theme, theme::Appearance::Dark);
assert!(!a.ui.backstage);
}
/// #115: the old dark-mode switch migrates to Light/Dark; a saved System stays System, and the
/// old key is never written back.
#[test]
fn interface_theme_migrates_and_survives_a_restart() {
use theme::Appearance;
for (saved, want) in [(r#"{"dark": false}"#, Appearance::Light), (r#"{"dark": true}"#, Appearance::Dark), ("{}", Appearance::Light)] {
let mut a = app();
a.apply_prefs(serde_json::from_str(saved).unwrap());
assert_eq!(a.ui.theme, want, "{saved}");
}
let mut a = app();
a.run("ui.theme", json!({"value": "System"})).unwrap();
let saved = serde_json::to_string(&a.prefs()).unwrap();
assert!(saved.contains(r#""theme":"system""#) && !saved.contains(r#""dark":"#), "{saved}");
let mut b = app();
b.apply_prefs(serde_json::from_str(&saved).unwrap());
assert_eq!(b.ui.theme, Appearance::System);
// An unknown saved value keeps the rest of the preferences.
let odd: UiState = serde_json::from_str(r#"{"theme": "sepia", "tab": "Insert"}"#).unwrap();
assert_eq!((odd.theme, odd.tab.as_str()), (Appearance::Light, "Insert"));
}
/// #475: the interface size is kept between runs; junk saved values read as 100 % and
/// out-of-range ones are clamped, without losing the other preferences.
#[test]
fn interface_size_survives_a_restart_and_clamps_junk() {
let mut a = app();
assert_eq!(a.run("ui.interfaceSize", json!({})).unwrap()["percent"], 100);
assert_eq!(a.run("ui.interfaceSize", json!({"percent": 150})).unwrap()["percent"], 150);
let saved = serde_json::to_string(&a.prefs()).unwrap();
assert!(saved.contains(r#""interfaceSize":150"#), "{saved}");
let mut b = app();
b.apply_prefs(serde_json::from_str(&saved).unwrap());
assert_eq!(b.ui.interface_size.percent(), 150);
for (raw, want) in [("1000", 200), ("10", 80), ("-5", 80), ("124.6", 125), (r#""big""#, 100), ("null", 100), ("[1]", 100)] {
let ui: UiState = serde_json::from_str(&format!(r#"{{"interfaceSize": {raw}, "tab": "Insert"}}"#)).unwrap();
assert_eq!((ui.interface_size.percent(), ui.tab.as_str()), (want, "Insert"), "{raw}");
}
assert_eq!(a.run("ui.interfaceSize", json!({"percent": 500})).unwrap()["percent"], 200);
assert!(a.run("ui.interfaceSize", json!({"percent": "huge"})).is_err());
assert!(a.run("ui.interfaceSize", json!({"percent": null})).is_err());
assert_eq!(a.ui.interface_size.percent(), 200, "a rejected value keeps the setting");
}
/// #475: the interface size multiplies the display scale the system reports (egui's zoom
/// factor) and leaves document zoom alone.
#[test]
fn interface_size_scales_on_top_of_the_system_scale() {
let ctx = egui::Context::default();
let mut a = app();
let doc_zoom = a.session.view.zoom;
a.run("ui.interfaceSize", json!({"percent": 150})).unwrap();
let frame = |a: &mut WordApp, native: f32| {
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(native);
ctx.run_ui(input, |ui| a.logic(ui.ctx())).drop_without_applying_deltas();
};
// The new factor applies from the next pass.
frame(&mut a, 2.0);
frame(&mut a, 2.0);
assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (1.5, 3.0));
a.run("ui.interfaceSize", json!({"percent": 80})).unwrap();
frame(&mut a, 1.0);
frame(&mut a, 1.0);
assert_eq!((ctx.zoom_factor(), ctx.pixels_per_point()), (0.8, 0.8));
assert_eq!(a.session.view.zoom, doc_zoom, "document zoom is a separate setting");
}
/// #115: System follows the OS appearance (light when unknown); the manual choices ignore it.
#[test]
fn interface_theme_resolves_against_the_os_appearance() {
use egui::Theme::{Dark, Light};
use theme::Appearance;
let cases = [(Appearance::System, None, false), (Appearance::System, Some(Light), false), (Appearance::System, Some(Dark), true)];
for (setting, os, dark) in cases.into_iter().chain([(Appearance::Light, Some(Dark), false), (Appearance::Dark, Some(Light), true)]) {
assert_eq!(setting.is_dark(os), dark, "{setting:?} with the OS at {os:?}");
}
let mut a = app();
assert!(a.run("ui.theme", json!({"value": "purple"})).is_err());
assert_eq!(a.run("ui.theme", json!({"value": "dark"})).unwrap()["theme"], "dark");
assert_eq!(a.run("ui.dark", json!({})).unwrap()["theme"], "light", "ui.dark toggles the manual choice");
assert_eq!(a.run("ui.theme", json!({})).unwrap()["theme"], "light");
}
/// One app frame with the OS appearance egui reports; returns the window commands it sent.
fn theme_frame(ctx: &egui::Context, a: &mut WordApp, os: Option<egui::Theme>) -> Vec<egui::ViewportCommand> {
let input = egui::RawInput { system_theme: os, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| a.logic(ui.ctx()));
let commands = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
out.drop_without_applying_deltas();
commands
}
/// #311: with System, egui's theme preference stays System, so the native window is never
/// pinned to a concrete appearance (macOS would stop reporting OS changes), and our palette
/// follows each OS appearance egui reports.
#[test]
fn system_theme_follows_the_os_without_pinning_the_window() {
use egui::{SystemTheme, Theme, ThemePreference, ViewportCommand};
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.theme", json!({"value": "system"})).unwrap();
let mut sent = Vec::new();
for (os, dark) in [(Some(Theme::Light), false), (Some(Theme::Dark), true), (Some(Theme::Light), false), (None, false)] {
sent.extend(theme_frame(&ctx, &mut a, os));
assert_eq!(ctx.options(|o| o.theme_preference), ThemePreference::System, "OS at {os:?}");
let want = if dark { theme::Tokens::dark() } else { theme::Tokens::light() };
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "OS at {os:?}");
// Our palette, not egui's default style for that theme.
assert_eq!(ctx.global_style().visuals.panel_fill, want.ribbon, "OS at {os:?}");
assert_eq!(a.ui_is_dark(), dark, "OS at {os:?}");
}
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::SystemDefault)), "{sent:?}");
assert!(!sent.iter().any(|c| matches!(c, ViewportCommand::SetTheme(SystemTheme::Light | SystemTheme::Dark))), "{sent:?}");
// A manual choice pins the window, whatever the OS says.
a.run("ui.theme", json!({"value": "dark"})).unwrap();
let sent = theme_frame(&ctx, &mut a, Some(Theme::Light));
assert!(sent.contains(&ViewportCommand::SetTheme(SystemTheme::Dark)), "{sent:?}");
assert!(theme::Tokens::get(&ctx).dark);
}
/// #312: Dark page darkens only the pages; the interface keeps following its own setting.
#[test]
fn dark_page_leaves_the_interface_theme_alone() {
use egui::Theme::{Dark, Light};
for (setting, os, dark) in [
("system", Some(Light), false),
("system", None, false),
("light", Some(Dark), false),
("system", Some(Dark), true),
("dark", Some(Light), true),
] {
let ctx = egui::Context::default();
let mut a = app();
a.run("ui.theme", json!({"value": setting})).unwrap();
a.run("view.darkMode", json!({"value": true})).unwrap();
theme_frame(&ctx, &mut a, os);
assert!(a.session.view.dark_mode, "{setting} with the OS at {os:?}");
assert_eq!(theme::Tokens::get(&ctx).dark, dark, "{setting} with the OS at {os:?}");
assert_eq!(a.run("ui.theme", json!({})).unwrap()["dark"], dark, "{setting} with the OS at {os:?}");
}
}
#[test]
fn word_count_setting_survives_a_restart() {
let mut a = app();
a.session.run("review.wordCount", &json!({"includeTextBoxes": false})).unwrap();
let saved = serde_json::to_string(&a.prefs()).unwrap();
assert!(saved.contains(r#""editing":{"countNotes":false,"#), "{saved}");
let mut b = app();
b.apply_prefs(serde_json::from_str(&saved).unwrap());
assert!(!b.session.prefs.count_notes);
}
#[test]
fn prefs_without_editing_keep_its_defaults() {
let mut a = app();
a.apply_prefs(serde_json::from_str(r#"{"tab": "Insert", "dark": true}"#).unwrap());
assert_eq!((a.ui.tab.as_str(), a.ui.theme), ("Insert", theme::Appearance::Dark));
assert!(a.session.prefs.count_notes, "text boxes and notes count by default");
}
fn png_bytes(c: [u8; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_fn(20, 10, |_, _| image::Rgba(c));
let mut b = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png).unwrap();
b
}
fn insert_picture(a: &mut WordApp, c: [u8; 4]) {
let data = wordcraft_engine::cmd::insert::base64_encode(&png_bytes(c));
a.session.run("insert.picture", &json!({"data": data})).unwrap();
}
fn object_count(a: &mut WordApp) -> usize {
a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0)
}
/// A pending Change Picture replaces the selected image instead of inserting (#147).
#[test]
fn change_picture_replaces_instead_of_inserting() {
let mut a = app();
a.services.inbox = Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())));
insert_picture(&mut a, [200, 30, 30, 255]);
assert_eq!(object_count(&mut a), 1);
let before = a.selected_picture_media().unwrap();
a.change_picture_target = a.selected_picture_media();
a.services.inbox.as_ref().unwrap().lock().unwrap().push(("new.png".into(), png_bytes([30, 200, 30, 255])));
a.drain_inbox();
assert_eq!(object_count(&mut a), 1);
assert_ne!(a.selected_picture_media().unwrap(), before);
assert!(a.change_picture_target.is_none());
}
/// Without a pending change, inbox images insert; stale targets clear on selection change.
#[test]
fn insert_without_pending_adds_and_stale_target_clears() {
let mut a = app();
a.services.inbox = Some(std::sync::Arc::new(std::sync::Mutex::new(Vec::new())));
insert_picture(&mut a, [200, 30, 30, 255]);
a.session.run("select.collapse", &json!({"end": true})).unwrap();
a.services.inbox.as_ref().unwrap().lock().unwrap().push(("second.png".into(), png_bytes([30, 30, 200, 255])));
a.drain_inbox();
assert_eq!(object_count(&mut a), 2);
// Stale target (another picture's key) clears instead of replacing the new selection.
a.change_picture_target = Some("m0000-gone.png".into());
a.clear_stale_change_picture();
assert!(a.change_picture_target.is_none());
}
/// `ui.changePicture` needs a selected picture; cancelling the picker clears the target.
#[test]
fn change_picture_guards_and_cancel_clears() {
let mut a = app();
assert!(a.run("ui.changePicture", json!({})).is_err());
assert!(a.change_picture_target.is_none());
insert_picture(&mut a, [200, 30, 30, 255]);
// No pickers in tests, so the picker "cancels" and the target clears.
assert!(a.run("ui.changePicture", json!({})).is_ok());
assert!(a.change_picture_target.is_none());
}
/// Any unrelated action cancels a pending Change Picture (#147 web-picker cancel case).
#[test]
fn unrelated_command_clears_change_picture_target() {
let mut a = app();
insert_picture(&mut a, [200, 30, 30, 255]);
a.change_picture_target = a.selected_picture_media();
assert!(a.change_picture_target.is_some());
let _ = a.run("format.bold", json!({}));
assert!(a.change_picture_target.is_none());
}
fn dialog_name(a: &WordApp) -> Option<&'static str> {
a.dialog.as_ref().map(dialogs::Dialog::name)
}
/// #240: Select Recipients needs data, so clicking it opens the recipient list instead of
/// reporting missing parameters; scripts and agents still get the error.
#[test]
fn select_recipients_opens_the_recipient_list() {
let mut a = app();
let r = a.run("mailings.recipients", json!({})).unwrap();
assert_eq!(r, json!({"pending": "recipientList"}));
assert_eq!(dialog_name(&a), Some("recipientList"));
assert!(a.status_msg.is_none(), "no error in the status bar: {:?}", a.status_msg);
let Some(dialogs::Dialog::RecipientList { fields, rows, .. }) = &a.dialog else { panic!() };
assert_eq!(fields.len(), dialogs::NEW_LIST_FIELDS.len());
assert_eq!(rows.len(), 1, "a new list starts with one empty entry");
// Edit Recipient List opens the same dialog, showing the current list.
a.dialog = None;
a.run("mailings.recipients", json!({"csv": "Name,City\nAda,London"})).unwrap();
a.run("mailings.editRecipients", json!({})).unwrap();
let Some(dialogs::Dialog::RecipientList { fields, rows, .. }) = &a.dialog else { panic!() };
assert_eq!((fields.clone(), rows.clone()), (vec!["Name".to_string(), "City".into()], vec![vec!["Ada".to_string(), "London".into()]]));
// Type a New List… always starts fresh.
a.run("ui.dialog", json!({"name": "newRecipientList"})).unwrap();
let Some(dialogs::Dialog::RecipientList { fields, .. }) = &a.dialog else { panic!() };
assert_eq!(fields[0], dialogs::NEW_LIST_FIELDS[0]);
// Insert Merge Field without a field opens its dialog too.
assert_eq!(a.run("mailings.insertField", json!({})).unwrap(), json!({"pending": "insertMergeField"}));
// Programmatic calls never open dialogs.
let mut b = app();
assert!(b.execute("mailings.recipients", json!({})).is_err());
assert!(b.dialog.is_none());
}
/// #339: Ctrl+F9 opens the Field dialog (#321) and Edit Field… shows the selected field's code;
/// scripts and agents get an error instead of a dialog.
#[test]
fn field_code_dialogs_open_for_users_only() {
let mut a = app();
assert_eq!(a.run("insert.field", json!({})).unwrap(), json!({"pending": "field"}));
a.dialog = None;
a.run("insert.field", json!({"instr": "PAGE"})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
assert_eq!(a.run("fields.setCode", json!({})).unwrap(), json!({"pending": "editFieldCode"}));
let Some(dialogs::Dialog::FieldCode { code, edit: true, .. }) = &a.dialog else { panic!("{:?}", a.dialog) };
assert_eq!(code, "PAGE");
let mut b = app();
assert!(b.execute("insert.field", json!({})).is_err());
assert!(b.execute("fields.setCode", json!({})).is_err());
assert!(b.dialog.is_none());
}
/// Rules › If…Then…Else… asks for its condition and inserts the IF field it describes; Next
/// Record has nothing to ask. Match Fields and Check for Errors show what they found.
#[test]
fn merge_rules_and_reports_open_dialogs() {
let mut a = app();
a.run("mailings.recipients", json!({"csv": "First Name,City\nAda,London"})).unwrap();
assert_eq!(a.run("mailings.rules", json!({"rule": "IF"})).unwrap(), json!({"pending": "ruleIf"}));
let Some(mut d) = a.dialog.take() else { panic!("no dialog") };
let dialogs::Dialog::MergeRule { field, value, then, els, .. } = &mut d else { panic!("{d:?}") };
assert_eq!(field, "First Name", "the list's first field is picked");
(*field, *value, *then, *els) = ("City".into(), "London".into(), "Local".into(), "Away".into());
let params = dialogs::rule_params(&d).unwrap();
a.run("mailings.rules", params).unwrap();
assert!(a.dialog.is_none());
let fields = |a: &WordApp| -> Vec<String> {
let (doc, body) = (&a.session.doc, wordcraft_doc::StoryRef::Body);
let paras = doc.para_paths(body).into_iter().filter_map(|p| doc.para(body, &p).cloned());
paras
.flat_map(|p| p.objects)
.filter_map(|o| if let wordcraft_doc::para::InlineObject::Field { instr, .. } = o { Some(instr) } else { None })
.collect()
};
assert_eq!(fields(&a), vec![r#"IF { MERGEFIELD City } = "London" "Local" "Away""#]);
// Next Record inserts directly; Skip Record If asks.
assert!(a.run("mailings.rules", json!({"rule": "NEXT"})).unwrap().get("pending").is_none());
assert_eq!(fields(&a).last().map(String::as_str), Some("NEXT"));
assert_eq!(a.run("mailings.rules", json!({"rule": "skipif"})).unwrap(), json!({"pending": "ruleSkipIf"}));
a.dialog = None;
a.run("mailings.matchFields", json!({})).unwrap();
let Some(dialogs::Dialog::MatchFields { address, .. }) = &a.dialog else { panic!() };
assert_eq!(address["firstName"], "First Name");
a.run("mailings.checkErrors", json!({})).unwrap();
let Some(dialogs::Dialog::CheckErrors { unknown, records }) = &a.dialog else { panic!() };
assert_eq!((unknown.len(), *records), (0, 1));
// Scripts and agents get the data or the default rule, never a dialog.
let mut b = app();
b.execute("mailings.checkErrors", json!({})).unwrap();
b.execute("mailings.rules", json!({})).unwrap();
assert!(b.dialog.is_none());
}
/// Desktop: Use an Existing List… picks a file and loads it as recipients, not as a document.
#[test]
fn an_existing_list_picked_on_the_desktop_loads_recipients() {
let dir = scratch("recipients");
let csv = dir.join("people.csv");
std::fs::write(&csv, "First Name,City\nAda,London\nAlan,Wilmslow\n").unwrap();
let mut a = typed();
let asked = std::rc::Rc::new(std::cell::RefCell::new(String::new()));
let (seen, path) = (asked.clone(), csv.to_string_lossy().to_string());
a.services.pick_open = Some(Box::new(move |purpose| {
*seen.borrow_mut() = purpose.to_string();
Some(path.clone())
}));
a.run("ui.openRecipientList", json!({})).unwrap();
assert_eq!(*asked.borrow(), "recipients", "the host can offer CSV files");
assert_eq!(a.session.merge.headers, vec!["First Name", "City"]);
assert_eq!(a.session.merge.rows.len(), 2);
assert!(body_text(&a).contains(UNSAVED), "the document stays open");
let _ = std::fs::remove_dir_all(&dir);
}
/// Web: the picked file arrives through the inbox and loads as recipients, even as .txt; a
/// CSV dropped on the page does too. Anything else afterwards opens as before.
#[test]
fn an_existing_list_picked_on_the_web_loads_recipients() {
let mut a = typed();
let asked = std::rc::Rc::new(std::cell::RefCell::new(String::new()));
let seen = asked.clone();
a.services.open_async = Some(Box::new(move |purpose| *seen.borrow_mut() = purpose.to_string()));
let inbox: Inbox = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
a.services.inbox = Some(inbox.clone());
assert_eq!(a.run("ui.openRecipientList", json!({})).unwrap(), json!({"pending": true}));
assert_eq!(*asked.borrow(), "recipients");
inbox.lock().unwrap().push(("people.txt".into(), b"Name\tCity\nAda\tLondon\n".to_vec()));
a.drain_inbox();
assert_eq!(a.session.merge.headers, vec!["Name", "City"]);
assert!(!a.recipient_list_pending);
assert!(body_text(&a).contains(UNSAVED), "the document stays open");
inbox.lock().unwrap().push(("more.csv".into(), b"Name\nBo\nCy\n".to_vec()));
a.drain_inbox();
assert_eq!(a.session.merge.rows.len(), 2);
// A cancelled picker leaves nothing pending once the user does something else.
a.run("ui.openRecipientList", json!({})).unwrap();
a.run("format.bold", json!({})).unwrap();
assert!(!a.recipient_list_pending);
}
}