diff --git a/ROADMAP.md b/ROADMAP.md index beb91abb35..8f5484295e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -452,6 +452,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | Recent documents can be taken off the list (#2716): hovering a row in File › Home/Open ▸ Recent shows a × that removes that document, a Clear all button under the list empties it, and the change is written to the preferences right away, so it survives a crash. `ui.recentRemove {"path"}` and `ui.recentClear` do the same for CLI, control and MCP | | 2026-10-31 | Control/MCP #2596 review: write/flush failures after sending begins are outcome-unknown; image exports to a path are not replayed; accept-thread startup failures disable the server and remove the key; invalid UTF-8 is rejected per line without ending stdio. Synthetic regressions cover partial JSON delivery at EOF, flush failure, export vs in-memory retry and failed startup. | | 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation | | 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | @@ -1090,6 +1091,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) | | 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged | | 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) | +| 2026-10-17 | trivial | Progress log: removing documents from the recent list (#2716) | | 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only | | 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) | diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 83dc231769..9f497ab7dd 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -299,25 +299,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt } fn save_prefs(app: &WordApp) { + if let Err(e) = try_save_prefs(app) { + log::warn!("preferences: {e}"); + } +} + +/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written +/// because preferences are switched off or there is no settings folder. +fn try_save_prefs(app: &WordApp) -> Result { if !prefs_enabled() { - return; + return Ok(false); } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() - && let Err(e) = std::fs::create_dir_all(dir) - { - log::warn!("preferences: cannot create {}: {e}", dir.display()); - return; - } - match serde_json::to_vec_pretty(&app.prefs()) { - Ok(bytes) => { - if let Err(e) = write_prefs(&p, &bytes) { - log::warn!("preferences: cannot save {}: {e}", p.display()); - } - } - Err(e) => log::warn!("preferences: cannot serialize: {e}"), - } + let Some(p) = prefs_path() else { return Ok(false) }; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; } + let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?; + write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?; + Ok(true) } /// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or @@ -352,6 +351,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { set_clipboard_rich: Some(Box::new(clipboard::set_rich)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), + save_prefs: Some(Box::new(try_save_prefs)), ..Default::default() } } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 9c10e5f659..568c7ef839 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -397,6 +397,9 @@ impl WordApp { for req in std::mem::take(&mut self.session.ui_requests) { self.handle_request(&req); } + // The open document goes to the front of the recent list after every command. The recent + // commands (`ui.recentRemove`, `ui.recentClear`) return from `ui_command` before this + // runs, so taking a document off the list is never undone by the re-add (#2716). if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string()) && self.ui.recent.first() != Some(&p) { @@ -800,6 +803,38 @@ impl WordApp { } json!({"startScreen": self.ui.start_screen}) } + "ui.recentRemove" => { + // Backstage ▸ Home/Open ▸ Recent: take one document off the list (Word's context + // menu Remove). The path must be an entry that is on the list (#2716). + let Some(path) = p.get("path").and_then(Value::as_str) else { + return Some(Err("`path` must name a document on the recent list".into())); + }; + let before = self.ui.recent.len(); + self.ui.recent.retain(|r| r != path); + if self.ui.recent.len() == before { + return Some(Err(format!("`{path}` is not on the recent list"))); + } + let saved = self.save_prefs_now(); + if !matches!(saved, Some(Err(_))) { + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); + self.status(i18n::fmt(tl!("Removed {name} from the recent list"), &[("name", &name)])); + } + let mut r = json!({"removed": 1, "recent": self.ui.recent}); + add_saved(&mut r, saved); + r + } + "ui.recentClear" => { + // Clear the whole list, as Word's registry clear does for recent documents (#2716). + let removed = self.ui.recent.len(); + self.ui.recent.clear(); + let saved = self.save_prefs_now(); + if !matches!(saved, Some(Err(_))) { + self.status(i18n::fmt(tl!("Removed {count} documents from the recent list"), &[("count", &removed.to_string())])); + } + let mut r = json!({"removed": removed, "recent": self.ui.recent}); + add_saved(&mut r, saved); + r + } "ui.displayServer" => { // File › Options › Advanced › Linux display server (#2184): `value` `auto`, // `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch. @@ -1022,6 +1057,19 @@ fn input_dialog_other(id: &str, params: &Value) -> Option<&'static str> { } } +/// The result fields of a command that saves the preferences at once: `saved` is true when the +/// write worked, false with `saveError` when it failed (the change still holds for this run) and +/// null when the host has no preferences hook or wrote nothing. +fn add_saved(r: &mut Value, saved: Option>) { + r["saved"] = match &saved { + None => Value::Null, + Some(res) => json!(res.is_ok()), + }; + if let Some(Err(e)) = saved { + r["saveError"] = json!(e); + } +} + /// Commands that work while no document is open (#1129): opening or creating one, closing the /// window, Help, adding fonts (a font file dropped on the empty window), and the settings (File › /// Options, the view and interface preferences it sets, AutoCorrect Options, Customize Ribbon and @@ -1071,6 +1119,8 @@ pub(crate) fn works_without_document(id: &str) -> bool { | "ui.discord" | "ui.caretAnimation" | "ui.startScreen" + | "ui.recentRemove" + | "ui.recentClear" | "ui.displayServer" | "ui.statusCount" | "ui.checkForUpdates" diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 5541598d23..cd52541c99 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -5,6 +5,8 @@ use super::{ zotero, }; +use crate::i18n; + impl WordApp { /// A file that wouldn't open (#1103): log it, and say on the status bar which file and why, /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. @@ -19,6 +21,23 @@ impl WordApp { self.status_msg = Some((s.into(), now_ms())); } + /// Write the current preferences out through the host's hook, if it has one (#2716). The + /// desktop app saves them at exit; a change that should survive a crash, like taking a + /// document off the recent list, needs a save at the moment of the change. `None`: the host + /// has no hook or wrote nothing; `Some(Err)`: the write failed, and the status line says so. + pub(super) fn save_prefs_now(&mut self) -> Option> { + let save = self.services.save_prefs.as_ref()?; + match save(self) { + Ok(true) => Some(Ok(())), + // The host wrote nothing (preferences switched off): same as having no hook. + Ok(false) => None, + Err(e) => { + self.status(i18n::fmt(tl!("Could not save the preferences: {error}"), &[("error", &e)])); + Some(Err(e)) + } + } + } + /// 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 { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f0e25a7cbd..a17b0a45db 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -592,6 +592,28 @@ fn personal_error_id() -> egui::Id { egui::Id::unique("backstage_personal_error") } +/// How the × of a recent row is drawn (#2716): not at all while nothing is hovered, dimmed while +/// the row is, and with the stronger fill only when the pointer is on the × itself. +#[derive(Debug, PartialEq, Eq)] +enum RemoveCue { + Hidden, + Dim, + Strong, +} + +fn remove_cue(row_active: bool, x_active: bool) -> RemoveCue { + if x_active { + RemoveCue::Strong + } else if row_active { + RemoveCue::Dim + } else { + RemoveCue::Hidden + } +} + +/// Width of the × that sits at the trailing end of a recent row (#2716). +const REMOVE_W: f32 = 28.0; + /// Draws the Recent documents list, distinguishes duplicate names by path for screen readers, and /// stores an open failure in this page's temporary state. fn open_list(app: &mut WordApp, ui: &mut Ui) { @@ -610,7 +632,10 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { let file_name = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.to_string()); let names = shown.iter().map(|p| file_name(p)).collect::>(); for (p, name) in shown.iter().zip(&names) { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click()); + // #2716: each row carries its own Remove, as Word's context menu does. The × is a button + // of its own at the trailing end, so it never opens the document; clicking the rest of the + // row opens it as before. + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0) - REMOVE_W, 40.0), Sense::click()); // The row shows a name and a path that may not fit: the path in full is its tooltip. let resp = resp.on_hover_text(crate::i18n::isolate_path(p)); // Two documents with the same file name are told apart by their path, as on screen. @@ -632,10 +657,35 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { let side = crate::rtl::lead_center(rtl); ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 13.0), side, crate::i18n::isolate(name), medium(13.0), t.text); ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 29.0), side, crate::i18n::isolate_path(p), regular(11.0), t.text_dim); + let xr = Rect::from_min_max(pos2(r.max.x + 2.0, r.min.y + 4.0), pos2(r.max.x + REMOVE_W, r.max.y - 4.0)); + // Right-to-left: the × sits on the left, past the row's trailing edge. + let xr = if rtl { xr.translate(vec2(-(r.width() + REMOVE_W + 2.0), 0.0)) } else { xr }; + let xresp = ui.interact(xr, ui.scope_id().with(("recent-remove", &p)), Sense::click()); + xresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Remove from list"))); + // The × shows (dimmed) as soon as the row or the × is hovered or focused, so the Remove can + // be found; only the × itself gets the stronger fill and full-strength glyph. + match remove_cue(resp.hovered() || resp.has_focus(), xresp.hovered() || xresp.has_focus()) { + RemoveCue::Hidden => {} + RemoveCue::Dim => { + ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text_dim); + } + RemoveCue::Strong => { + ui.painter().rect_filled(xr, 4.0, t.hover); + ui.painter().text(xr.center(), egui::Align2::CENTER_CENTER, "×", medium(15.0), t.text); + } + } if resp.clicked() { let failed = open_recent(app, p).err(); ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default())); } + if xresp.clicked() { + let _ = app.run("ui.recentRemove", json!({"path": p})); + } + } + if app.ui.recent.iter().take(app.ui.recent_shown()).next().is_some() + && ui.add(egui::Button::new(egui::RichText::new(tl!("Clear all")).font(medium(13.0)))).clicked() + { + let _ = app.run("ui.recentClear", json!({})); } } diff --git a/crates/ui-egui/src/backstage/tests.rs b/crates/ui-egui/src/backstage/tests.rs index ed64c25458..3003642b9b 100644 --- a/crates/ui-egui/src/backstage/tests.rs +++ b/crates/ui-egui/src/backstage/tests.rs @@ -517,6 +517,56 @@ fn an_unusable_saved_folder_falls_back_to_the_apps_own() { let _ = std::fs::remove_dir_all(&own); } +/// #2716: the × of a recent row is a cue, not a secret: dimmed when only the row is hovered, +/// stronger on the × itself, absent when neither is. +#[test] +fn recent_remove_cross_shows_when_the_row_is_hovered() { + use super::{RemoveCue, remove_cue}; + assert_eq!(remove_cue(false, false), RemoveCue::Hidden); + assert_eq!(remove_cue(true, false), RemoveCue::Dim, "row hovered shows the glyph"); + assert_eq!(remove_cue(false, true), RemoveCue::Strong); + assert_eq!(remove_cue(true, true), RemoveCue::Strong); +} + +/// #2716: each recent row has its own Remove (× at the right end, so it opens nothing), and +/// Clear all takes the whole list away; the list is empty afterwards. +#[test] +fn recent_rows_can_be_removed_one_by_one_or_all_at_once() { + use egui::accesskit::Role; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + a.run("ui.backstage", json!({"value": true, "page": "home"})).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, + ); + h.run_steps(4); + let row = |h: &egui_kittest::Harness, name: &str| h.query_all_by_role_and_label(Role::Button, name).next().map(|n| n.rect()); + assert!(row(&h, "alpha.docx").is_some() && row(&h, "beta.docx").is_some(), "both rows show"); + // The × inside alpha's row, not beta's: click it and only alpha goes. + let remove_in = |h: &mut egui_kittest::Harness, name: &str| { + let r = row(h, name).expect(name); + h.query_all_by_role_and_label(Role::Button, "Remove from list") + .find(|n| n.rect().min.y >= r.min.y && n.rect().min.y < r.max.y) + .expect("row has its Remove") + .click(); + }; + remove_in(&mut h, "alpha.docx"); + h.run_steps(4); + assert_eq!(h.state().ui.recent, ["/tmp/beta.docx".to_string()], "the row's own Remove takes its row"); + assert!(h.state().ui.backstage, "the × does not open the Backstage away"); + assert!(row(&h, "alpha.docx").is_none() && row(&h, "beta.docx").is_some()); + h.get_by_label("Clear all").click(); + h.run_steps(4); + assert!(h.state().ui.recent.is_empty(), "Clear all empties the list"); + assert!(h.query_by_label("Documents you open will show up here.").is_some()); + assert!(row(&h, "beta.docx").is_none(), "and Clear all goes with it"); +} + /// Home and New are different pages (#141): Home has the short template row, the link to New /// and Recent; New has every template and no Recent list. #[test] diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index dcff376d0a..d9359b973b 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -1,5 +1,5 @@ use super::state::DefaultApp; -use super::{FileDialogAnswer, FileDialogRequest, async_saves, paste_picture, printing}; +use super::{FileDialogAnswer, FileDialogRequest, WordApp, async_saves, paste_picture, printing}; /// Platform services injected by the host (file dialogs, file I/O). #[derive(Default)] @@ -92,6 +92,13 @@ pub struct Services { /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder /// instead. An error means nothing changed. pub make_default_app: Option Result>>, + /// Desktop: write the app's current preferences out now, not only at exit (#2716): a + /// document taken off the recent list should survive the app being killed before a normal + /// close. `Ok(true)`: written; `Ok(false)`: nothing written because the host keeps no + /// preferences (switched off, no settings folder); an error is the reason the write failed. + /// The recent commands report these as `saved` true / null / false. `None` (web, tests) keeps + /// the change to this run and reports `saved: null`. + pub save_prefs: Option Result>>, /// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852), /// used while File › Options names none. File › New lists personal templates from it and Save /// as Word Template starts in it. `None` (web) hides personal templates. diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 4c069a3755..1a0590ae13 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -7915,3 +7915,107 @@ fn focus_hides_the_ribbon_and_escape_brings_it_back() { h.step(); assert!(h.state().session.view.read_mode && shown(&h, "Focus"), "Immersive Reader keeps the status bar"); } + +/// Issue #2716: taking documents off the recent list is final — the open document must not be +/// pushed back by the after-command re-add — and the change goes to the settings at once, so +/// it survives the app being killed. Junk params return errors, never panics. +#[test] +fn recent_list_edits_are_final_and_persist() { + let saved = std::rc::Rc::new(std::cell::Cell::new(0u32)); + let saved_hook = saved.clone(); + let mut a = app(); + a.services.save_prefs = Some(Box::new(move |app: &WordApp| { + saved_hook.set(saved_hook.get() + 1); + let _ = serde_json::to_string(&app.prefs()).unwrap(); + Ok(true) + })); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + // The open document is on the list, at the back. + a.session.path = Some(std::path::PathBuf::from("/tmp/beta.docx")); + + // Removing the open document sticks: `after_command` must not re-add it. + let r = a.run("ui.recentRemove", json!({"path": "/tmp/beta.docx"})).unwrap(); + assert_eq!(r["removed"], 1); + assert_eq!(r["recent"], json!(["/tmp/alpha.docx"])); + assert_eq!(r["saved"], true); + assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "the open document stays off the list"); + assert_eq!(saved.get(), 1, "the change is written out at once"); + + // Removing an entry that isn't there is an error, not a silent success, and saves nothing. + assert!(a.run("ui.recentRemove", json!({"path": "/tmp/gamma.docx"})).is_err()); + assert_eq!(saved.get(), 1, "a rejected command writes nothing"); + // Hostile params: missing path, junk types. + for junk in [json!({}), json!({"path": 7}), json!({"path": null}), json!({"path": ["x"]})] { + assert!(a.run("ui.recentRemove", junk).is_err()); + } + assert_eq!(a.ui.recent, ["/tmp/alpha.docx".to_string()], "junk params change nothing"); + + // A normal command re-adds the open document, as before. + a.run("text.insert", json!({"text": "x"})).unwrap(); + assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string())); + + // Clearing empties the list (the re-added open document is back, so both entries go) and + // keeps it empty through the next command. + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert_eq!(r["removed"], 2); + assert!(a.ui.recent.is_empty()); + assert_eq!(saved.get(), 2, "the clear is written out at once"); + a.run("text.insert", json!({"text": "y"})).unwrap(); + assert_eq!(a.ui.recent.first(), Some(&"/tmp/beta.docx".to_string()), "the open document comes back after clearing"); + + // Clearing an empty list reports zero removed and is not an error. + a.ui.recent.clear(); + assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0); + + // The list lives in the prefs, so a restart keeps the removal. + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap(); + let saved_prefs = serde_json::to_string(&a.prefs()).unwrap(); + assert!(saved_prefs.contains(r#""recent":["/tmp/beta.docx"]"#), "{saved_prefs}"); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved_prefs).unwrap()); + assert_eq!(b.ui.recent, ["/tmp/beta.docx".to_string()]); +} + +/// Issue #2716: when the immediate preferences write fails, both recent commands say so (result +/// and status line) instead of reporting plain success; the removal still holds for this run. +/// A host with no hook, or one that wrote nothing, reports `saved: null`. +#[test] +fn recent_list_edits_report_a_failed_preferences_save() { + let mut a = app(); + a.services.save_prefs = Some(Box::new(|_: &WordApp| Err::("disk full".to_string()))); + a.ui.recent = vec!["/tmp/alpha.docx".into(), "/tmp/beta.docx".into()]; + + let r = a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap(); + assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}"); + assert_eq!(r["recent"], json!(["/tmp/beta.docx"])); + assert_eq!(a.ui.recent, ["/tmp/beta.docx".to_string()], "the removal holds for this run"); + let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(status.contains("disk full") && !status.starts_with("Removed"), "{status}"); + + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert_eq!((&r["saved"], &r["saveError"]), (&json!(false), &json!("disk full")), "{r}"); + assert_eq!(r["removed"], 1); + assert!(a.ui.recent.is_empty()); + + // No hook, or a hook that wrote nothing (preferences switched off): `saved` is null. + for hook in [None, Some(Box::new(|_: &WordApp| Ok(false)) as Box Result>)] { + a.services.save_prefs = hook; + a.ui.recent = vec!["/tmp/alpha.docx".into()]; + let r = a.run("ui.recentClear", json!({})).unwrap(); + assert!(r["saved"].is_null() && r.get("saveError").is_none(), "{r}"); + assert!(a.status_msg.as_ref().is_some_and(|(m, _)| m.starts_with("Removed")), "{:?}", a.status_msg); + } +} + +/// Issue #2716: taking documents off the list needs no open document — the Start screen shows +/// the recent list, and its buttons must work from there. +#[test] +fn recent_list_edits_work_without_a_document() { + let mut a = app(); + a.ui.recent = vec!["/tmp/alpha.docx".into()]; + assert!(!a.session.dirty && a.session.path.is_none()); + assert!(works_without_document("ui.recentRemove") && works_without_document("ui.recentClear")); + assert_eq!(a.run("ui.recentRemove", json!({"path": "/tmp/alpha.docx"})).unwrap()["removed"], 1); + assert_eq!(a.run("ui.recentClear", json!({})).unwrap()["removed"], 0); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b12d4c5a5d..6fc0451d7e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -82,8 +82,7 @@ let apps move windows, so there Arrange All and Side by Side only resize. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | -| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | +| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.displayServer` (`{"value": "auto"\|"wayland"\|"x11"}`; no value reads it; File › Options › Advanced › Linux display server, which the desktop app reads on Linux and the BSDs at its next launch; `WORDCRAFT_WAYLAND` overrides it, #2184), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.recentRemove` (`{"path": entry}` takes that document off the recent list — `path` must be an entry on the list; on the desktop the new list is saved right away: the result's `saved` is true then, false with `saveError` when the write failed (the removal still holds for this run) and null where the host keeps no preferences (the web app, or preferences switched off), #2716), `ui.recentClear` (`{}` clears the whole list; the result reports `removed`, the new `recent` and `saved` as above), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `windows`: the other windows as the drawn window sees them (`available`, `others`, `sideBySide`, `syncScroll`); `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |