From 0dd5b56dfe6bfbf2c8bcb32a7b54013ffd6459f6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:17:59 +0000 Subject: [PATCH 01/87] Task panes: draw them with craft_ui::panes; Styles docks left or right or floats (#1756) The nine task panes (SmartArt Text Pane, Navigation, Clipboard, Styles, Style Inspector, Accessibility, Alt Text, Restrict Editing, Comments) are drawn by craft_ui::panes with the Columns policy: a title bar with a menu and close button, a resize edge that also takes arrow keys, and F6 to move between panes and the document. Panes open from the same view flags, in the same order and at the same default widths; close runs each pane's existing command. The Styles pane can dock left or right or float through the pane menu or the new command view.stylesPaneDock. ui.json keeps its position and float rect; unknown values read as the defaults. --- Cargo.lock | 2 +- crates/engine/src/cmd/view.rs | 14 ++ crates/engine/src/lib.rs | 3 +- crates/engine/src/session.rs | 37 ++++ crates/engine/src/tests.rs | 25 +++ crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/a11y_pane.rs | 8 - crates/ui-egui/src/i18n/bg.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/et.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sr-latn.tsv | 8 + crates/ui-egui/src/i18n/sr.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/lib.rs | 90 ++++++++- crates/ui-egui/src/panes.rs | 289 +++++++++++++++++----------- 31 files changed, 522 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8154b2041a..ded460c881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" dependencies = [ "egui", ] diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index ff29899133..eea11db594 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -91,6 +91,10 @@ pub fn specs() -> Vec { .params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#) .pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), + // Where the Styles pane sits: docked right or left, or floating (#1756). No `dock` reads it. + CommandSpec::new("view.stylesPaneDock", "Styles Pane Position", "Home › Styles", styles_pane_dock) + .params(r#"{"dock"?: "left" | "right" | "float"}"#) + .pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { toggle(s, v, |x| &mut x.accessibility_pane) @@ -158,6 +162,16 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { Ok(json!({"mode": m, "readMode": read})) } +fn styles_pane_dock(s: &mut Session, v: &Value) -> CmdResult { + if let Some(d) = v.get("dock") { + let Some(dock) = d.as_str().and_then(crate::StylesPaneDock::parse) else { + return Err(CmdError::Params("dock must be left, right or float".into())); + }; + s.view.styles_pane_dock = dock; + } + Ok(json!({"dock": s.view.styles_pane_dock.name()})) +} + fn read_column_width(s: &mut Session, v: &Value) -> CmdResult { let width = match v.get("value").and_then(Value::as_str) { Some("auto") => None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0158a6054f..32e43e3f9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,8 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale, + CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, ViewState, blank_document, + set_system_locale, }; pub use wordcraft_doc as doc; pub use wordcraft_geom::Region; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 671a53cb2b..93fe4fcffc 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -131,6 +131,10 @@ pub struct ViewState { pub table_gridlines: bool, pub nav_pane: bool, pub styles_pane: bool, + /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). + /// Kept in the user's preferences, not the document. + #[serde(default)] + pub styles_pane_dock: StylesPaneDock, /// Style Inspector pane (Home › Styles). #[serde(default)] pub style_inspector: bool, @@ -204,6 +208,38 @@ pub struct ViewState { pub full_screen: bool, } +/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StylesPaneDock { + #[default] + Right, + Left, + /// A window of its own over the document, which can be moved and resized. + #[serde(rename = "float")] + Floating, +} + +impl StylesPaneDock { + /// `left`, `right` or `float`; anything else is `None`. + pub fn parse(name: &str) -> Option { + match name { + "right" => Some(Self::Right), + "left" => Some(Self::Left), + "float" => Some(Self::Floating), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Floating => "float", + } + } +} + fn on() -> bool { true } @@ -240,6 +276,7 @@ impl Default for ViewState { table_gridlines: true, nav_pane: false, styles_pane: false, + styles_pane_dock: StylesPaneDock::Right, style_inspector: false, comments_pane: false, clipboard_pane: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4031e230a1..f45b1656cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3882,6 +3882,31 @@ fn styles_apply_pane_is_not_an_edit() { assert!(s.view.styles_pane); } +/// #1756: the Styles pane docks on the right (the default), on the left, or floats. +#[test] +fn styles_pane_docks_left_right_or_floats() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.dirty = false; + let undo = s.undo_labels(); + assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], "right"); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "right"}), "no dock reads it"); + for dock in ["left", "float", "right"] { + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({"dock": dock})), json!({"dock": dock})); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": dock})); + assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], dock); + } + run(&mut s, "view.stylesPaneDock", json!({"dock": "left"})); + for junk in [json!({"dock": "top"}), json!({"dock": 1}), json!({"dock": null}), json!({"dock": ["left"]}), json!({"dock": "LEFT "})] { + assert!(s.run("view.stylesPaneDock", &junk).is_err(), "{junk} is an error"); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "left"}), "{junk} changes nothing"); + } + // A view setting: no undo step, the document isn't changed, and the pane's visibility stays. + assert_eq!(s.undo_labels(), undo); + assert!(!s.dirty); + assert!(!s.view.styles_pane); +} + #[test] fn styles_apply_rejects_bad_style_params() { let mut s = s(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed412196..d7762b82c0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs index 25d2c82c62..425af8c6ca 100644 --- a/crates/ui-egui/src/a11y_pane.rs +++ b/crates/ui-egui/src/a11y_pane.rs @@ -10,10 +10,6 @@ use crate::theme::{regular, semibold}; /// Review › Check Accessibility: the findings for the document as it is now. pub fn accessibility(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Accessibility") { - let _ = app.run("view.accessibilityPane", json!({"value": false})); - return; - } let issues = issues(app, ui); if issues.is_empty() { ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak()); @@ -90,10 +86,6 @@ fn go_to(app: &mut WordApp, issue: &Value) { /// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark, /// or the title and description of the table the caret is in. pub fn alt_text(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Alt Text") { - let _ = app.run("view.altTextPane", json!({"value": false})); - return; - } if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) { let decorative = obj.is_decorative(); ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index af0a3a3144..f62fcc6591 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2687,3 +2687,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си. Import… Импортиране… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положение на панела със стилове + Dock Left Закачане вляво + Dock Right Закачане вдясно + Float Откачане + Pane Options Опции на панела + Move Pane Преместване на панела + Resize Pane Преоразмеряване на панела diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 041f03be1d..280765cc72 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2690,3 +2690,11 @@ preview Title Ukázka Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce. Import… Importovat… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Umístění podokna stylů + Dock Left Ukotvit vlevo + Dock Right Ukotvit vpravo + Float Plovoucí + Pane Options Možnosti podokna + Move Pane Přesunout podokno + Resize Pane Změnit velikost podokna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eeb70c65e8..b75c438cfb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2287,3 +2287,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält. Import… Importieren… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Position des Formatvorlagenbereichs + Dock Left Links andocken + Dock Right Rechts andocken + Float Unverankert + Pane Options Bereichsoptionen + Move Pane Bereich verschieben + Resize Pane Bereichsgröße ändern diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19ef8fcf53..6e0e94e026 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2654,3 +2654,11 @@ preview Title Título Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones. Import… Importar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posición del panel de estilos + Dock Left Acoplar a la izquierda + Dock Right Acoplar a la derecha + Float Flotante + Pane Options Opciones del panel + Move Pane Mover panel + Resize Pane Cambiar tamaño del panel diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0aa9e52c17..aeec5279f9 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2322,3 +2322,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid. Import… Impordi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stiilipaani asukoht + Dock Left Dokki vasakule + Dock Right Dokki paremale + Float Ujuv + Pane Options Paani suvandid + Move Pane Teisalda paan + Resize Pane Muuda paani suurust diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44c9cb9377..f4d289be76 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2700,3 +2700,11 @@ template Letter Lettre Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions. Import… Importer… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Position du volet Styles + Dock Left Ancrer à gauche + Dock Right Ancrer à droite + Float Flottant + Pane Options Options du volet + Move Pane Déplacer le volet + Resize Pane Redimensionner le volet diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index a80b38d0b2..29ebe34792 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2698,3 +2698,11 @@ preview Title Naslov Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije. Import… Uvezi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Položaj okna stilova + Dock Left Usidri lijevo + Dock Right Usidri desno + Float Plutajuće + Pane Options Mogućnosti okna + Move Pane Premjesti okno + Resize Pane Promijeni veličinu okna diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0998d7d86a..c15b031f7b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2694,3 +2694,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait. Import… Importálás… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stílusok panel helye + Dock Left Rögzítés balra + Dock Right Rögzítés jobbra + Float Lebegtetés + Pane Options Panel beállításai + Move Pane Panel áthelyezése + Resize Pane Panel átméretezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 314f7cada3..a85cf1d4ed 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2707,3 +2707,11 @@ preview Title Esempio Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni. Import… Importa… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posizione del riquadro Stili + Dock Left Ancora a sinistra + Dock Right Ancora a destra + Float Mobile + Pane Options Opzioni riquadro + Move Pane Sposta riquadro + Resize Pane Ridimensiona riquadro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d633eac511..00cd791af4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2288,3 +2288,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。 Import… インポート… +# Task panes and the Styles pane position (#1756) + Styles Pane Position スタイル ウィンドウの位置 + Dock Left 左にドッキング + Dock Right 右にドッキング + Float フローティング + Pane Options ウィンドウ オプション + Move Pane ウィンドウの移動 + Resize Pane ウィンドウのサイズ変更 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7e50812e09..49b47bd027 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2542,3 +2542,11 @@ preview Title 제목 Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다. Import… 가져오기… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 스타일 창 위치 + Dock Left 왼쪽에 고정 + Dock Right 오른쪽에 고정 + Float 떠 있음 + Pane Options 창 옵션 + Move Pane 창 이동 + Resize Pane 창 크기 조정 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 784182eb7d..f2be90935a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2703,3 +2703,11 @@ preview Title Tytuł Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje. Import… Importuj… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Położenie okienka stylów + Dock Left Zadokuj po lewej + Dock Right Zadokuj po prawej + Float Pływające + Pane Options Opcje okienka + Move Pane Przenieś okienko + Resize Pane Zmień rozmiar okienka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a1038e1a7..dd6f449c4f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções. Import… Importar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posição do painel de estilos + Dock Left Encaixar à esquerda + Dock Right Encaixar à direita + Float Flutuante + Pane Options Opções do painel + Move Pane Mover painel + Resize Pane Redimensionar painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7880999f98..3e00416600 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2696,3 +2696,11 @@ preview Title Пример Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции. Import… Импорт… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положение области стилей + Dock Left Закрепить слева + Dock Right Закрепить справа + Float Открепить + Pane Options Параметры области + Move Pane Переместить область + Resize Pane Изменить размер области diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8e0a9136c2..3a7fe5f91f 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2298,3 +2298,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije. Import… Uvezi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Položaj okna stilova + Dock Left Usidri levo + Dock Right Usidri desno + Float Plutajuće + Pane Options Opcije okna + Move Pane Premesti okno + Resize Pane Promeni veličinu okna diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c55fa2c7e4..7e99069f32 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2302,3 +2302,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције. Import… Увези… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положај окна стилова + Dock Left Усидри лево + Dock Right Усидри десно + Float Плутајуће + Pane Options Опције окна + Move Pane Премести окно + Resize Pane Промени величину окна diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 50cab37aac..88d593509d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2713,3 +2713,11 @@ preview Title Titel Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner. Import… Importera… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stilpanelens placering + Dock Left Docka till vänster + Dock Right Docka till höger + Float Flytande + Pane Options Panelalternativ + Move Pane Flytta panel + Resize Pane Ändra storlek på panel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e7771f20a5..971f4914de 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2695,3 +2695,11 @@ preview Title Örnek Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur. Import… İçe aktar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stiller Bölmesi Konumu + Dock Left Sola Yerleştir + Dock Right Sağa Yerleştir + Float Serbest Bırak + Pane Options Bölme Seçenekleri + Move Pane Bölmeyi Taşı + Resize Pane Bölmeyi Yeniden Boyutlandır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f327585957..01895fb26e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції. Import… Імпортувати… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положення панелі стилів + Dock Left Закріпити ліворуч + Dock Right Закріпити праворуч + Float Відкріпити + Pane Options Параметри панелі + Move Pane Перемістити панель + Resize Pane Змінити розмір панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6616368ace..2b5ba02cec 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2738,3 +2738,11 @@ template Letter Thư Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ. Import… Nhập… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Vị trí khung kiểu + Dock Left Neo bên trái + Dock Right Neo bên phải + Float Nổi + Pane Options Tùy chọn khung + Move Pane Di chuyển khung + Resize Pane Đổi kích thước khung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d733ca8f00..ae23a8e6ab 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。 Import… 导入… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 样式窗格位置 + Dock Left 停靠在左侧 + Dock Right 停靠在右侧 + Float 浮动 + Pane Options 窗格选项 + Move Pane 移动窗格 + Resize Pane 调整窗格大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8bc708ea1..de6f923688 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2289,3 +2289,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。 Import… 匯入… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 樣式窗格位置 + Dock Left 停駐在左側 + Dock Right 停駐在右側 + Float 浮動 + Pane Options 窗格選項 + Move Pane 移動窗格 + Resize Pane 調整窗格大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 220131df86..4e8f1463f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -271,6 +271,15 @@ pub struct UiState { /// View › Navigation Pane: open or closed as the person left it, kept between runs (#2526). /// The session owns it while running (see [`WordApp::prefs`]). pub nav_pane: bool, + /// Where the Styles pane sits (#1756). The session owns it while running (see [`WordApp::prefs`]). + #[serde(deserialize_with = "lenient_styles_pane_dock")] + pub styles_pane_dock: wordcraft_engine::StylesPaneDock, + /// The floating Styles pane's `[x, y, width, height]`, also kept while it is docked. + #[serde(deserialize_with = "lenient_float_rect")] + pub styles_pane_float: Option<[f32; 4]>, + /// The docked Styles pane's width this run; like the other panes' widths, not kept between runs. + #[serde(skip)] + pub styles_pane_width: Option, pub show_discord: bool, /// User name (File › Options) for comments and tracked changes; empty keeps the default. pub author: String, @@ -373,6 +382,9 @@ impl Default for UiState { legacy_dark: None, nav_tab: "headings".into(), nav_pane: false, + styles_pane_dock: wordcraft_engine::StylesPaneDock::Right, + styles_pane_float: None, + styles_pane_width: None, show_discord: true, author: String::new(), window: None, @@ -459,6 +471,21 @@ pub fn recent_fonts_from<'a>(names: impl IntoIterator) -> Vec>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(v.as_str().and_then(wordcraft_engine::StylesPaneDock::parse).unwrap_or_default()) +} + +/// Anything but four finite numbers reads as no saved rect; `craft_ui::panes` brings a saved one +/// back inside the window. +fn lenient_float_rect<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Value::deserialize(d)?; + let Some([x, y, w, h]) = v.as_array().map(Vec::as_slice) else { return Ok(None) }; + let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); + Ok(n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h])) +} + /// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, /// not the whole list. fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -762,6 +789,7 @@ impl WordApp { ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations; ui.dark_page = self.session.view.dark_mode; ui.nav_pane = self.session.view.nav_pane; + ui.styles_pane_dock = self.session.view.styles_pane_dock; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); ui.ink_pens = self.session.view.draw.pens.clone(); @@ -807,6 +835,7 @@ impl WordApp { self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations; self.session.view.dark_mode = self.ui.dark_page; self.session.view.nav_pane = self.ui.nav_pane; + self.session.view.styles_pane_dock = self.ui.styles_pane_dock; // 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(); @@ -6613,6 +6642,62 @@ mod tests { assert!(!old.session.view.nav_pane, "no saved value: closed"); } + /// Task panes: two open panes on one edge sit side by side, the first outermost; a pane's × + /// runs its command; the Styles pane's place and float rect survive a restart (#1756). + #[test] + fn task_panes_share_an_edge_close_and_keep_the_styles_place() { + use egui_kittest::kittest::Queryable; + let bars = |h: &egui_kittest::Harness<'_, WordApp>| { + let mut bars: Vec = h.get_all_by_label("Move Pane").map(|n| n.rect()).collect(); + bars.sort_by(|a, b| a.left().total_cmp(&b.left())); + bars + }; + // A title bar's ▾ and × sit right of the bar, on its row. + fn click_button(h: &egui_kittest::Harness<'_, WordApp>, bar: egui::Rect, label: &str) { + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom() && r.left() >= bar.right() - 1.0; + let node = h.get_all_by_label(label).filter(|n| on_row(n.rect())).min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())); + node.unwrap_or_else(|| panic!("no {label} beside the title bar")).click(); + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + a.run("view.commentsPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + let [comments, styles] = bars(&h)[..] else { panic!("two pane title bars") }; + assert!(comments.right() <= styles.left() + 1.0, "side by side"); + assert!(comments.left() > 720.0, "both on the right"); + // Default widths 290 and 250: Styles, opened first, is the outer one. + assert!((comments.width() - styles.width() - 40.0).abs() < 2.0, "Styles is outermost: {comments:?} {styles:?}"); + + click_button(&h, styles, "Close"); + h.run_steps(3); + assert!(!h.state().session.view.styles_pane, "× ran view.stylesPane"); + assert!(h.state().session.view.comments_pane); + assert_eq!(bars(&h).len(), 1); + + h.state_mut().run("view.stylesPane", json!({"value": true})).unwrap(); + h.run_steps(3); + let styles = *bars(&h).last().unwrap(); + click_button(&h, styles, "Pane Options"); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(h.state().session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + let rect = h.state().ui.styles_pane_float.expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = app(); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert_eq!(next.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert_eq!(next.ui.styles_pane_float, Some(rect)); + + let mut junk = app(); + junk.apply_prefs(serde_json::from_str(r#"{"stylesPaneDock": "top", "stylesPaneFloat": [1, "x", 3, 4], "navPane": true}"#).unwrap()); + assert_eq!(junk.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Right, "junk reads as the default"); + assert_eq!(junk.ui.styles_pane_float, None); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + } + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); @@ -7890,13 +7975,14 @@ mod tests { h.step(); assert!(h.state().session.view.focus_mode); assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); - assert!(!shown(&h, "Navigation"), "and so is the Navigation pane"); + // The pane's title bar: the only pane open is Navigation. + assert!(!shown(&h, "Move Pane"), "and so is the Navigation pane"); h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); assert!(shown(&h, "Ruler"), "and the ribbon is back"); - assert!(shown(&h, "Navigation"), "with the pane"); + assert!(shown(&h, "Move Pane"), "with the pane"); // Immersive Reader is Read Mode, not Focus: its status bar stays. h.state_mut().run("view.immersive", json!({})).unwrap(); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c600daaea5..673a472e12 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,116 +1,198 @@ -//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, +//! Task panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, //! Accessibility and Alt Text (see `a11y_pane`), Restrict Editing ([`crate::dialogs_review`]), Comments, -//! and the SmartArt Text Pane. +//! and the SmartArt Text Pane. `craft_ui::panes` draws them around the document, with their title +//! bars, menus and resize edges; the session's view flags say which are open. use std::collections::BTreeMap; +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy}; use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; use wordcraft_doc::{Pos, StoryRef}; +use wordcraft_engine::StylesPaneDock; use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -pub fn show(app: &mut WordApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - // SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. - if app.session.view.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() { - egui::Panel::left("smart_art_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Text Pane") { - let _ = app.run("smartArt.textPane", json!({"value": false})); - return; - } - crate::dialogs_smart_art::text_pane(app, ui); - }); +/// The task panes, in the order they open: each edge's first pane is outermost. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pane { + /// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. + SmartArtText, + Navigation, + Clipboard, + Styles, + StyleInspector, + Accessibility, + AltText, + RestrictEditing, + Comments, +} + +impl Pane { + fn title(self) -> &'static str { + match self { + Self::SmartArtText => "Text Pane", + Self::Navigation => "Navigation", + Self::Clipboard => "Clipboard", + Self::Styles => "Styles", + Self::StyleInspector => "Style Inspector", + Self::Accessibility => "Accessibility", + Self::AltText => "Alt Text", + Self::RestrictEditing => "Restrict Editing", + Self::Comments => "Comments", + } } - if app.session.view.nav_pane { - egui::Panel::left("nav_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| nav(app, ui)); + + fn default_state(self) -> PaneState { + let (edge, width) = match self { + Self::SmartArtText => (Edge::Start, 250.0), + Self::Navigation => (Edge::Start, 260.0), + Self::Clipboard => (Edge::Start, 250.0), + Self::Styles => (Edge::End, 250.0), + Self::StyleInspector => (Edge::End, 240.0), + Self::Accessibility => (Edge::End, 280.0), + Self::AltText => (Edge::End, 260.0), + Self::RestrictEditing => (Edge::End, 270.0), + Self::Comments => (Edge::End, 290.0), + }; + PaneState::docked(edge, width) } - if app.session.view.clipboard_pane { - egui::Panel::left("clipboard_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| clipboard(app, ui)); - } - if app.session.view.styles_pane { - egui::Panel::right("styles_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| styles(app, ui)); - } - if app.session.view.style_inspector { - egui::Panel::right("style_inspector") - .default_size(240.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| inspector(app, ui)); - } - if app.session.view.accessibility_pane { - egui::Panel::right("accessibility_pane") - .default_size(280.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::accessibility(app, ui)); - } - if app.session.view.alt_text_pane { - egui::Panel::right("alt_text_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::alt_text(app, ui)); - } - if app.restrict.open { - egui::Panel::right("restrict_pane") - .default_size(270.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Restrict Editing") { - app.restrict.open = false; - return; - } - crate::dialogs_review::restrict_pane(app, ui); - }); - } - if app.session.view.comments_pane { - egui::Panel::right("comments_pane") - .default_size(290.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| comments(app, ui)); + + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. + fn close_command(self) -> Option<&'static str> { + match self { + Self::SmartArtText => Some("smartArt.textPane"), + Self::Navigation => Some("view.navigationPane"), + Self::Clipboard => Some("edit.clipboardPane"), + Self::Styles => Some("view.stylesPane"), + Self::StyleInspector => Some("styles.inspector"), + Self::Accessibility => Some("view.accessibilityPane"), + Self::AltText => Some("view.altTextPane"), + Self::Comments => Some("view.commentsPane"), + Self::RestrictEditing => None, + } } } -pub(crate) fn header(ui: &mut Ui, title: &str) -> bool { - let mut close = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Drawn, not the "✕" character: the interface fonts have no glyph for it. - if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { - close = true; - } - }); - }); - ui.add_space(4.0); - close +/// The panes open now, from the session's view flags. +pub fn open(app: &WordApp) -> Vec { + let v = &app.session.view; + let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some(); + [ + (Pane::SmartArtText, smart_art), + (Pane::Navigation, v.nav_pane), + (Pane::Clipboard, v.clipboard_pane), + (Pane::Styles, v.styles_pane), + (Pane::StyleInspector, v.style_inspector), + (Pane::Accessibility, v.accessibility_pane), + (Pane::AltText, v.alt_text_pane), + (Pane::RestrictEditing, app.restrict.open), + (Pane::Comments, v.comments_pane), + ] + .into_iter() + .filter_map(|(pane, shown)| shown.then_some(pane)) + .collect() +} + +/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). +/// Other panes keep theirs in the host for the session. +fn styles_state(app: &WordApp) -> PaneState { + let place = match app.session.view.styles_pane_dock { + StylesPaneDock::Right => Place::Docked(Edge::End), + StylesPaneDock::Left => Place::Docked(Edge::Start), + StylesPaneDock::Floating => Place::Floating, + }; + PaneState { place, size: app.ui.styles_pane_width.or(Pane::Styles.default_state().size), float_rect: app.ui.styles_pane_float } +} + +fn labels() -> PaneLabels { + PaneLabels { + menu: tl!("Pane Options").into(), + move_start: tl!("Dock Left").into(), + move_end: tl!("Dock Right").into(), + float: tl!("Float").into(), + close: tl!("Close").into(), + move_pane: tl!("Move Pane").into(), + resize: tl!("Resize Pane").into(), + ..PaneLabels::default() + } +} + +struct Content<'a> { + app: &'a mut WordApp, +} + +impl PaneContent for Content<'_> { + fn title(&self, pane: &Pane) -> String { + tl!(pane.title()).into() + } + + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + let app = &mut *self.app; + match pane { + Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui), + Pane::Navigation => nav(app, ui), + Pane::Clipboard => clipboard(app, ui), + Pane::Styles => styles(app, ui), + Pane::StyleInspector => inspector(app, ui), + Pane::Accessibility => crate::a11y_pane::accessibility(app, ui), + Pane::AltText => crate::a11y_pane::alt_text(app, ui), + Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), + Pane::Comments => comments(app, ui), + } + } + + fn default_state(&self, pane: &Pane) -> PaneState { + pane.default_state() + } +} + +/// Draw the open panes around the document. Call before the canvas's `CentralPanel`. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let open = open(app); + let styles = styles_state(app); + let out = PaneHost::new("task_panes") + .policy(SidePolicy::Columns) + .labels(labels()) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) + .body_margin(egui::Margin::same(10)) + .show(ui, &open, |pane| (*pane == Pane::Styles).then_some(styles), &mut Content { app }); + for request in out.requests { + apply(app, request); + } +} + +/// Run a request from the pane host through the pane's commands and the UI preferences. +pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { + match request { + PaneRequest::Close(pane) => close(app, pane), + PaneRequest::Place(Pane::Styles, place) => { + let dock = match place { + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", + Place::Docked(Edge::Bottom) => return, + }; + let _ = app.run("view.stylesPaneDock", json!({"dock": dock})); + } + PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width), + PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), + PaneRequest::Place(..) | PaneRequest::Resize(..) | PaneRequest::FloatRect(..) | PaneRequest::Focus(_) => {} + } +} + +fn close(app: &mut WordApp, pane: Pane) { + match pane.close_command() { + Some(id) => { + let _ = app.run(id, json!({"value": false})); + } + None => app.restrict.open = false, + } } fn nav(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Navigation") { - let _ = app.run("view.navigationPane", json!({"value": false})); - return; - } let qid = egui::Id::new("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). @@ -234,10 +316,6 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool) -> egui::Resp /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Clipboard") { - let _ = app.run("edit.clipboardPane", json!({"value": false})); - return; - } let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); ui.horizontal(|ui| { if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { @@ -280,10 +358,6 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) { } fn styles(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Styles") { - let _ = app.run("view.stylesPane", json!({"value": false})); - return; - } let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string(); let current_name = app @@ -294,7 +368,8 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { .map(crate::i18n::style_name) .unwrap_or_else(|| st.get("styleName").and_then(Value::as_str).unwrap_or("").to_string()); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", ¤t_name)])).small()); - ui.horizontal(|ui| { + // Wrapped: the host gives the pane a fixed width, and the row is wider than its default. + ui.horizontal_wrapped(|ui| { if ui.button(tl!("New Style…")).clicked() { app.dialog = crate::dialogs::Dialog::open("newStyle", app); } @@ -355,10 +430,6 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { } fn comments(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Comments") { - let _ = app.run("view.commentsPane", json!({"value": false})); - return; - } if crate::widgets::icon_text_button(ui, "newComment", tl!("New Comment"), regular(12.5)).clicked() { let _ = app.run("review.newComment", json!({"text": ""})); } @@ -590,10 +661,6 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { /// Style Inspector: the paragraph and character levels at the caret, each with its style and the /// direct formatting on top, and a button to clear each level. fn inspector(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Style Inspector") { - let _ = app.run("styles.inspector", json!({"value": false})); - return; - } // Read-only, so call it directly: running the command every frame would pay for the command // machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane. let r = wordcraft_engine::cmd::inspector::inspect(&app.session); From af9b00dd4c5c70f74814f726a8bf6e6098121244 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:44:47 +0000 Subject: [PATCH 02/87] Task panes: follow craft-ui panes at f4f9e5b (#1756) craft-ui's pane host names each title bar after its pane, so the accessible name is "Move Pane: {title}" ("Move Pane: Styles") in all 22 catalogs. The pane tests find the title bars by that name. --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/et.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr-latn.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/lib.rs | 6 +++--- crates/ui-egui/src/panes.rs | 2 +- 26 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ded460c881..278aea4671 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" +source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d7762b82c0..46e53d3c05 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f62fcc6591..5ab7df8e8f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2693,5 +2693,5 @@ Dock Right Закачане вдясно Float Откачане Pane Options Опции на панела - Move Pane Преместване на панела + Move Pane: {title} Преместване на панела: {title} Resize Pane Преоразмеряване на панела diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 280765cc72..668a43062a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2696,5 +2696,5 @@ preview Title Ukázka Dock Right Ukotvit vpravo Float Plovoucí Pane Options Možnosti podokna - Move Pane Přesunout podokno + Move Pane: {title} Přesunout podokno: {title} Resize Pane Změnit velikost podokna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b75c438cfb..74aa12c6fc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2293,5 +2293,5 @@ Dock Right Rechts andocken Float Unverankert Pane Options Bereichsoptionen - Move Pane Bereich verschieben + Move Pane: {title} Bereich verschieben: {title} Resize Pane Bereichsgröße ändern diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6e0e94e026..a8bd706910 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2660,5 +2660,5 @@ preview Title Título Dock Right Acoplar a la derecha Float Flotante Pane Options Opciones del panel - Move Pane Mover panel + Move Pane: {title} Mover panel: {title} Resize Pane Cambiar tamaño del panel diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index aeec5279f9..cc2c7b197f 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2328,5 +2328,5 @@ Dock Right Dokki paremale Float Ujuv Pane Options Paani suvandid - Move Pane Teisalda paan + Move Pane: {title} Teisalda paan: {title} Resize Pane Muuda paani suurust diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f4d289be76..ea6b5f349c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2706,5 +2706,5 @@ template Letter Lettre Dock Right Ancrer à droite Float Flottant Pane Options Options du volet - Move Pane Déplacer le volet + Move Pane: {title} Déplacer le volet : {title} Resize Pane Redimensionner le volet diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 29ebe34792..baa0812bfe 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2704,5 +2704,5 @@ preview Title Naslov Dock Right Usidri desno Float Plutajuće Pane Options Mogućnosti okna - Move Pane Premjesti okno + Move Pane: {title} Premjesti okno: {title} Resize Pane Promijeni veličinu okna diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c15b031f7b..7293128cad 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2700,5 +2700,5 @@ Dock Right Rögzítés jobbra Float Lebegtetés Pane Options Panel beállításai - Move Pane Panel áthelyezése + Move Pane: {title} Panel áthelyezése: {title} Resize Pane Panel átméretezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a85cf1d4ed..2f368d017f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2713,5 +2713,5 @@ preview Title Esempio Dock Right Ancora a destra Float Mobile Pane Options Opzioni riquadro - Move Pane Sposta riquadro + Move Pane: {title} Sposta riquadro: {title} Resize Pane Ridimensiona riquadro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 00cd791af4..762833bece 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2294,5 +2294,5 @@ Dock Right 右にドッキング Float フローティング Pane Options ウィンドウ オプション - Move Pane ウィンドウの移動 + Move Pane: {title} ウィンドウの移動: {title} Resize Pane ウィンドウのサイズ変更 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 49b47bd027..fd438a8d4f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2548,5 +2548,5 @@ preview Title 제목 Dock Right 오른쪽에 고정 Float 떠 있음 Pane Options 창 옵션 - Move Pane 창 이동 + Move Pane: {title} 창 이동: {title} Resize Pane 창 크기 조정 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f2be90935a..f6df33d12b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2709,5 +2709,5 @@ preview Title Tytuł Dock Right Zadokuj po prawej Float Pływające Pane Options Opcje okienka - Move Pane Przenieś okienko + Move Pane: {title} Przenieś okienko: {title} Resize Pane Zmień rozmiar okienka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dd6f449c4f..5b5f04e5c9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2306,5 +2306,5 @@ Dock Right Encaixar à direita Float Flutuante Pane Options Opções do painel - Move Pane Mover painel + Move Pane: {title} Mover painel: {title} Resize Pane Redimensionar painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e00416600..2f1e0771bd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2702,5 +2702,5 @@ preview Title Пример Dock Right Закрепить справа Float Открепить Pane Options Параметры области - Move Pane Переместить область + Move Pane: {title} Переместить область: {title} Resize Pane Изменить размер области diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 3a7fe5f91f..58679b7131 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2304,5 +2304,5 @@ Dock Right Usidri desno Float Plutajuće Pane Options Opcije okna - Move Pane Premesti okno + Move Pane: {title} Premesti okno: {title} Resize Pane Promeni veličinu okna diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7e99069f32..cd3104a979 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2308,5 +2308,5 @@ Dock Right Усидри десно Float Плутајуће Pane Options Опције окна - Move Pane Премести окно + Move Pane: {title} Премести окно: {title} Resize Pane Промени величину окна diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 88d593509d..50f8695fee 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2719,5 +2719,5 @@ preview Title Titel Dock Right Docka till höger Float Flytande Pane Options Panelalternativ - Move Pane Flytta panel + Move Pane: {title} Flytta panel: {title} Resize Pane Ändra storlek på panel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 971f4914de..670e1c7bcf 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2701,5 +2701,5 @@ preview Title Örnek Dock Right Sağa Yerleştir Float Serbest Bırak Pane Options Bölme Seçenekleri - Move Pane Bölmeyi Taşı + Move Pane: {title} Bölmeyi Taşı: {title} Resize Pane Bölmeyi Yeniden Boyutlandır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 01895fb26e..e509988b3f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2306,5 +2306,5 @@ Dock Right Закріпити праворуч Float Відкріпити Pane Options Параметри панелі - Move Pane Перемістити панель + Move Pane: {title} Перемістити панель: {title} Resize Pane Змінити розмір панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2b5ba02cec..0a8adfb9ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2744,5 +2744,5 @@ template Letter Thư Dock Right Neo bên phải Float Nổi Pane Options Tùy chọn khung - Move Pane Di chuyển khung + Move Pane: {title} Di chuyển khung: {title} Resize Pane Đổi kích thước khung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ae23a8e6ab..921426227b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2306,5 +2306,5 @@ Dock Right 停靠在右侧 Float 浮动 Pane Options 窗格选项 - Move Pane 移动窗格 + Move Pane: {title} 移动窗格:{title} Resize Pane 调整窗格大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index de6f923688..83283ac52b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2295,5 +2295,5 @@ Dock Right 停駐在右側 Float 浮動 Pane Options 窗格選項 - Move Pane 移動窗格 + Move Pane: {title} 移動窗格:{title} Resize Pane 調整窗格大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e8f1463f0..4fd11cd0e2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6648,7 +6648,7 @@ mod tests { fn task_panes_share_an_edge_close_and_keep_the_styles_place() { use egui_kittest::kittest::Queryable; let bars = |h: &egui_kittest::Harness<'_, WordApp>| { - let mut bars: Vec = h.get_all_by_label("Move Pane").map(|n| n.rect()).collect(); + let mut bars: Vec = h.get_all_by_label_contains("Move Pane: ").map(|n| n.rect()).collect(); bars.sort_by(|a, b| a.left().total_cmp(&b.left())); bars }; @@ -7976,13 +7976,13 @@ mod tests { assert!(h.state().session.view.focus_mode); assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); // The pane's title bar: the only pane open is Navigation. - assert!(!shown(&h, "Move Pane"), "and so is the Navigation pane"); + assert!(!shown(&h, "Move Pane: Navigation"), "and so is the Navigation pane"); h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); assert!(shown(&h, "Ruler"), "and the ribbon is back"); - assert!(shown(&h, "Move Pane"), "with the pane"); + assert!(shown(&h, "Move Pane: Navigation"), "with the pane"); // Immersive Reader is Read Mode, not Focus: its status bar stays. h.state_mut().run("view.immersive", json!({})).unwrap(); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 673a472e12..9df8c9eb64 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -113,7 +113,7 @@ fn labels() -> PaneLabels { move_end: tl!("Dock Right").into(), float: tl!("Float").into(), close: tl!("Close").into(), - move_pane: tl!("Move Pane").into(), + move_pane: tl!("Move Pane: {title}").into(), resize: tl!("Resize Pane").into(), ..PaneLabels::default() } From 0802788da9f0dd9464c06191139d934efc903ba9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:46:51 +0000 Subject: [PATCH 03/87] Task panes: follow craft-ui panes at 787b706 (#1756) --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 278aea4671..1db4891f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" +source = "git+https://github.com/aiongg/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 46e53d3c05..123e79a6f7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From e4804235c5e484f5f900f8db8e6e629c995cebd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:01:32 +0000 Subject: [PATCH 04/87] Task panes: F6 past the last pane gives the document the keyboard (#1756) --- crates/ui-egui/src/panes.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 9df8c9eb64..6179929eae 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -162,6 +162,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { for request in out.requests { apply(app, request); } + if out.to_document { + app.canvas.want_focus = true; + } } /// Run a request from the pane host through the pane's commands and the UI preferences. @@ -938,6 +941,37 @@ mod tests { assert!(h.state().canvas.focused, "Delete returns focus to the document"); assert!(h.state().session.clip_history.items().is_empty(), "the item was deleted"); } + + /// F6 past the last pane gives the document the keyboard, and typing reaches it (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_past_the_last_pane_types_into_the_document() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("edit.clipboardPane", serde_json::json!({"value": true})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas.focused); + let f6 = egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for pane in ["Navigation", "Clipboard"] { + h.input_mut().events.push(f6.clone()); + h.run_steps(2); + assert!(!h.state().canvas.focused, "F6 moved the keyboard to the {pane} pane"); + } + h.input_mut().events.push(f6); + h.run_steps(2); + assert!(h.state().canvas.focused, "F6 past the last pane returns to the document"); + h.input_mut().events.push(egui::Event::Text("typed".into())); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); + } } #[cfg(test)] From 080b05a0fddab85e15b662d841ca73fc6ffc1783 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:10:59 +0000 Subject: [PATCH 05/87] Task panes: follow craft-ui panes at 352a830 (#1756) craft-ui 352a830 is the head of storytold/craft-libs#187. The panes API WordCraft uses is unchanged. A floating pane also resizes from its top edge and top corners, through a 4 pt band above the title bar. --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1db4891f70..6e243bb7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" +source = "git+https://github.com/aiongg/craft-libs?rev=352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb#352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 123e79a6f7..ed88b4a143 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 023cda955429b618579a030118cb49629db3bd87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:10:59 +0000 Subject: [PATCH 06/87] Task panes: shortcuts run right after a pane resize (#1756) A pointer drag on a pane's resize edge or a floating pane's grip leaves no keyboard focus behind, so global Ctrl and F-key shortcuts, which defer to any focused widget, run right after it. The test also covers an edge focused from the keyboard: arrow keys move it and Escape leaves it. --- crates/ui-egui/src/lib.rs | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f4047a11b9..716f94eef1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6725,6 +6725,69 @@ mod tests { assert!(junk.session.view.nav_pane, "the rest of the file still loads"); } + /// A pointer drag on a pane's resize edge leaves no keyboard focus behind, so Ctrl and + /// F-key shortcuts run right after it, docked or floating. A resize edge focused from the + /// keyboard takes the arrow keys until Escape (#1756). + #[test] + fn shortcuts_run_after_a_pane_resize() { + use egui_kittest::kittest::Queryable; + type H = egui_kittest::Harness<'static, WordApp>; + fn key(h: &mut H, key: egui::Key, modifiers: egui::Modifiers) { + h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + fn drag(h: &mut H, from: egui::Pos2, by: egui::Vec2) { + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(button(from, true)); + h.step(); + for t in [0.5, 1.0] { + h.event(egui::Event::PointerMoved(from + by * t)); + h.step(); + } + h.event(button(from + by, false)); + h.run_steps(2); + } + // Ctrl+H opens Replace; Escape closes it again. + fn replace_opens(h: &mut H) -> bool { + key(h, egui::Key::H, egui::Modifiers::COMMAND); + let opened = h.state().dialog.as_ref().map(|d| d.name()) == Some("replace"); + key(h, egui::Key::Escape, egui::Modifiers::NONE); + opened && h.state().dialog.is_none() + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + + let edge = h.get_by_label("Resize Pane").rect(); + // On the pane's side of the edge: the document's scroll bar lies under its outer half. + drag(&mut h, edge.center() + egui::vec2(1.5, 0.0), egui::vec2(-40.0, 0.0)); + let width = h.state().ui.styles_pane_width.unwrap_or_default(); + assert!((width - 290.0).abs() <= 2.0, "the drag widened the docked pane: {width}"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the drag"); + + h.get_by_label("Resize Pane").focus(); + h.run_steps(2); + key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.styles_pane_width.is_some_and(|w| w > width), "arrow keys move the focused edge"); + key(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "Escape leaves the edge"); + assert!(replace_opens(&mut h), "Ctrl+H runs after Escape"); + + h.state_mut().run("view.stylesPaneDock", json!({"dock": "float"})).unwrap(); + h.run_steps(3); + // The bottom-right grip. + let corner = |r: &egui::Rect| r.max.x + r.max.y; + let grip = h.get_all_by_label("Resize Pane").map(|n| n.rect()).max_by(|a, b| corner(a).total_cmp(&corner(b))).unwrap(); + let before = h.state().ui.styles_pane_float; + drag(&mut h, grip.center(), egui::vec2(30.0, 30.0)); + assert_ne!(h.state().ui.styles_pane_float, before, "the drag resized the floating pane"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the floating pane's resize"); + } + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); From 08028c44c0f58d24b7857e6ba4d145635ce5d154 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:41:20 +0000 Subject: [PATCH 07/87] Engine: several open documents; file.new opens another One session holds several open documents. The active document stays in the Session's own fields, so commands and the UI keep working on it; the others are parked as DocState values and swapped in field by field (std::mem::swap, no clones or relayout). Session and DocState are destructured exhaustively, so a new field must be classified as per document or shared before it builds. - Session::documents, activate, add_document, close_document, is_untouched_blank, with_document, find_open, is_open; DocInfo. - Document ids come from a process-wide counter. - file.new opens beside the open documents; file.open replaces an untouched blank document, opens beside the others otherwise, and activates a file that is already open ("alreadyOpen": true). - file.closeDocument {id?, force?} closes a document without a window (refuses unsaved changes unless forced); file.close is unchanged. - view.switchWindows {id?} lists the open documents or activates one. - Envelopes, Labels, Finish & Merge and New from Template open their result as a new document; the main document stays open, with no undo step for the command. - Each document keeps its own recovery worker and copy; Read Aloud readings name their document; with_document tags UI requests with the document they came from. --- ROADMAP.md | 4 +- crates/engine/src/cmd/file.rs | 52 ++- crates/engine/src/cmd/mailings.rs | 10 +- crates/engine/src/cmd/speech.rs | 8 +- crates/engine/src/cmd/tools.rs | 3 +- crates/engine/src/cmd/view.rs | 12 + crates/engine/src/lib.rs | 5 +- crates/engine/src/recovery/desktop.rs | 62 ++- crates/engine/src/session.rs | 271 +++++++++--- crates/engine/src/session/documents.rs | 566 +++++++++++++++++++++++++ crates/engine/src/speech.rs | 4 + crates/engine/src/tests.rs | 15 +- crates/engine/src/tests_documents.rs | 255 +++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- 15 files changed, 1180 insertions(+), 97 deletions(-) create mode 100644 crates/engine/src/session/documents.rs create mode 100644 crates/engine/src/tests_documents.rs diff --git a/ROADMAP.md b/ROADMAP.md index ef0e2222ac..731d388e51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -572,6 +573,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7b21994e2f..a89bbedf10 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -129,8 +129,11 @@ pub fn specs() -> Vec { sel_result(s) }) .key("Mod+W / Mod+F4") - .params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#) + .params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask; file.closeDocument closes a document there)}"#) .pure(), + CommandSpec::new("file.closeDocument", "Close Document", "Agents", close_document) + .params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#) + .pure(), CommandSpec::new("file.properties", "Properties", "File › Info", properties) .params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#), CommandSpec::new("file.info", "Info", "File", info).pure(), @@ -192,7 +195,8 @@ fn privacy(s: &Session) -> Value { json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time}) } -/// Create a blank document in the saved proofing language, or use the requested template. +/// Open a new document beside the open ones and make it active: a blank one in the saved +/// proofing language, or the requested template. fn new(s: &mut Session, v: &Value) -> CmdResult { let doc = match p::str(v, "template").unwrap_or("blank") { "sample" => crate::sample::sample_document(), @@ -201,14 +205,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult { "report" => crate::sample::report(), _ => normal_document(s), }; - s.set_document(doc); - s.path = None; + s.add_document(doc); sel_result(s) } /// A blank document on the Normal template: the region's paper (#838), written in the editing /// language (the templates' text is English). -fn normal_document(s: &Session) -> Document { +pub(crate) fn normal_document(s: &Session) -> Document { let mut d = s.blank_document(); if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) { d.styles.default_chr.lang = Some(lang.clone()); @@ -216,12 +219,22 @@ fn normal_document(s: &Session) -> Document { d } -/// Open a document, using the saved proofing default only for plain text and Markdown. +/// Open a document, using the saved proofing default only for plain text and Markdown. A file +/// that is already open becomes the active document instead (`"alreadyOpen": true`). The file +/// replaces an untouched blank document; otherwise it opens beside the open documents. fn open(s: &mut Session, v: &Value) -> CmdResult { let Some(path) = p::str(v, "path") else { s.ui_requests.push(json!({"open": "openFile"})); return sel_result(s); }; + if p::str(v, "data").is_none() + && let Some(id) = s.find_open(std::path::Path::new(path)) + { + s.activate(id)?; + return Ok( + json!({"path": s.path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": s.password.is_some(), "alreadyOpen": true, "id": id}), + ); + } let password = p::str(v, "password"); let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; @@ -244,7 +257,11 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { { doc.styles.default_chr.lang = Some(lang.clone()); } - s.set_document(doc); + if s.is_untouched_blank() { + s.set_document(doc); + } else { + s.add_opened_document(doc); + } if let Some(z) = s.doc.settings.zoom { super::view::restore_zoom(s, z); } @@ -253,7 +270,26 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { if encrypted { s.password = password.map(crate::io::Password::new); } - Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted})) + Ok( + json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted, "alreadyOpen": false, "id": s.document_id()}), + ) +} + +/// Close a document with no window to ask about it (agents, headless sessions). +fn close_document(s: &mut Session, v: &Value) -> CmdResult { + let id = match v.get("id").filter(|x| !x.is_null()) { + None => s.document_id(), + Some(x) => x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?, + }; + if !s.is_open(id) { + return Err(CmdError::Params(format!("no open document has id {id}"))); + } + let dirty = s.documents().iter().any(|d| d.id == id && d.dirty); + if dirty && p::bool(v, "force") != Some(true) { + return Err(CmdError::Failed("the document has unsaved changes: save it first, or pass \"force\": true to discard them".into())); + } + s.close_document(id)?; + Ok(json!({"closed": id, "active": s.document_id(), "documents": s.documents()})) } /// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index eebbc162d4..e1373ca35e 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -1036,10 +1036,8 @@ fn finish(s: &mut Session, v: &Value) -> CmdResult { crate::io::save_path(std::path::Path::new(path), &out).map_err(CmdError::Failed)?; return Ok(json!({"records": count, "path": path})); } - s.set_document(out); - s.path = None; + s.add_document(out); s.dirty = true; - s.merge.preview = false; Ok(json!({"records": count})) } @@ -1190,8 +1188,7 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult { let mut d = Document::new(); d.last_section = sect; d.body = blocks; - s.set_document(d); - s.path = None; + s.add_document(d); Ok(json!({"width": w, "height": h})) } @@ -1423,8 +1420,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult { } } d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())]; - s.set_document(d); - s.path = None; + s.add_document(d); let _ = Pos::body(0, 0); Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id})) } diff --git a/crates/engine/src/cmd/speech.rs b/crates/engine/src/cmd/speech.rs index 651f6d6369..fac23ac830 100644 --- a/crates/engine/src/cmd/speech.rs +++ b/crates/engine/src/cmd/speech.rs @@ -35,7 +35,8 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult { if let Some(b) = p::bool(v, "skipCitations") { s.read_aloud.skip_citations = b; } - if s.read_aloud.state() != State::Stopped { + // A reading of this document pauses or carries on; one of another document gives way. + if s.read_aloud.state() != State::Stopped && s.read_aloud.document == Some(s.document_id()) { s.read_aloud.play_pause(); return status(s, v); } @@ -54,12 +55,13 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult { if queue.is_empty() { return Err(CmdError::Failed("there's nothing to read".into())); } + s.read_aloud.document = Some(s.document_id()); s.read_aloud.start(queue); status(s, v) } fn play_pause(s: &mut Session, v: &Value) -> CmdResult { - if s.read_aloud.progress().1 == 0 { + if s.read_aloud.progress().1 == 0 || s.read_aloud.document != Some(s.document_id()) { return read_aloud(s, v); } s.read_aloud.play_pause(); @@ -92,7 +94,7 @@ fn skip_citations(s: &mut Session, v: &Value) -> CmdResult { let on = p::bool(v, "value").unwrap_or(!s.read_aloud.skip_citations); if on != s.read_aloud.skip_citations { s.read_aloud.skip_citations = on; - if let Some(cur) = s.read_aloud.current() { + if let Some(cur) = s.read_aloud.current().filter(|_| s.read_aloud.document == Some(s.document_id())) { let paused = s.read_aloud.state() == State::Paused; let queue = speech::sentences(&s.doc, &cur.start, None, on); if !queue.is_empty() { diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 73668e3575..6da169cdd7 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -492,8 +492,7 @@ pub fn specs() -> Vec { let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; // As in Word, the new document is attached to the template it came from (#2852). doc.settings.attached_template = Some(path.to_string()); - s.set_document(doc); - s.path = None; + s.add_document(doc); s.dirty = true; sel_result(s) }) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 99ac8b5ece..96924cd11a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -125,6 +125,9 @@ pub fn specs() -> Vec { sel_result(s) }) .pure(), + CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows) + .params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#) + .pure(), CommandSpec::new("view.split", "Split", "View › Window", |s, _| { s.ui_requests.push(json!({"toggle": "split"})); sel_result(s) @@ -261,3 +264,12 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult { } Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit})) } + +/// View › Window › Switch Windows: the open documents, or make one of them active. +fn switch_windows(s: &mut Session, v: &Value) -> CmdResult { + if let Some(x) = v.get("id").filter(|x| !x.is_null()) { + let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?; + s.activate(id)?; + } + Ok(json!({"documents": s.documents()})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0158a6054f..b1aec1ce4d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,8 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale, + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, + blank_document, set_system_locale, }; pub use wordcraft_doc as doc; pub use wordcraft_geom::Region; @@ -170,6 +171,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_documents; +#[cfg(test)] mod tests_fonts; #[cfg(test)] mod tests_revision_checks; diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 738e20a2d5..92bbf7eee2 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -401,14 +401,24 @@ pub(crate) struct Recovery { revision: Option, due: Instant, alive: bool, + /// The profile folder and wakeup, for [`Recovery::sibling`]. + root: PathBuf, + wake: Arc, } impl Recovery { + /// Recovery for another open document: its own worker on the same profile folder, keeping + /// that document's copy as this one keeps its document's. + pub fn sibling(&self) -> Option { + Self::start(self.root.clone(), self.wake.clone()).map_err(|e| log::warn!("recovery: {e}")).ok() + } pub fn start(root: PathBuf, wake: Arc) -> Result { + let (worker_root, worker_wake) = (root.clone(), wake.clone()); let (tx, jobs) = mpsc::sync_channel(1); let (events, rx) = mpsc::channel(); std::thread::Builder::new() .name("wordcraft-recovery".into()) .spawn(move || { + let (root, wake) = (worker_root, worker_wake); let mut store = match Store::new(root) { Ok(s) => s, Err(e) => { @@ -452,6 +462,8 @@ impl Recovery { revision: None, due: Instant::now() + INTERVAL, alive: true, + root, + wake, }) } fn queue(&mut self, job: Job) -> Result<(), String> { @@ -1043,6 +1055,31 @@ mod tests { assert!(s.recovery_error().unwrap().contains("document changed")); } + /// A restore asked for in one document lands in that document, even when another document + /// is active while the copy loads. + #[test] + fn delayed_restore_lands_in_the_document_that_asked_for_it() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let first = s.document_id(); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + let second = s.document_id(); + s.run("text.insert", &json!({"text": "Other work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); + s.activate(first).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); + assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); + assert_eq!(s.documents().len(), 2); + } + #[test] fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { let t = Temp::new(); @@ -1073,16 +1110,39 @@ mod tests { assert_eq!(fs::read(original).unwrap(), bytes); } + /// Each open document keeps its own recovery copy: switching between documents doesn't start + /// a new copy of either. + #[test] + fn each_open_document_keeps_its_own_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + let first = s.document_id(); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + s.run("file.new", &json!({})).unwrap(); + settle(&mut s); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + s.activate(first).unwrap(); + s.run("text.insert", &json!({"text": ", edited"})).unwrap(); + save_now(&mut s); + let records = fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(); + assert_eq!(records, 2, "one copy per document"); + assert_eq!(s.recovery_documents().len(), 1, "the first document's own copy"); + } + #[test] fn protection_after_document_switch_does_not_discard_another_draft() { let t = Temp::new(); let mut s = session(&t.0); s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); save_now(&mut s); + let first = s.document_id(); s.run("file.new", &json!({})).unwrap(); // Protection can be commanded before the host's next recovery tick. s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); settle(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(s.with_document(first, |s| s.recovery_documents().len()).unwrap(), 1); + assert_eq!(fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(), 1, "the first draft is kept"); } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1b79dbbdd0..e0df7a7a7e 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -25,6 +25,10 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM use crate::{CmdError, Registry}; +mod documents; +pub use documents::{DocInfo, DocState}; +use documents::{document_id_mark, next_document_id, untitled_name}; + /// A selection: anchor (where it started) and focus (where the caret is). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Selection { @@ -359,7 +363,32 @@ impl EditSnapshot { } } -/// An editing session. +/// The layout last made and what it was made for: (revision, width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed). +/// What `run` keeps to take a failed mutating command back: the document, the selection, the +/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column +/// block. +type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); + +type LayoutEntry = ( + u64, + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, +); + +/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) +/// and the open documents. The active document's state is in the session's own fields, which +/// every command works on; the other open documents wait in [`DocState`] values and are swapped +/// in when they become active (see [`Session::documents`]). pub struct Session { pub doc: Document, pub sel: Selection, @@ -419,26 +448,16 @@ pub struct Session { /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). pub(crate) autocap: Option, rev: u64, - /// Which document this is: changes when [`Session::set_document`] replaces it (open, new, - /// mail merge, recover), never on an edit. + /// Which document this is, unique in the process: changes when [`Session::set_document`] + /// replaces it (an open into an untouched blank document, recover), never on an edit or when + /// another document becomes active and this one comes back. document_id: u64, + /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. + untitled: u32, + /// `rev` when the document was made, for [`Session::is_untouched_blank`]. + made_rev: u64, cache: LayoutCache, - /// (revision, width, read height, view, read line spacing, layout, proofing, outline, - /// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed). - #[allow(clippy::type_complexity)] - layout: Option<( - u64, - f32, - f32, - ViewMode, - Option, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - )>, + layout: Option, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). @@ -506,6 +525,12 @@ pub struct Session { edit_marks: Vec, /// How many Go Back presses since the last edit (which place comes next). go_back_step: usize, + /// The open documents other than the active one, in no particular order. + others: Vec, + /// Every open document's id, the active one's too, in the order they were opened. + order: Vec, + /// Untitled documents made so far, for the next one's name (`Document3`). + untitled_count: u32, } /// The number of edit locations Go Back cycles through (Word: four). @@ -658,72 +683,119 @@ impl Session { } pub fn new(doc: Document) -> Self { - let start = doc.start_of(StoryRef::Body); - let bib_style = doc_bib_style(&doc); + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = DocState::new(doc, 1, ViewState::default()); Session { doc, - sel: Selection::caret(start), - view: ViewState::default(), - pending: None, - path: None, - file_name: None, + sel, + view, + pending, + path, + file_name, user_dict_path: None, - dirty: false, + dirty, clipboard: None, - pending_move: None, + pending_move, clipboard_text: String::new(), clipboard_html: String::new(), clip_history: Default::default(), - find: FindState::default(), - goal_x: None, - page_hint: 0, + find, + goal_x, + page_hint, registry: Arc::new(crate::cmd::registry()), author: "WordCraft User".into(), initials: String::new(), painter: None, status: String::new(), - new_header_parts: Vec::new(), - hf_return: None, - history: Vec::new(), - undo_evicted: 0, - redo: Vec::new(), - typing_open: false, - join_next: false, - autocap: None, - rev: 1, - document_id: 1, - cache: LayoutCache::new(), - layout: None, - typing: None, - originals: Default::default(), - picture_looks: Default::default(), + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, last_command: None, recording: None, macros: Default::default(), - versions: Vec::new(), + versions, building_blocks: Default::default(), - autosave: true, + autosave, bib_style, - merge: Default::default(), + merge, ui_requests: Vec::new(), - math: None, + math, math_latex: false, math_normal_text: false, - also_selected: Vec::new(), + also_selected, prefs: Prefs::default(), system_region: Default::default(), keymap: crate::KeyMap::default(), ribbon: crate::RibbonLayout::default(), editing_lang: None, read_aloud: Default::default(), - password: None, + password, #[cfg(not(target_arch = "wasm32"))] - recovery: None, - column: None, - column_mode: false, - widened: None, - edit_marks: Vec::new(), - go_back_step: 0, + recovery, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, } } @@ -844,7 +916,7 @@ impl Session { /// The document's file name and full path for fields (`FILENAME`) and layout context. /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default - /// consistently to `("Document1", "Document1")`. Document metadata title is never used + /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used /// as a filename fallback (#3449). pub fn resolved_filenames(&self) -> (String, String) { if let Some(path) = &self.path { @@ -854,7 +926,8 @@ impl Session { } else if let Some(name) = &self.file_name { (name.clone(), name.clone()) } else { - ("Document1".to_string(), "Document1".to_string()) + let name = untitled_name(self.untitled); + (name.clone(), name) } } @@ -995,6 +1068,10 @@ impl Session { if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); self.rev = self.rev.wrapping_add(1); + // Not an edit: the document is as untouched as it was. + if self.made_rev == self.rev.wrapping_sub(1) { + self.made_rev = self.rev; + } self.relayout(); } } @@ -1197,7 +1274,11 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { self.autocap = None; - self.document_id = self.document_id.wrapping_add(1); + let replaced = self.document_id; + self.document_id = next_document_id(); + if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { + *at = self.document_id; + } self.bib_style = doc_bib_style(&doc); self.doc = doc; self.doc.ensure_nonempty(); @@ -1218,6 +1299,7 @@ impl Session { self.reset_history(); self.touch(); self.dirty = false; + self.made_rev = self.rev; self.relayout(); } @@ -1232,8 +1314,8 @@ impl Session { /// /// `restore` is exact when the commands in between only add undo steps (every mutating /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full - /// stack). Taking steps off the stack (`edit.undo`) or clearing it (`file.new`, - /// `file.open`) in between is not covered. + /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into + /// a blank document) or switching documents in between is not covered. /// /// The destructuring lists every field, so a new field does not build until someone decides /// whether a snapshot must cover it. @@ -1288,6 +1370,13 @@ impl Session { merge: _, ui_requests: _, document_id: _, + // The untitled name and when the document was made: not edits. + untitled: _, + made_rev: _, + // The other open documents: a snapshot covers the active one. + others: _, + order: _, + untitled_count: _, read_aloud: _, prefs: _, system_region: _, @@ -1382,6 +1471,9 @@ impl Session { /// control channel). Mutating commands are undoable; a failed command leaves the document as /// it was; a panic inside a command becomes an error. pub fn run(&mut self, id: &str, params: &Value) -> Result { + // The document the command starts in, and the first id a document it opens can have. + let home = self.document_id; + let mark = document_id_mark(); let join = std::mem::take(&mut self.join_next); let reg = self.registry.clone(); let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; @@ -1422,7 +1514,7 @@ impl Session { } // The undo stacks are snapshotted whole, not as a length: commands run nested commands, // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. - let before_doc = if spec.mutates { + let before_doc: Option = if spec.mutates { Some(( self.doc.clone(), self.sel.clone(), @@ -1468,6 +1560,9 @@ impl Session { Err(CmdError::Failed(format!("{id} panicked: {msg}"))) } }; + if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { + return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before); + } // Restrict Editing: outside the exceptions nothing may change. if result.is_ok() && guard @@ -1535,6 +1630,54 @@ impl Session { result } + /// The end of [`Session::run`] for a command that ended up in another document than it + /// started in (it opened, made, switched to or closed one) or that failed after opening one. + /// The checks that compare the document before and after the command don't apply. + /// Succeeded: the first document loses the undo step made for the command when its text is + /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is + /// active and as it was. + fn finish_elsewhere( + &mut self, + home: u64, + mark: u64, + result: Result, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + ) -> Result { + match &result { + Ok(_) => { + self.put_back_parked(home, before_doc); + self.clamp_selection(); + } + Err(e) => { + self.autocap = None; + self.prefs.default_language = default_language_before; + if self.is_open(home) { + let _ = self.switch_to(home); + let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); + for d in opened { + let _ = self.close_document(d); + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + self.undo_evicted = ev; + self.column = c; + } + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } + /// The paragraph path the caret is in. pub fn caret_path(&self) -> &Path { &self.sel.focus.path diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs new file mode 100644 index 0000000000..d27c791d68 --- /dev/null +++ b/crates/engine/src/session/documents.rs @@ -0,0 +1,566 @@ +//! Several open documents in one session. The active document lives in the session's own fields, +//! where every command works on it; the others wait as [`DocState`] values and are swapped in, +//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid +//! out again or thrown away, so a host may switch documents as often as it likes. + +use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; +use serde_json::json; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Pos, StoryRef}; +use wordcraft_layout::LayoutCache; + +use super::{ColumnBlock, FindState, LayoutEntry, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use crate::CmdError; + +/// Document ids are unique in the process, so an id an agent or a window holds never names +/// another document, even across sessions. +static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_document_id() -> u64 { + NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// The id the next document will get: every document made after this call has an id at least +/// this big. +pub(super) fn document_id_mark() -> u64 { + NEXT_DOCUMENT_ID.load(Ordering::Relaxed) +} + +/// An untitled document's name: `Document1`, `Document2`… +pub(super) fn untitled_name(n: u32) -> String { + format!("Document{}", n.max(1)) +} + +/// An open document that isn't the active one: every per-document field of [`Session`], waiting +/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick +/// Parts, the custom dictionary, Read Aloud…) is not here. +pub struct DocState { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) view: ViewState, + pub(super) pending: Option, + pub(super) path: Option, + pub(super) file_name: Option, + pub(super) dirty: bool, + pub(super) pending_move: Option, + pub(super) find: FindState, + pub(super) goal_x: Option, + pub(super) page_hint: usize, + pub(super) new_header_parts: Vec, + pub(super) hf_return: Option, + pub(super) history: Vec>, + pub(super) undo_evicted: u64, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) autocap: Option, + pub(super) rev: u64, + pub(super) document_id: u64, + pub(super) untitled: u32, + pub(super) made_rev: u64, + pub(super) cache: LayoutCache, + pub(super) layout: Option, + pub(super) typing: Option, + pub(super) originals: std::collections::HashMap, + pub(super) picture_looks: std::collections::HashMap, + pub(super) versions: Vec<(String, String, Document)>, + pub(super) autosave: bool, + pub(super) bib_style: String, + pub(super) merge: crate::cmd::mailings::MergeState, + pub(super) math: Option, + pub(super) password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(super) recovery: Option, + pub(super) also_selected: Vec, + pub(super) column: Option, + pub(super) column_mode: bool, + pub(super) widened: Option, + pub(super) edit_marks: Vec, + pub(super) go_back_step: usize, +} + +impl DocState { + /// A fresh document state: caret at the start, no history, not dirty, a new id. + pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { + let start = doc.start_of(StoryRef::Body); + let bib_style = doc_bib_style(&doc); + DocState { + doc, + sel: Selection::caret(start), + view, + pending: None, + path: None, + file_name: None, + dirty: false, + pending_move: None, + find: FindState::default(), + goal_x: None, + page_hint: 0, + new_header_parts: Vec::new(), + hf_return: None, + history: Vec::new(), + undo_evicted: 0, + redo: Vec::new(), + typing_open: false, + join_next: false, + autocap: None, + rev: 1, + document_id: next_document_id(), + untitled, + made_rev: 1, + cache: LayoutCache::new(), + layout: None, + typing: None, + originals: Default::default(), + picture_looks: Default::default(), + versions: Vec::new(), + autosave: true, + bib_style, + merge: Default::default(), + math: None, + password: None, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, + also_selected: Vec::new(), + column: None, + column_mode: false, + widened: None, + edit_marks: Vec::new(), + go_back_step: 0, + } + } + + /// The document's id ([`Session::document_id`] while it is active). + pub fn id(&self) -> u64 { + self.document_id + } + + fn info(&self) -> DocInfo { + DocInfo { + id: self.document_id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: false, + } + } +} + +/// One open document, as [`Session::documents`] lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocInfo { + /// The document's id: unique in the process, kept while the document is open. + pub id: u64, + /// The name the title bar shows: the file's name without its extension, else the name given + /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; + /// a front end names untitled documents in its own language). + pub title: String, + pub path: Option, + /// Changed since it was opened or last saved. + pub dirty: bool, + /// The active document, the one commands work on. + pub active: bool, +} + +fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { + match (path, file_name) { + (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), + (None, Some(name)) => name.to_string(), + (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), + (None, None) => untitled_name(untitled), + } +} + +impl Session { + /// Swap the active document's state with `d`'s: `d` then holds the document that was active. + fn swap_doc(&mut self, d: &mut DocState) { + use std::mem::swap; + // Every field is listed, so a new one doesn't build until someone decides whether each + // open document has its own (then it goes in `DocState` and is swapped below) or the + // session shares it. + let Session { + // Per document. + doc: _, + sel: _, + view: _, + pending: _, + path: _, + file_name: _, + dirty: _, + pending_move: _, + find: _, + goal_x: _, + page_hint: _, + new_header_parts: _, + hf_return: _, + history: _, + undo_evicted: _, + redo: _, + typing_open: _, + join_next: _, + autocap: _, + rev: _, + document_id: _, + untitled: _, + made_rev: _, + cache: _, + layout: _, + typing: _, + originals: _, + picture_looks: _, + versions: _, + autosave: _, + bib_style: _, + merge: _, + math: _, + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + also_selected: _, + column: _, + column_mode: _, + widened: _, + edit_marks: _, + go_back_step: _, + // Shared by every open document. + user_dict_path: _, + clipboard: _, + clipboard_text: _, + clipboard_html: _, + clip_history: _, + registry: _, + author: _, + initials: _, + painter: _, + status: _, + last_command: _, + recording: _, + macros: _, + building_blocks: _, + ui_requests: _, + read_aloud: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + editing_lang: _, + math_latex: _, + math_normal_text: _, + others: _, + order: _, + untitled_count: _, + } = self; + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = d; + swap(&mut self.doc, doc); + swap(&mut self.sel, sel); + swap(&mut self.view, view); + swap(&mut self.pending, pending); + swap(&mut self.path, path); + swap(&mut self.file_name, file_name); + swap(&mut self.dirty, dirty); + swap(&mut self.pending_move, pending_move); + swap(&mut self.find, find); + swap(&mut self.goal_x, goal_x); + swap(&mut self.page_hint, page_hint); + swap(&mut self.new_header_parts, new_header_parts); + swap(&mut self.hf_return, hf_return); + swap(&mut self.history, history); + swap(&mut self.undo_evicted, undo_evicted); + swap(&mut self.redo, redo); + swap(&mut self.typing_open, typing_open); + swap(&mut self.join_next, join_next); + swap(&mut self.autocap, autocap); + swap(&mut self.rev, rev); + swap(&mut self.document_id, document_id); + swap(&mut self.untitled, untitled); + swap(&mut self.made_rev, made_rev); + swap(&mut self.cache, cache); + swap(&mut self.layout, layout); + swap(&mut self.typing, typing); + swap(&mut self.originals, originals); + swap(&mut self.picture_looks, picture_looks); + swap(&mut self.versions, versions); + swap(&mut self.autosave, autosave); + swap(&mut self.bib_style, bib_style); + swap(&mut self.merge, merge); + swap(&mut self.math, math); + swap(&mut self.password, password); + #[cfg(not(target_arch = "wasm32"))] + swap(&mut self.recovery, recovery); + swap(&mut self.also_selected, also_selected); + swap(&mut self.column, column); + swap(&mut self.column_mode, column_mode); + swap(&mut self.widened, widened); + swap(&mut self.edit_marks, edit_marks); + swap(&mut self.go_back_step, go_back_step); + } + + /// Make document `id` active without anything else a switch by the user does. + pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { + if id == self.document_id { + return Ok(()); + } + let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; + let mut d = self.others.swap_remove(at); + self.swap_doc(&mut d); + self.others.push(d); + Ok(()) + } + + /// Take parked document `id` out of the session; false when it isn't parked here. + fn remove_parked(&mut self, id: u64) -> bool { + let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; + self.others.swap_remove(at); + self.order.retain(|d| *d != id); + true + } + + /// Whether document `id` is open in this session (active or not). + pub fn is_open(&self, id: u64) -> bool { + id == self.document_id || self.others.iter().any(|d| d.document_id == id) + } + + /// Every open document in the order they were opened, the active one included. + pub fn documents(&self) -> Vec { + self.order + .iter() + .filter_map(|&id| { + if id == self.document_id { + Some(DocInfo { + id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: true, + }) + } else { + self.others.iter().find(|d| d.document_id == id).map(DocState::info) + } + }) + .collect() + } + + /// Make document `id` the active one, the one commands work on (nothing happens when it + /// already is). A Read Aloud reading of another document stops. An id that isn't open is an + /// error. + pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id)?; + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Open `doc` as another document and make it active; the one that was active stays open. + /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the + /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view + /// the active document had. Returns its id. + pub fn add_document(&mut self, doc: Document) -> u64 { + self.untitled_count = self.untitled_count.saturating_add(1); + self.add(doc, self.untitled_count) + } + + /// [`Session::add_document`] for a document opened from a file: it doesn't use up an + /// untitled name. + pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { + self.add(doc, 0) + } + + fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + doc.ensure_nonempty(); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut d = DocState::new(doc, untitled, view); + #[cfg(not(target_arch = "wasm32"))] + { + d.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); + } + let id = d.document_id; + self.order.push(id); + self.swap_doc(&mut d); + self.others.push(d); + self.stop_reading_elsewhere(); + id + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first). + /// Closing the active document makes the one opened before it active (else the one after). + /// Closing the last open document leaves a new blank one, so a session always has an + /// active document; what a window does then is the front end's choice. An id that isn't + /// open is an error. + pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { + if id != self.document_id { + if !self.remove_parked(id) { + return Err(unknown(id)); + } + return Ok(()); + } + let at = self.order.iter().position(|d| *d == id); + let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); + match next { + Some(n) => { + self.switch_to(n)?; + self.remove_parked(id); + } + None => { + let mut doc = crate::cmd::file::normal_document(self); + doc.ensure_nonempty(); + self.untitled_count = self.untitled_count.saturating_add(1); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut fresh = DocState::new(doc, self.untitled_count, view); + #[cfg(not(target_arch = "wasm32"))] + { + fresh.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); + } + self.order = vec![fresh.document_id]; + self.swap_doc(&mut fresh); + } + } + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Whether the active document is a blank one nobody has used: no file and no name, never + /// changed, nothing to undo or redo and no text. Opening a file replaces such a document + /// rather than opening beside it. + pub fn is_untouched_blank(&self) -> bool { + self.path.is_none() + && self.file_name.is_none() + && !self.dirty + && self.history.is_empty() + && self.redo.is_empty() + && self.rev == self.made_rev + && self.doc.paragraph_count() <= 1 + && self.doc.plain_text(StoryRef::Body).trim().is_empty() + } + + /// Run `f` with document `id` active, then make the document that was active before active + /// again (unless `f` closed it). For work addressed to a document other than the one a + /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. + /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the + /// document that was active. An id that isn't open is an error and `f` doesn't run. + pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { + let home = self.document_id; + self.switch_to(id)?; + let queued = self.ui_requests.len(); + let r = f(self); + if id != home { + for q in self.ui_requests.iter_mut().skip(queued) { + if let Some(o) = q.as_object_mut() { + o.entry("document").or_insert(json!(id)); + } + } + } + if self.document_id != home && self.is_open(home) { + self.switch_to(home)?; + } + Ok(r) + } + + /// The open document saved at `path`, if any: the same file reached by another spelling + /// (`./a.docx`, a symbolic link) counts on desktop. + pub fn find_open(&self, path: &FsPath) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let wanted = std::fs::canonicalize(path).ok(); + #[cfg(target_arch = "wasm32")] + let wanted: Option = None; + let same = |p: &FsPath| { + if p == path { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(w) = &wanted { + return std::fs::canonicalize(p).ok().as_ref() == Some(w); + } + let _ = &wanted; + false + }; + self.order.iter().copied().find(|&id| { + if id == self.document_id { + self.path.as_deref().is_some_and(same) + } else { + self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) + } + }) + } + + /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its + /// sentences in the document shown). + fn stop_reading_elsewhere(&mut self) { + if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { + self.read_aloud.stop(); + } + } + + /// Take the undo step `run` made for a command back out of the document it started in, when + /// the command ended up in another document (it opened or made one) and left the first + /// document's text as it was: opening a document is no edit to undo there. + pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { + let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; + if !self.is_open(id) { + return; + } + let _ = self.with_document(id, |s| { + if s.doc != doc { + return; + } + s.sel = sel; + s.history = history; + s.redo = redo; + s.typing_open = typing_open; + s.undo_evicted = evicted; + s.column = column; + }); + } +} + +fn unknown(id: u64) -> CmdError { + CmdError::Params(format!("no open document has id {id}")) +} diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 30d447f783..d06113d634 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -513,6 +513,9 @@ pub struct ReadAloud { pub skip_citations: bool, /// The player window is open (it stays open after the last sentence until closed). pub open: bool, + /// The document being read ([`crate::Session::document_id`]): the sentences' positions are + /// in it. + pub document: Option, } impl Default for ReadAloud { @@ -526,6 +529,7 @@ impl Default for ReadAloud { backend: if off { Backend::Hold } else { Backend::System }, skip_citations: true, open: false, + document: None, } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fda7aa5c09..b89836b917 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3829,10 +3829,10 @@ fn document_id_changes_only_when_the_document_is_replaced() { assert_ne!(s.document_id(), second); } -/// Envelopes, Labels and Finish & Merge make a new, untitled document, as Word does, and like New -/// its undo history starts afresh. Undo/Redo across the swap put the wrong document under a file -/// (Undo, Save As, Redo, Save wrote the envelope over the saved file), so it isn't offered; the UI -/// asks to save the replaced document first. +/// Envelopes, Labels and Finish & Merge open a new, untitled document beside the main one, as Word +/// does, and like New its undo history starts afresh. Undo/Redo across documents would put the +/// wrong document under a file (Undo, Save As, Redo, Save wrote the envelope over the saved file), +/// so it isn't offered. The main document stays open as it was, with no undo step for the command. #[test] fn mailings_results_are_new_untitled_documents() { let dir = std::env::temp_dir().join(format!("wordcraft-engine-mailings-{}", std::process::id())); @@ -3852,10 +3852,13 @@ fn mailings_results_are_new_untitled_documents() { let before = text(&s); let document = s.document_id(); + let undo_steps = s.undo_labels().len(); run(&mut s, id, json!({})); let result = text(&s); - assert_ne!(result, before, "{id}: the result replaced the document"); + assert_ne!(result, before, "{id}: the result is the active document"); assert_ne!(s.document_id(), document, "{id}: a different document"); + let main = s.with_document(document, |s| (text(s), s.undo_labels().len())).unwrap(); + assert_eq!(main, (before.clone(), undo_steps), "{id}: the main document stays open as it was"); assert_eq!(s.path, None, "{id}: the result is untitled"); assert_eq!(run(&mut s, "file.save", json!({}))["saved"], false, "{id}: Save asks where (Save As)"); assert_eq!(std::fs::read(&original).unwrap(), on_disk, "{id}: the original file is untouched"); @@ -4922,6 +4925,8 @@ fn one_page_is_restored_when_the_document_is_reopened() { // A percentage is restored as a percentage, the fit mode dropped. run(&mut other, "view.zoom", json!({"value": 80})); run(&mut other, "file.save", json!({})); + // Opening an open file only shows it: close it first to read it again. + run(&mut s, "file.closeDocument", json!({})); run(&mut s, "file.open", json!({"path": path})); assert!(s.view.fit.is_empty() && !s.view.single_column); assert!((s.view.zoom - 0.8).abs() < 1e-6); diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs new file mode 100644 index 0000000000..329c25c9d0 --- /dev/null +++ b/crates/engine/src/tests_documents.rs @@ -0,0 +1,255 @@ +//! Several open documents in one session: File › New and Open open beside the others, each +//! document keeps its own text, selection, undo and find state, and work addressed to a document +//! by id lands in that document whichever one is active. + +use serde_json::{Value, json}; +use wordcraft_doc::StoryRef; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +fn ids(s: &Session) -> Vec { + s.documents().iter().map(|d| d.id).collect() +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + assert_ne!(a, b); + assert_eq!(ids(&s), vec![a, b], "opening order"); + let docs = s.documents(); + assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); + assert!(docs[0].dirty && !docs[1].dirty); + assert_eq!(text(&s), "", "the new document is active"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + + s.activate(a).unwrap(); + assert_eq!(text(&s), "Alpha"); + assert_eq!(s.document_id(), a, "ids stay with their documents"); + s.activate(a).unwrap(); + s.activate(b).unwrap(); + assert_eq!(text(&s), "Beta"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves +/// B alone; Redo puts it back. Find state stays with its document too. +#[test] +fn undo_and_find_stay_with_their_document_across_switches() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + s.find.query = "Alpha".into(); + let b = s.add_document(wordcraft_doc::Document::new()); + assert_eq!(s.find.query, "", "a new document has its own find state"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + s.find.query = "Beta".into(); + + s.activate(a).unwrap(); + assert_eq!(s.find.query, "Alpha"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Alpha"); + s.activate(b).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { + let dir = temp_dir("open"); + let one = dir.join("one.txt"); + let two = dir.join("two.txt"); + std::fs::write(&one, "One").unwrap(); + std::fs::write(&two, "Two").unwrap(); + + let mut s = s(); + assert!(s.is_untouched_blank()); + let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(r["alreadyOpen"], false); + assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); + let first = s.document_id(); + assert_eq!(s.documents()[0].title, "one"); + + run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); + assert_eq!(s.documents().len(), 2, "a document with a file stays open"); + let second = s.document_id(); + assert_eq!(text(&s), "Two"); + + // The same file again, by another spelling: it becomes the active document. + let again = dir.join(".").join("one.txt"); + let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); + assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); + assert_eq!((s.document_id(), s.documents().len()), (first, 2)); + assert_eq!(s.find_open(&two), Some(second)); + + // A failed open changes nothing. + assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); + assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); + + // An edited blank document isn't replaced. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "text.backspace", json!({})); + assert!(!s.is_untouched_blank()); + run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(s.documents().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::from_text("B")); + let c = s.add_document(wordcraft_doc::Document::from_text("C")); + s.activate(b).unwrap(); + s.close_document(b).unwrap(); + assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); + s.close_document(c).unwrap(); + s.close_document(a).unwrap(); + assert_eq!(s.documents().len(), 1); + assert!(![a, b, c].contains(&s.document_id())); + assert!(s.is_untouched_blank()); + assert!(s.close_document(a).is_err(), "closed documents are gone"); + + // Closing the first makes the next one active. + let d = s.document_id(); + let e = s.add_document(wordcraft_doc::Document::new()); + s.activate(d).unwrap(); + s.close_document(d).unwrap(); + assert_eq!(s.document_id(), e); +} + +#[test] +fn close_document_refuses_unsaved_changes_unless_forced() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Draft"})); + let b = s.add_document(wordcraft_doc::Document::new()); + assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); + assert_eq!(ids(&s), vec![a, b], "still open"); + let r = run(&mut s, "file.closeDocument", json!({})); + assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); + let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); + assert!(s.is_untouched_blank()); + for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { + assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), 1); +} + +/// Work addressed to a document by id lands in it, whichever document is active, and the active +/// one is left as it was. +#[test] +fn with_document_works_on_the_given_document_and_keeps_the_active_one() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + let b = s.add_document(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Beta"})); + let (b_rev, b_undo) = (s.rev(), s.undo_depth()); + + let r = s.with_document(a, |s| { + s.run("text.insert", &json!({"text": " more"})).unwrap(); + s.run("edit.advancedFind", &json!({})).ok(); + s.document_id() + }); + assert_eq!(r.unwrap(), a); + assert_eq!(s.document_id(), b); + assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); + assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); + assert!(s.with_document(u64::MAX, |_| ()).is_err()); + + // Closing the active document inside leaves the one it ran on active. + s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); + assert_eq!(ids(&s), vec![a]); + assert_eq!(s.document_id(), a); +} + +/// A Read Aloud reading names its document: switching documents stops it, and turning citation +/// skipping on in another document doesn't restart it at the first document's positions there. +#[test] +fn read_aloud_belongs_to_its_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(a)); + let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); + assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); + run(&mut s, "readAloud.skipCitations", json!({"value": false})); + assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(b)); +} + +#[test] +fn switch_windows_lists_and_activates() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::new()); + let r = run(&mut s, "view.switchWindows", json!({})); + let list = r["documents"].as_array().unwrap(); + assert_eq!( + list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), + vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] + ); + assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); + run(&mut s, "view.switchWindows", json!({"id": a})); + assert_eq!(s.document_id(), a); + for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + assert_eq!(s.document_id(), a); + } + let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; + for id in ["view.switchWindows", "file.closeDocument", "file.new"] { + for j in &junk { + let _ = s.run(id, j); + s.clamp_selection(); + let _ = s.layout(); + } + } + assert!(!s.documents().is_empty()); +} + +#[test] +fn document_ids_are_unique_across_sessions_and_replacements() { + let mut one = s(); + let two = s(); + assert_ne!(one.document_id(), two.document_id()); + let before = one.document_id(); + one.set_document(wordcraft_doc::Document::new()); + assert_ne!(one.document_id(), before); + assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 09bbfc6197..3a96ca63b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -80,7 +80,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | +| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -125,6 +125,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) | | 2026-10-21 | trivial | #3172: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are to a screen reader | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 764c83f871..0b80be3426 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**498 of 513 catalog features have commands (97.1%).** +**499 of 513 catalog features have commands (97.3%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | References | 25 | 25 | | Mailings | 19 | 19 | | Review | 29 | 31 | -| View | 25 | 29 | +| View | 26 | 29 | | Developer | 11 | 11 | | Table Design | 8 | 8 | | Table Layout | 33 | 33 | @@ -60,7 +60,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Window › Arrange All (`view.arrangeAll`) - Window › View Side by Side (`view.sideBySide`) - Window › Synchronous Scrolling (`view.syncScroll`) -- Window › Switch Windows (`view.switchWindows`) ### Equation From a2ca57ca3fdb6f353b2a1c218e15c033eae45b7e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:53:21 +0000 Subject: [PATCH 08/87] W1: MCP, control channel and CLI address an open document by id - MCP: execute, batch, type_text, select_text, get_text, inspect_document, render_page and save_document take an optional doc (an open document's id); the headless server runs them through Session::with_document and the bridge passes doc to the app. New list_documents tool; new_document replies with the new document's id. Tool descriptions and the server instructions say that file.new and file.open open another document. - Control channel: engine.execute, document.inspect and ui.render take doc and work on that document while the window keeps the active one; file.close on an addressed document refuses unsaved changes unless forced; ui.* commands need the document on screen. ui.inspect reports document and documents. Unknown or malformed ids are errors. - CLI run starts with one open document; help and docs describe how file.new, file.open and view.switchWindows work in a run. - docs/mcp.md and docs/control-protocol.md describe document ids and doc. --- apps/wordcraft-cli/src/main.rs | 10 ++- apps/wordcraft-cli/tests/documents.rs | 39 ++++++++ crates/mcp/src/lib.rs | 75 +++++++++++----- crates/mcp/src/server.rs | 5 +- crates/mcp/src/tests.rs | 42 +++++++++ crates/mcp/src/tools.rs | 123 ++++++++++++++++++++------ crates/ui-egui/src/control.rs | 105 ++++++++++++++++++++-- crates/ui-egui/src/control_tests.rs | 122 +++++++++++++++++++++++++ docs/control-protocol.md | 36 ++++++-- docs/mcp.md | 30 ++++++- 10 files changed, 519 insertions(+), 68 deletions(-) create mode 100644 apps/wordcraft-cli/tests/documents.rs create mode 100644 crates/ui-egui/src/control_tests.rs diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 78e4bbfa1a..0517f57b72 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -36,7 +36,11 @@ USAGE: {\"file\"?, \"template\"?, \"commands\": [{\"command\": id, \"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save formats follow the extension (docx and pdf in one run); - relative paths resolve against the script's folder + relative paths resolve against the script's folder. + file.new and file.open open another document and + make it active; later commands and the saves work on + the active one; view.switchWindows lists the open + documents and with {\"id\": N} makes one active wordcraft-cli commands [--json] list every command wordcraft-cli parity [--markdown] feature-catalog parity wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR] @@ -472,10 +476,14 @@ fn run>(args: &[S]) -> Result<(), Failure> { let mut s = match (from_file, from_template) { (Some(f), _) => open(&f, password.as_deref())?, // Both go through `file.new`, which puts a blank document on the region's paper (#3152). + // `file.new` opens beside the session's first document, which is then closed: the + // run starts with one open document. (None, t) => { let mut s = session(wordcraft_doc::Document::new()); + let first = s.document_id(); let params = t.map_or_else(|| json!({}), |t| json!({"template": t})); s.run("file.new", ¶ms).map_err(|e| e.to_string())?; + s.close_document(first).map_err(|e| e.to_string())?; s } }; diff --git a/apps/wordcraft-cli/tests/documents.rs b/apps/wordcraft-cli/tests/documents.rs new file mode 100644 index 0000000000..e62a880d7b --- /dev/null +++ b/apps/wordcraft-cli/tests/documents.rs @@ -0,0 +1,39 @@ +//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open +//! another document and make it active, and `--save` saves the active one. + +use std::path::Path; +use std::process::{Command, Output}; + +fn run(args: &[&str], out: &Path) -> Output { + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + cli.arg("run").args(args).arg("--save").arg(out); + cli.output().unwrap() +} + +#[test] +fn run_saves_the_document_opened_last() { + let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (src, out) = (dir.join("opened.txt"), dir.join("out.txt")); + std::fs::write(&src, "Opened\n").unwrap(); + let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy())); + + // A script that starts a new document and types into it saves that document. + let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out); + let fresh = std::fs::read_to_string(&out).unwrap_or_default(); + // A file opened after typing is what is saved; the typed document stays out of it. + let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out); + let reopened = std::fs::read_to_string(&out).unwrap_or_default(); + // A run starts with one open document. + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr)); + assert_eq!(fresh.trim(), "Fresh"); + assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr)); + assert_eq!(reopened.trim(), "Opened"); + assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr)); + let stdout = String::from_utf8_lossy(&listed.stdout); + assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}"); +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 9c4a875bfe..d96e77411f 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -35,30 +35,59 @@ impl Headless { self } - fn answer(&mut self, method: &str, params: Value) -> Result { - match method { - "engine.execute" | "command" => { - let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); - let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); - self.session.run(&id, &p).map_err(|e| e.to_string()) - } - "engine.commands" => Ok(self.session.registry.describe()), - "document.inspect" => self.session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), - "ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)), - "ui.render" | "render.page" => { - let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; - let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; - let l = self.session.layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default()); - let png = img.to_png(); - Ok( - json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}), - ) - } - m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), - other => self.session.run(other, ¶ms).map_err(|e| e.to_string()), + /// Answer `method`; a document method addressed to another open document (`doc`) runs on + /// that document, and the active one stays active. + fn answer(&mut self, method: &str, mut params: Value) -> Result { + if !DOC_METHODS.contains(&method) { + return answer_on(&mut self.session, method, params); } + let doc = doc_param(¶ms)?; + if let Some(o) = params.as_object_mut() { + o.remove("doc"); + } + match doc { + None => answer_on(&mut self.session, method, params), + Some(id) => self.session.with_document(id, |s| answer_on(s, method, params)).map_err(|e| e.to_string())?, + } + } +} + +/// The methods that work on one document, and so take `doc`: the id of the open document to work +/// on (`view.switchWindows` lists them), the active one when absent. +pub const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render", "render.page"]; + +/// The document a request addresses with `doc`: `None` (absent or null) is the active document. +/// Anything but a whole number from 0 up is an error; whether it names an open document is for +/// the session to say. +pub fn doc_param(params: &Value) -> Result, String> { + match params.get("doc") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| "`doc` must be the id of an open document (list_documents lists them)".to_string()), + } +} + +fn answer_on(session: &mut Session, method: &str, params: Value) -> Result { + match method { + "engine.execute" | "command" => { + let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); + let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + session.run(&id, &p).map_err(|e| e.to_string()) + } + "engine.commands" => Ok(session.registry.describe()), + "document.inspect" => session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), + "ui.parity" => Ok(wordcraft_engine::catalog::parity(&session.registry)), + "ui.render" | "render.page" => { + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1); + let page = usize::try_from(page).unwrap_or(usize::MAX).saturating_sub(1); + let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; + let l = session.layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale, &Default::default()); + let png = img.to_png(); + Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) + } + m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), + other => session.run(other, ¶ms).map_err(|e| e.to_string()), } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 63f6764c61..c0b1a40272 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,7 +22,10 @@ const INSTRUCTIONS: &str = "WordCraft is a word processor (a Microsoft Word clon parameters with list_commands and run them with execute (or several with batch). Positions are \ {story:\"body\", path:[block,(row,cell,block)...], off:byteOffset}. Typical flow: new_document → type_text \ \"Hello\" → execute text.newParagraph → execute para.style {style:\"Heading 1\"} → select_text {text:\"Hello\"} → \ -execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}."; +execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}. \ +Several documents can be open: new_document and open_document open another one and make it active; \ +list_documents gives their ids, and `doc` on execute, batch and the other document tools works on one \ +without switching."; /// Resource URIs. pub const DOC_URI: &str = "wordcraft://document"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02b..9872f98d0a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -152,3 +152,45 @@ fn type_text_stops_at_a_refused_paragraph_break() { assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); } + +/// W1: with several documents open, `doc` sends a tool to one of them; the active document stays +/// active and unchanged, and an id that names no open document is an error. +#[test] +fn tools_address_an_open_document_by_id() { + let mut s = Server::new(Box::new(Headless::default())); + let first = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + tool(&mut s, "type_text", json!({"text": "First"})); + let second = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + assert_ne!(first, second); + tool(&mut s, "type_text", json!({"text": "Second"})); + + tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + tool(&mut s, "batch", json!({"commands": [{"command": "text.insert", "params": {"text": " again"}}], "doc": first})); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + let inspected = tool(&mut s, "inspect_document", json!({"doc": first})); + assert_eq!(inspected["blocks"][0]["text"], "First more again"); + let r = call(&mut s, 12, "tools/call", json!({"name": "render_page", "arguments": {"doc": first, "scale": 0.2}})); + assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); + + let list = tool(&mut s, "list_documents", json!({})); + let docs = list["documents"].as_array().unwrap(); + let ids: Vec = docs.iter().filter_map(|d| d["id"].as_u64()).collect(); + assert!(ids.contains(&first) && ids.contains(&second), "{list}"); + let active: Vec = docs.iter().filter(|d| d["active"] == true).filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(active, vec![second]); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (name, args) in [ + ("execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("get_text", json!({"doc": bad})), + ("inspect_document", json!({"doc": bad})), + ("render_page", json!({"doc": bad})), + ] { + let r = call(&mut s, 13, "tools/call", json!({"name": name, "arguments": args})); + assert_eq!(r["result"]["isError"], true, "{name} with doc {bad}: {r}"); + } + } + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b8..65c1e11fa8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -48,6 +48,16 @@ fn n(desc: &str) -> Value { fn obj(props: Value, req: &[&str]) -> Value { json!({"type": "object", "properties": props, "required": req}) } +/// Adds the optional `doc` property to a tool's schema. +fn with_doc(mut schema: Value) -> Value { + if let Some(p) = schema.get_mut("properties").and_then(Value::as_object_mut) { + p.insert("doc".into(), json!({"type": "integer", "minimum": 0, "description": DOC_DESC})); + } + schema +} + +const DOC_DESC: &str = "Id of the open document to work on (list_documents); the active document when left out. The active document stays active."; + fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value { json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}}) } @@ -64,31 +74,31 @@ pub fn tool_definitions() -> Value { tool( "execute", "Run a command", - "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.", - obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]), + "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.", + with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])), false ), tool( "batch", "Run several commands", - "Run commands in order; stops at the first error unless keepGoing.", - obj( + "Run commands in order; stops at the first error unless keepGoing. `doc` sends every command to that open document.", + with_doc(obj( json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}), &["commands"] - ), + )), false ), tool( "new_document", "New document", - "Start a document: blank, sample, letter, resume or report.", + "Start a document: blank, sample, letter, resume or report. It opens beside the documents already open and becomes the active one; the reply's `id` addresses it (`doc`).", obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]), false ), tool( "open_document", "Open", - "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one.", + "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one. The file opens beside the documents already open (it replaces a blank one nobody has used) and becomes the active one; a file that is already open becomes active again (`alreadyOpen`). The reply's `id` addresses it (`doc`).", obj(json!({"path": s("File path"), "password": s("Password, if the file is protected")}), &["path"]), false ), @@ -96,36 +106,43 @@ pub fn tool_definitions() -> Value { "save_document", "Save", "Save (format from the extension: docx, pdf, odt, rtf, html, md, tex, txt, png).", - obj(json!({"path": s("File path; omit to save in place")}), &[]), + with_doc(obj(json!({"path": s("File path; omit to save in place")}), &[])), false ), tool( "type_text", "Type text", "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", - obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), + with_doc(obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"])), false ), tool( "select_text", "Select text", "Select the n-th occurrence of some text.", - obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), + with_doc(obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"])), false ), - tool("get_text", "Document text", "Body text with tracked deletions omitted.", obj(json!({}), &[]), true), + tool("get_text", "Document text", "Body text with tracked deletions omitted.", with_doc(obj(json!({}), &[])), true), tool( "inspect_document", "Inspect document", "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.", - obj(json!({}), &[]), + with_doc(obj(json!({}), &[])), true ), tool( "render_page", "Render page", "Render a page to PNG to look at the result.", - obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]), + with_doc(obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[])), + true + ), + tool( + "list_documents", + "Open documents", + "The open documents in the order they were opened: id, title, path, dirty (unsaved changes) and active (the one commands work on by default). new_document and open_document open another document beside these. Give a document's id as `doc` to execute, batch, type_text, select_text, get_text, inspect_document, render_page or save_document to work on it while it stays in the background; execute view.switchWindows {id} makes it the active one, file.closeDocument {id, force?} closes it.", + obj(json!({}), &[]), true ), tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true), @@ -154,8 +171,31 @@ pub fn tool_definitions() -> Value { ]) } -fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { - b.call("engine.execute", json!({"command": id, "params": params})) +fn exec(b: &mut dyn Backend, id: &str, params: Value, doc: Option<&Value>) -> Result { + b.call("engine.execute", addressed(json!({"command": id, "params": params}), doc)) +} + +/// `params` with the tool's `doc`, when it gave one. The backend checks it: a session says +/// whether it names an open document. +fn addressed(mut params: Value, doc: Option<&Value>) -> Value { + if let (Some(d), Some(o)) = (doc, params.as_object_mut()) { + o.insert("doc".into(), d.clone()); + } + params +} + +/// The active document's id, added to a reply that opened or made one. +fn with_active_id(b: &mut dyn Backend, r: Result) -> Result { + let mut v = r?; + if v.get("id").is_none() + && let Ok(list) = exec(b, "view.switchWindows", json!({}), None) + && let Some(id) = + list.get("documents").and_then(Value::as_array).and_then(|d| d.iter().find(|d| d["active"] == true)).and_then(|d| d.get("id")) + && let Some(o) = v.as_object_mut() + { + o.insert("id".into(), id.clone()); + } + Ok(v) } fn wrap(r: Result) -> ToolResult { @@ -167,6 +207,7 @@ fn wrap(r: Result) -> ToolResult { pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let st = |k: &str| a.get(k).and_then(Value::as_str); + let doc = a.get("doc").filter(|d| !d.is_null()); match name { "list_commands" => { let q = st("query").unwrap_or("").to_lowercase(); @@ -188,7 +229,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } } "execute" => match st("command") { - Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))), + Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)), None => ToolResult::error("missing `command`"), }, "batch" => { @@ -196,7 +237,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let mut out = Vec::new(); for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); - let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({}))); + let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc); let failed = r.is_err(); out.push(match r { Ok(v) => json!({"command": id, "ok": true, "result": v}), @@ -208,13 +249,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } ToolResult::json(&Value::Array(out)) } - "new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))), - "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}))), + "new_document" => { + let r = exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}), None); + wrap(with_active_id(b, r)) + } + "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}), None)), + "list_documents" => wrap(exec(b, "view.switchWindows", json!({}), None)), "save_document" => { let p = st("path"); let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase(); if ext == "png" { - wrap(exec(b, "file.exportPng", json!({"path": p}))) + wrap(exec(b, "file.exportPng", json!({"path": p}), doc)) } else { wrap(exec( b, @@ -223,6 +268,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { Some(p) => json!({"path": p}), None => json!({}), }, + doc, )) } } @@ -232,13 +278,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let mut last = Ok(Value::Null); for (i, line) in t.split('\n').enumerate() { if i > 0 { - last = exec(b, "text.newParagraph", json!({})); + last = exec(b, "text.newParagraph", json!({}), doc); if last.is_err() { break; } } if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); + last = exec(b, "text.insert", json!({"text": line, "raw": true}), doc); } if last.is_err() { break; @@ -246,19 +292,23 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } wrap(last) } else { - wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) + wrap(exec(b, "text.insert", json!({"text": t, "raw": true}), doc)) } } - "select_text" => { - wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}))) - } - "get_text" => wrap(exec(b, "document.text", json!({}))), - "inspect_document" => wrap(b.call("document.inspect", json!({}))), + "select_text" => wrap(exec( + b, + "select.text", + json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}), + doc, + )), + "get_text" => wrap(exec(b, "document.text", json!({}), doc)), + "inspect_document" => wrap(b.call("document.inspect", addressed(json!({}), doc))), "parity" => wrap(b.call("ui.parity", json!({}))), // Pictures travel inside the reply (`png`), headless or through the app: no file with a // predictable name in the shared temporary folder. "render_page" => { - let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}); + let params = + addressed(json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}), doc); match b.call("ui.render", params) { Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})), Err(e) => ToolResult::error(e), @@ -323,6 +373,21 @@ mod tests { assert_eq!(forwarded(json!({"path": "/tmp/a.docx"}))["password"], Value::Null); } + /// `doc` travels beside the command, not inside its params, so the app's control channel can + /// run the command on that document; without one nothing is added. + #[test] + fn doc_is_forwarded_beside_the_command() { + let mut b = Recording { seen: None }; + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}, "doc": 7})); + let (_, call) = b.seen.take().expect("the backend was called"); + assert_eq!(call["doc"], json!(7)); + assert!(call["params"].get("doc").is_none(), "{call}"); + call_tool(&mut b, "inspect_document", &json!({"doc": 7})); + assert_eq!(b.seen.take().expect("called").1["doc"], json!(7)); + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}})); + assert!(b.seen.take().expect("called").1.get("doc").is_none()); + } + /// The password has to be in the schema, or a client has no way to know it can be sent. #[test] fn the_schema_advertises_the_password_as_optional() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 62975845c4..2803ef2ef1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,7 +1,7 @@ //! Programmatic control of the running app (agents, tests, MCP bridge). //! //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): -//! - `engine.execute {command, params}`: run any command (engine or `ui.*`) +//! - `engine.execute {command, params, doc?}`: run any command (engine or `ui.*`) //! - `engine.commands {search?}`: engine and app print commands with enablement and parameters //! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf //! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point. @@ -12,6 +12,10 @@ //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG //! (without `path`, both reply with the PNG as base64 `png`) //! - `ui.parity`: feature catalog parity +//! +//! `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of an open document +//! (`view.switchWindows` lists them): they work on that document while the window keeps showing +//! the active one. Without `doc` they work on the active document. use std::sync::mpsc::Sender; @@ -61,6 +65,8 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { "focused": app.canvas.focused, "title": app.title_stem(), "dirty": app.session.dirty, + "document": app.session.document_id(), + "documents": app.session.documents(), "perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms}, }) } @@ -113,18 +119,99 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<& return None; } if id == "file.close" && app.session.dirty { - return Some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"); + return Some(UNSAVED); } // Comment text still being typed is unsaved work too (#1348): replacing the document would drop it. (crate::discards_document(id, params) && crate::panes::has_comment_drafts(app)) .then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it") } +const UNSAVED: &str = "the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"; + /// The reply to a request that needs a document while none is open (#1129). const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; +/// The methods that take `doc`. +const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render"]; + +/// The open document other than the active one that a request's `doc` names; `None` for the +/// active document (no `doc`, null, or the active document's id). An id that names no open +/// document is an error. +fn addressed(app: &WordApp, p: &Value) -> Result, String> { + let id = match p.get("doc") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().ok_or("`doc` must be the id of an open document (view.switchWindows lists them)")?, + }; + if !app.session.is_open(id) { + return Err(format!("no open document has id {id}")); + } + Ok((id != app.session.document_id()).then_some(id)) +} + +/// The reply to work run on another document: its result, or why it couldn't run or failed. +fn reply(r: Result, F>) -> Outcome { + match r { + Ok(Ok(v)) => ok(v), + Ok(Err(e)) => err(e), + Err(e) => err(e), + } +} + +/// A document method addressed to open document `doc`, which isn't the active one: it runs on +/// that document, and the window keeps showing the active one. +fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome { + if app.no_document == Some(doc) { + return err(NO_DOCUMENT); + } + match method { + "document.inspect" => reply(app.session.with_document(doc, |s| s.run("document.inspect", p))), + "ui.render" => { + let page = p.get("page").and_then(Value::as_u64).unwrap_or(1); + let scale = p.get("scale").and_then(Value::as_f64).map_or(2.0, |v| v as f32); + let path = p.get("path").and_then(Value::as_str); + let r = app.session.with_document(doc, |s| match path { + Some(path) => s.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})).map_err(|e| e.to_string()), + None => render_png(s, page, scale), + }); + reply(r) + } + _ => { + let Some(id) = p.get("command").or(p.get("id")).and_then(Value::as_str) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + if id.starts_with("ui.") { + return err(format!("`{id}` works on the window: make the document active first (view.switchWindows {{\"id\": {doc}}})")); + } + // Close, with the same guard as the window's: unsaved changes need `force`. + if id == "file.close" { + let force = params.get("force").and_then(Value::as_bool) == Some(true); + if !force && app.session.documents().iter().any(|d| d.id == doc && d.dirty) { + return err(UNSAVED); + } + return match app.session.close_document(doc) { + Ok(()) => ok(json!({"closed": true, "id": doc, "documents": app.session.documents()})), + Err(e) => err(e), + }; + } + let r = app.session.with_document(doc, |s| s.run(id, ¶ms)); + // UI requests the command made carry `document`, so the window can tell they aren't + // about the document it shows. + for req in std::mem::take(&mut app.session.ui_requests) { + app.handle_request(&req); + } + reply(r) + } + } +} + pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; + if DOC_METHODS.contains(&req.method.as_str()) { + match addressed(app, p) { + Ok(Some(doc)) => return handle_in(app, doc, &req.method, p), + Ok(None) => {} + Err(e) => return err(e), + } + } let s = |k: &str| p.get(k).and_then(Value::as_str); let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32); match req.method.as_str() { @@ -263,7 +350,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O }, // No path: the PNG travels in the reply. Never a fixed name in the shared // temporary folder, where another user could have planted a link to a file. - None => match render_png(app, page, scale) { + None => match render_png(&app.session, page, scale) { Ok(v) => ok(v), Err(e) => err(e), }, @@ -298,11 +385,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O /// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless /// MCP server's `ui.render`). -fn render_png(app: &WordApp, page: u64, scale: f32) -> Result { +fn render_png(session: &wordcraft_engine::Session, page: u64, scale: f32) -> Result { let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1); - let l = app.session.export_layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); + let l = session.export_layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); let png = img.to_png(); Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) } @@ -327,3 +414,7 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { } json!({"ok": true, "result": {"path": path, "width": w, "height": h}}) } + +#[cfg(test)] +#[path = "control_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/control_tests.rs b/crates/ui-egui/src/control_tests.rs new file mode 100644 index 0000000000..fe0a0f3637 --- /dev/null +++ b/crates/ui-egui/src/control_tests.rs @@ -0,0 +1,122 @@ +//! The control channel's `doc`: methods addressed to an open document that isn't the active one. + +use serde_json::{Value, json}; +use wordcraft_engine::Session; + +use super::{ControlRequest, Outcome, handle}; +use crate::{Services, WordApp}; + +fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) +} + +/// Send one request the way the control channel does and return the reply. +fn send(a: &mut WordApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, _reply) = ControlRequest::new(method, params); + match handle(a, &ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a reply"}), + } +} + +fn exec(a: &mut WordApp, command: &str, params: Value) -> Value { + send(a, "engine.execute", json!({"command": command, "params": params})) +} + +fn body(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) +} + +/// Two documents, the first typed in and parked behind the second: (first, second). +fn two_documents(a: &mut WordApp) -> (u64, u64) { + let first = a.session.document_id(); + assert_eq!(exec(a, "text.insert", json!({"text": "First"}))["ok"], true); + assert_eq!(exec(a, "file.new", json!({}))["ok"], true); + let second = a.session.document_id(); + assert_ne!(first, second); + assert_eq!(exec(a, "text.insert", json!({"text": "Second"}))["ok"], true); + (first, second) +} + +/// W1: `doc` sends a command, an inspection or a render to a document in the background; the +/// window's document stays active and unchanged, and an id that names no open document is an +/// error. +#[test] +fn methods_address_an_open_document_by_id() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); + let r = send(&mut a, "ui.render", json!({"doc": first, "scale": 0.2})); + assert!(r["result"]["png"].as_str().is_some_and(|p| !p.is_empty()), "{r}"); + // The active document's own id works like no `doc` at all. + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "!"}, "doc": second})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(body(&a).trim(), "Second!"); + + let r = send(&mut a, "ui.inspect", json!({})); + assert_eq!(r["result"]["document"], second, "{r}"); + let ids: Vec = r["result"]["documents"].as_array().into_iter().flatten().filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(ids, vec![first, second]); + + // Window commands need the document on screen. + let r = send(&mut a, "engine.execute", json!({"command": "ui.tab", "params": {"value": "insert"}, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (method, params) in [ + ("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("document.inspect", json!({"doc": bad})), + ("ui.render", json!({"doc": bad})), + ] { + let r = send(&mut a, method, params); + assert_eq!(r["ok"], false, "{method} with doc {bad}: {r}"); + } + } + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second!"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); +} + +/// W1: `file.close` addressed to a document in the background refuses its unsaved changes +/// unless forced (only a boolean true), as it does for the window's document (#1268), and closes +/// only that document. +#[test] +fn closing_an_addressed_document_keeps_unsaved_work_unless_forced() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + for params in [json!({}), json!({"force": "true"}), json!({"force": 1})] { + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": params, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + let e = r["error"].as_str().unwrap_or(""); + assert!(e.contains("unsaved") && e.contains("force"), "{e}"); + assert!(a.session.is_open(first)); + } + + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": {"force": true}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(first)); + assert_eq!(a.session.document_id(), second); + assert!(a.has_document() && !a.quit_requested); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": first})); + assert_eq!(r["ok"], false, "a closed document can't be addressed: {r}"); + + // A document without unsaved changes closes at once. + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let clean = a.session.document_id(); + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let active = a.session.document_id(); + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "doc": clean})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(clean) && a.session.is_open(second)); + assert_eq!(a.session.document_id(), active); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0f09568ae9..681c21cb63 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,10 +52,10 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | 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.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)…) | +| `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.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)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | -| `document.inspect` | `text?` | 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 | +| `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; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | | `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates | | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag | @@ -64,12 +64,36 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | | `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | -| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | +| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | | `ui.parity` | — | feature-catalog parity | | `ui.resize` / `ui.focus` / `app.quit` | | window control | Any other method name is treated as a command id: `{"method": "format.bold"}` works. +### Open documents + +Several documents can be open at once. Each has an id, a whole number that stays the same while +the document is open and is never reused by the running app. One document is the active one: the +window shows it, and commands work on it unless told otherwise. + +- `file.new` opens another document and makes it active. `file.open` does the same with a file; + it replaces a blank document nobody has used yet, and a file that is already open becomes + active again (`"alreadyOpen": true`). Its result carries the document's `id`. +- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", + "dirty", "active"}]}`); with `{"id": n}` it makes that document active. +- `file.closeDocument {"id"?, "force"?}` closes a document without touching the window. +- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work + on, next to their other params. The command runs on that document and the window keeps showing + the active one: + +```json +{"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +`doc` left out, or `null`, is the active document. An id that names no open document, or a value +that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work +on the document on screen only: with the id of another document they are an error. + The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). @@ -94,7 +118,9 @@ stay); save first (`file.save`), or pass `{"force": true}` to discard the change The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved changes closes at once (#1268). Closing closes the document, not the app: the window stays and -shows no page, and `file.new` or `file.open` goes on from there (#1712). +shows no page, and `file.new` or `file.open` goes on from there (#1712). With `doc` naming a +document in the background, `file.close` closes that document under the same rule (unsaved +changes need `{"force": true}`), and the window keeps showing the active one. ### Native printing diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d1..8b07b6b691 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -33,7 +33,10 @@ PDF in one run without a window: line win. A command is `{"command": id, "params": {…}}` or just the id. `save` is a path or a list; each output's format follows its extension. Relative `file` and `save` paths resolve against the script's folder (the working folder for stdin). `--cmd` flags run after the script's commands and -`--save` after its outputs. A failing command stops the run with `command N (id): …` and exit +`--save` after its outputs. The run starts with one open document. `file.new` and `file.open` +open another one and make it active; later commands and the saves work on the active document. +`view.switchWindows` lists the open documents (with `--print` their ids show), and +`view.switchWindows={"id": n}` makes one active. A failing command stops the run with `command N (id): …` and exit status 1, and nothing is saved. Every output is checked (known extension, folder exists) before the first is written. Paths inside command params (a picture to insert, say) are relative to the working folder, not the script's. Scripts are capped at 4 MiB. ## Tracked changes by an agent @@ -106,8 +109,9 @@ files it was given rather than guarding against other software on the computer. | `list_commands` | every command (filter with `query`) — over 270 of them | | `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` | | `batch` | run several commands in order | -| `new_document` | blank, sample, letter, resume, report | +| `new_document` | blank, sample, letter, resume, report; opens beside the open documents | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | +| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` | | `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | @@ -115,6 +119,28 @@ files it was given rather than guarding against other software on the computer. | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) | +### Several documents + +`new_document` and `open_document` (the commands `file.new` and `file.open`) open another +document beside the ones already open and make it the active one; their reply carries its `id`. +`open_document` replaces a blank document nobody has used, and a file that is already open becomes +active again (`alreadyOpen`). `list_documents` lists the open documents with their ids. + +Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`, +`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open +document: the tool works on that document and the active one stays active. + +```json +{"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +An id that names no open document, or a value that isn't a whole number from 0 up, is a tool error. +`execute` with `view.switchWindows {"id": n}` makes a document active, and `file.closeDocument +{"id": n, "force"?: true}` closes one (unsaved changes need `force`). Through `--connect`, `doc` +reaches the app's control channel (see [Open documents](control-protocol.md#open-documents)); the +app's window keeps showing the document it showed. `screenshot`, `click` and `key` act on the +window, so they work on the document on screen. + `insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it. From 3693759149574239c44d6f93b9a8e12ecff98c35 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:01:56 +0000 Subject: [PATCH 09/87] UI: several open documents in one window; Switch Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop window follows the session's active document. What the window keeps per document (AutoSave's file, comment drafts) is parked while another document shows and comes back with it; everything about the page that was shown (dialogs, menus, drags, composition, status, caches keyed by revision) starts afresh. The page's scroll area is per document, so switching back finds the page where it was left; zoom is in the session. - View › Window › Switch Windows lists the open documents (active marked, unsaved dotted) and shows the one chosen (view.switchWindows). - New, Open, the mailings and personal templates open beside the open documents and no longer ask about unsaved changes. - File › Close shows the next open document; after the last one the window shows no page, as before. - Quitting asks Save / Don't Save / Cancel about each document with unsaved work, showing it; Cancel keeps the app open. - UI requests tagged for a document that isn't shown never act on the shown one: theme changes apply, Close closes that document when it has nothing unsaved, the rest are dropped. - Read Aloud highlights and follows only the document being read. - A late Save As answer shows and saves the document it was for. - Untitled documents are numbered in the interface language. --- ROADMAP.md | 4 +- apps/wordcraft/src/apple_events.rs | 5 +- crates/ui-egui/src/backstage.rs | 26 +-- crates/ui-egui/src/canvas.rs | 52 ++++- crates/ui-egui/src/documents.rs | 289 ++++++++++++++++++++++++++ crates/ui-egui/src/file_dialogs.rs | 17 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ro.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 310 +++++++++++++++------------- crates/ui-egui/src/panes.rs | 5 + crates/ui-egui/src/previews.rs | 5 + crates/ui-egui/src/read_aloud.rs | 10 +- crates/ui-egui/src/ribbon.rs | 10 + docs/ui-parity.md | 5 +- 38 files changed, 636 insertions(+), 180 deletions(-) create mode 100644 crates/ui-egui/src/documents.rs diff --git a/ROADMAP.md b/ROADMAP.md index 731d388e51..cd54d1c1a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -573,6 +574,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e952a3dfcf..71d0d05b4d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -35,8 +35,9 @@ impl AppleEvents { } /// Open the documents and act on the quit requests that arrived since the last frame. A document -/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which -/// asks the same. +/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it +/// is already open, by showing it. Quitting closes the window, which asks about every document +/// with unsaved changes. pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { for e in inbox.drain() { match e { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 909dfd4651..7e9ea3bcff 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1127,33 +1127,25 @@ mod tests { let _ = std::fs::remove_dir_all(&chosen); } - /// A personal template replaces the document like New does: unsaved changes are asked about - /// first, and nothing is replaced until that is answered. + /// A personal template opens a new document like New does: nothing is asked, and the + /// document with unsaved changes stays open beside it. #[test] - fn a_personal_template_asks_about_unsaved_changes_first() { + fn a_personal_template_opens_beside_unsaved_work() { let dir = personal_dir("dirty"); let memo = dir.join("Memo.dotx"); write_template(&memo, "From the template"); let mut h = new_page_harness(Some(dir.clone())); h.state_mut().run("document.setText", json!({"text": "Unsaved work"})).unwrap(); - assert!(h.state().session.dirty); + let first = h.state().session.document_id(); h.get_by_label("Memo").click(); h.run_steps(3); let body = |h: &egui_kittest::Harness<'static, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "Save changes is asked"); - assert_eq!(body(&h), "Unsaved work", "the document stays until the question is answered"); - h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - h.run_steps(2); - assert_eq!(body(&h), "Unsaved work", "Cancel keeps it"); - assert_eq!(h.state().session.doc.settings.attached_template, None); - h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); - h.run_steps(3); - h.get_by_label("Memo").click(); - h.run_steps(3); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - h.run_steps(2); - assert!(body(&h).contains("From the template"), "Don't Save makes the document from the template"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert!(body(&h).contains("From the template")); assert_eq!(h.state().session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref())); + h.state_mut().run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(body(&h), "Unsaved work", "the unsaved document is still open"); + assert!(h.state().session.dirty); drop(h); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 618f1c454b..9caeec2150 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -198,6 +198,32 @@ pub struct CanvasState { } impl CanvasState { + /// Another document is shown in the window: everything about the page that was shown (drags, + /// menus, the input method's composition, a scroll in progress, rendered pages…) starts + /// afresh. What belongs to the window or the user stays: the colours last applied, the + /// ruler's tab kind, the faces added for interface text, the page area's size. The scroll + /// position comes back by itself, as the page's scroll area keeps one per document in egui's + /// memory. `seen`: the document was shown before, so the view stays where it was rather than + /// going to the caret. + pub(crate) fn show_other_document(&mut self, seen: bool) { + let old = std::mem::take(self); + let ime_reset = !old.ime_preedit.is_empty() || old.ime_preedit_range.is_some(); + *self = CanvasState { + last_highlight: old.last_highlight, + last_font_color: old.last_font_color, + last_shading: old.last_shading, + ime_faces: old.ime_faces, + tab_kind: old.tab_kind, + scale: old.scale, + canvas_rect: old.canvas_rect, + read_viewport: old.read_viewport, + web_viewport: old.web_viewport, + scroll_to_caret: !seen, + ime_reset, + ..CanvasState::default() + }; + } + /// Stop a text-selection drag when the pointer gesture becomes an object drag. pub(crate) fn cancel_text_drag(&mut self) { self.dragging = false; @@ -1171,7 +1197,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; let delta = wheel_delta + auto_delta + middle_delta; let mut scroll_area = egui::ScrollArea::both() - .id_salt("canvas_scroll") + // One scroll position per open document, so switching back finds the page as it was left. + .id_salt(("canvas_scroll", app.session.document_id())) .auto_shrink([false, false]) // Bringing the caret into view glides there, or jumps with animation effects off. .animated(animate) @@ -4846,17 +4873,17 @@ mod tests { assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); } - /// #1348: an agent can't replace the document while comment text is still being typed - /// unless it passes `force`, as with unsaved changes. + /// #1348: an agent can't close the document while comment text is still being typed unless + /// it passes `force`, as with unsaved changes. New and Open open beside it, losing nothing. #[test] - fn an_agent_cannot_replace_the_document_over_a_pending_comment_draft() { + fn an_agent_cannot_close_the_document_over_a_pending_comment_draft() { let (mut h, id) = comment_harness(); h.state_mut().session.dirty = false; click_balloon(&mut h, id); type_in(&mut h, "Typed "); let open = json!({"path": "/nonexistent/other.docx"}); - assert!(crate::control::close_refused(h.state(), "file.open", &open).is_some()); - assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_some()); + assert!(crate::control::close_refused(h.state(), "file.open", &open).is_none()); + assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_none()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({})).is_some()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({"force": true})).is_none()); assert!(crate::control::close_refused(h.state(), "text.insert", &json!({"text": "x"})).is_none()); @@ -4883,15 +4910,18 @@ mod tests { assert!(h.state_mut().close_requested(), "Don't Save closes"); } - /// #1348: New with a refused draft asks to save; Don't Save makes the new document. + /// #1348: New with a refused draft opens beside it, asking nothing; the draft waits with its + /// document and is there when that document is shown again. #[test] - fn a_refused_comment_draft_can_be_dropped_on_new() { + fn a_refused_comment_draft_stays_with_its_document_on_new() { let (mut h, _) = refused_draft(); let document = h.state().session.document_id(); h.state_mut().run("file.new", json!({})).unwrap(); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt asks about it"); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_ne!(h.state().session.document_id(), document, "Don't Save made the new document"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert_ne!(h.state().session.document_id(), document, "New made a document"); + assert!(!crate::panes::has_comment_drafts(h.state()), "the draft isn't the new document's"); + h.state_mut().run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(crate::panes::has_comment_drafts(h.state()), "the draft is back with its document"); } /// #1348: Save with a refused draft saves the rest of the document, keeps the draft and says why. diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs new file mode 100644 index 0000000000..85a3a5154a --- /dev/null +++ b/crates/ui-egui/src/documents.rs @@ -0,0 +1,289 @@ +//! Several open documents in one window. The window shows the session's active document; what +//! it keeps about a document (AutoSave's file, comment drafts, the page's drags, menus and +//! composition…) follows the active one ([`WordApp::sync_document`]). View › Window › Switch +//! Windows lists the open documents, File › Close closes the shown one and shows the next, and +//! quitting asks about every document with unsaved changes, one after another. + +use serde_json::{Value, json}; +use wordcraft_engine::DocInfo; + +use crate::{WordApp, panes}; + +/// What the window keeps for an open document while another one is shown. +#[derive(Default)] +pub(crate) struct Parked { + /// [`WordApp`]'s `autosave_path`: the file AutoSave writes this document to. + autosave_path: Option, + /// Comment and reply text typed in this document and not yet written to it. + comment_drafts: panes::CommentDrafts, +} + +impl WordApp { + /// Follow the session's active document. When it isn't the one the window last showed, the + /// shown document's own state is put aside (or dropped, if it was closed or replaced), the + /// new one's comes back, and everything about the page that was shown starts afresh: open + /// dialogs and menus, drags, the input method's composition, the status message, caches + /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per + /// document; the zoom is in the session's view). Runs after every command and every frame. + pub(crate) fn sync_document(&mut self) { + let id = self.session.document_id(); + if id == self.shown_document { + return; + } + let left = std::mem::replace(&mut self.shown_document, id); + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(left) { + self.parked.insert(left, leaving); + } + let back = self.parked.remove(&id); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + + self.canvas.show_other_document(seen); + self.controls = Default::default(); + self.dialog = None; + self.colors = None; + self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() }; + self.status_msg = None; + self.change_picture_target = None; + self.recipient_list_pending = false; + self.dialog_pick = None; + self.focus_ribbon = false; + self.nav_search_focus = false; + self.smart_art_item = None; + self.smart_art_row_focused = false; + self.smart_art_typing = None; + self.read_aloud_at = None; + self.info_editing = None; + // Caches keyed by the revision alone: another document's revision can be the same number. + self.word_count = (u64::MAX, 0); + self.find_fonts_for = None; + self.previews.document_changed(); + // The Start screen's placeholder (#1129) goes once a document is open beside it. + if let Some(placeholder) = self.no_document + && placeholder != id + && self.session.is_open(placeholder) + { + let _ = self.session.close_document(placeholder); + self.parked.remove(&placeholder); + } + } + + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). + pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { + self.session.activate(id).map_err(|e| e.to_string())?; + self.sync_document(); + Ok(()) + } + + /// The open documents with work that would be lost on quitting: the shown one first, then + /// the others in the order they were opened. Unsaved changes, a save the browser hasn't + /// confirmed, or comment text not yet written to the document. + fn unsaved_documents(&self) -> Vec { + if !self.has_document() { + return Vec::new(); + } + let active = self.session.document_id(); + let mut out = Vec::new(); + if self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self) { + out.push(active); + } + for d in self.session.documents() { + let unconfirmed = || self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == d.id); + let drafts = || self.parked.get(&d.id).is_some_and(|p| !p.comment_drafts.is_empty()); + if d.id != active && (d.dirty || unconfirmed() || drafts()) { + out.push(d.id); + } + } + out + } + + /// Whether an open document has work that quitting would lose. + pub(crate) fn has_unsaved_documents(&self) -> bool { + !self.unsaved_documents().is_empty() + } + + /// Quitting: show the next document with unsaved work and ask Save / Don't Save / Cancel + /// about it (its answer goes on with [`WordApp::close_document`]'s `quit`). With none left, + /// the app quits. + pub(crate) fn ask_before_quit(&mut self) -> Value { + let Some(&next) = self.unsaved_documents().first() else { + self.quit_requested = true; + return json!({"quit": true}); + }; + if next != self.session.document_id() + && let Err(e) = self.show_document(next) + { + log::error!("quitting: couldn't show document {next}: {e}"); + return json!({"quit": false}); + } + self.ui.backstage = false; + self.run("file.close", json!({"quit": true})).unwrap_or_else(|e| json!({"quit": false, "error": e})) + } +} + +/// The name an open document goes by in the window: the engine's title, with an untitled +/// document's `Document2` in the interface language. +pub fn display_title(d: &DocInfo) -> String { + if d.path.is_none() { + return untitled_title(&d.title).unwrap_or_else(|| d.title.clone()); + } + d.title.clone() +} + +/// `Document2` (the engine's English name for an untitled document) in the interface language, +/// or None for any other name. The catalogs translate `Document1`; the number follows it. +pub(crate) fn untitled_title(english: &str) -> Option { + let n = english.strip_prefix("Document")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let one = tl!("Document1"); + Some(match one.strip_suffix('1') { + Some(stem) => format!("{stem}{n}"), + None if n == "1" => one.to_string(), + None => format!("{one} ({n})"), + }) +} + +/// View › Window › Switch Windows: the open documents in the order they were opened, the shown +/// one marked, unsaved ones with the title bar's dot. Choosing one shows it. +pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { + for d in app.session.documents() { + let label = format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }); + if ui.add(egui::Button::selectable(d.active, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + let _ = app.run("view.switchWindows", json!({"id": d.id})); + ui.close(); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) + } + + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(2560.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ) + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// Mod+N with text typed opens a second document beside the first; Switch Windows lists both + /// and brings the first back with its text. + #[test] + fn new_keeps_the_typed_document_and_switch_windows_brings_it_back() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.run("text.insert", json!({"text": "First draft"})).unwrap(); + let first = a.session.document_id(); + let mut h = harness(a); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::N); + h.run_steps(2); + assert_ne!(h.state().session.document_id(), first, "Mod+N shows a new document"); + assert_eq!(text(h.state()), ""); + assert!(h.state().sent_title.starts_with("Document2"), "{}", h.state().sent_title); + + h.state_mut().run("ui.tab", json!({"tab": "View"})).unwrap(); + h.run_steps(2); + h.get_by_label("Switch Windows").click(); + h.run_steps(2); + use egui::accesskit::Role; + assert!(h.query_by_role_and_label(Role::Button, "Document1 •").is_some(), "the first document is listed, unsaved"); + assert!(h.query_by_role_and_label(Role::Button, "Document2").is_some(), "the new one is listed"); + h.get_by_role_and_label(Role::Button, "Document1 •").click(); + h.run_steps(2); + let a = h.state(); + assert_eq!(a.session.document_id(), first); + assert_eq!(text(a), "First draft"); + assert_eq!(a.session.documents().len(), 2); + assert!(a.sent_title.starts_with("Document1"), "{}", a.sent_title); + } + + /// Close with two documents open asks about the shown one's changes; after Don't Save the + /// other one shows. + #[test] + fn close_asks_then_shows_the_other_document() { + let mut a = app(); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let kept = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(prompt(&a).is_none(), "New asks nothing: nothing is replaced"); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(a.session.document_id(), kept); + assert_eq!(text(&a), "Kept"); + assert_eq!(a.session.documents().len(), 1); + assert!(a.has_document() && !a.quit_requested); + } + + /// Closing the window with a document of unsaved changes behind the shown one asks about it + /// (showing it); Cancel keeps the app open, and Don't Save quits. + #[test] + fn quitting_asks_about_a_document_that_is_not_shown() { + let mut a = app(); + a.run("text.insert", json!({"text": "Behind"})).unwrap(); + let behind = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(!a.close_requested()); + assert_eq!(a.session.document_id(), behind, "the unsaved document is shown"); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!(a.session.documents().len(), 2, "nothing closed"); + + assert!(!a.close_requested(), "asked again"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + assert!(a.close_requested()); + } + + /// A request an agent's command makes for a document that isn't shown never acts on the + /// shown one: Print doesn't open Backstage over it. + #[test] + fn requests_for_a_document_not_shown_leave_the_window_alone() { + let mut a = app(); + let first = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert!(!a.ui.backstage, "Print for the other document left the window alone"); + } + + #[test] + fn untitled_names_follow_the_interface_language() { + assert_eq!(super::untitled_title("Document3").as_deref(), Some("Document3")); + assert_eq!(super::untitled_title("Documents"), None); + assert_eq!(super::untitled_title("Report"), None); + } +} diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index f40ebf1867..2ccb8530f1 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -315,8 +315,12 @@ impl WordApp { r } AfterPick::SaveAs { after, document } => { + // The document it was for may wait behind another one: it is shown and saved. if document != self.session.document_id() { - return Err("the document for this save has been replaced".into()); + if !self.session.is_open(document) { + return Err("the document for this save has been closed".into()); + } + self.show_document(document)?; } // The save's error, kept for the Save Changes prompt to explain (#1445). A plain // Save As (Backstage's Browse…, AutoSave) has no prompt to carry it to, so it was @@ -601,7 +605,8 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } - /// A late Save As answer belongs to the imported document that opened the picker. + /// A late Save As answer belongs to the imported document that opened the picker: once that + /// document is closed, it writes nothing. #[test] fn imported_word_stale_save_as_never_writes_the_replacement_document() { for close in [false, true] { @@ -623,8 +628,8 @@ mod tests { } assert!(a.file_dialog_open()); let previous = a.session.document_id(); - // Automation bypasses the user prompt and can replace a document during a picker. - a.execute("file.new", json!({})).unwrap(); + // Automation bypasses the user prompt and can close a document during a picker. + a.execute("file.closeDocument", json!({"force": true})).unwrap(); typed(&mut a); assert_ne!(previous, a.session.document_id()); let draft = a.session.doc.clone(); @@ -960,14 +965,14 @@ mod tests { } /// "Save changes?" → Save on a new document: the held-back Close runs only once the later - /// Save As has actually saved; a cancelled Save As cancels the held-back New. + /// Save As has actually saved; a cancelled Save As cancels it. #[test] fn save_changes_waits_for_the_save_as_answer() { let dir = scratch("prompt"); let path = dir.join("novel.docx"); let (mut a, shown) = async_app(); typed(&mut a); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); answer(&shown, None); a.poll_file_dialog(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f93229681e..4b589cf50f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2761,6 +2761,9 @@ Deep Наситено Lit from Bottom Осветено отдолу Center Glow Сияние от центъра + Close Document Затваряне на документа More tabs Още раздели # #3013: Insert Table of Figures Include label and number Включване на етикета и номера + Switch Windows Превключване на прозорци + Switch\nWindows Превключване\nна прозорци diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d8c94f9b1..e97f27a375 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2763,6 +2763,9 @@ preview Title Ukázka Deep Sytý Lit from Bottom Osvětlený zdola Center Glow Záře ze středu + Close Document Zavřít dokument More tabs Další karty # #3013: Insert Table of Figures Include label and number Zahrnout popisek a číslo + Switch Windows Přepnout okna + Switch\nWindows Přepnout\nokna diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 418fa3670a..2ad0f6e2c1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2509,6 +2509,7 @@ preview Title Titel Document name Dokumentnavn Rename Omdøb Account Konto + Close Document Luk dokument Privacy Options Indstillinger for beskyttelse af personlige oplysninger Trust Center Center for sikkerhed og beskyttelse Privacy for this document Beskyttelse af personlige oplysninger for dette dokument @@ -2598,4 +2599,6 @@ preview Title Titel Format Shape Pane Format Shape Pane Full Screen View Full Screen View Personal Templates Personal Templates + Switch Windows Skift vinduer + Switch\nWindows Skift\nvinduer Underline Color Underline Color diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec04156c07..3650328694 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2359,6 +2359,7 @@ Deep Tief Lit from Bottom Von unten beleuchtet Center Glow Leuchten aus der Mitte + Close Document Dokument schließen # File › Home greeting Good morning Guten Morgen Good afternoon Guten Tag @@ -2366,3 +2367,5 @@ More tabs Weitere Registerkarten # #3013: Insert Table of Figures Include label and number Bezeichnung und Nummer einbeziehen + Switch Windows Fenster wechseln + Switch\nWindows Fenster\nwechseln diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 85d4fd96b0..ff5a74ead6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2726,6 +2726,9 @@ preview Title Título Deep Profundo Lit from Bottom Iluminado desde abajo Center Glow Brillo central + Close Document Cerrar documento More tabs Más pestañas # #3013: Insert Table of Figures Include label and number Incluir rótulo y número + Switch Windows Cambiar ventanas + Switch\nWindows Cambiar\nventanas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 4c41381344..5b17c5de79 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2394,6 +2394,7 @@ Deep Sügav Lit from Bottom Alt valgustatud Center Glow Keskne kuma + Close Document Sulge dokument # File › Home greeting Good morning Tere hommikust Good afternoon Tere päevast @@ -2401,3 +2402,5 @@ More tabs Veel vahekaarte # #3013: Insert Table of Figures Include label and number Lisa silt ja number + Switch Windows Vaheta aknaid + Switch\nWindows Vaheta\naknaid diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c6ca31e47c..21d152e361 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2772,6 +2772,9 @@ template Letter Lettre Deep Profond Lit from Bottom Éclairé par le bas Center Glow Halo central + Close Document Fermer le document More tabs Plus d'onglets # #3013: Insert Table of Figures Include label and number Inclure l’étiquette et le numéro + Switch Windows Changer de fenêtre + Switch\nWindows Changer de\nfenêtre diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 1bdb72d520..369ccc6e00 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2771,6 +2771,9 @@ preview Title Naslov Deep Duboko Lit from Bottom Osvijetljeno odozdo Center Glow Sjaj u središtu + Close Document Zatvori dokument More tabs Više kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + Switch Windows Promijeni prozore + Switch\nWindows Promijeni\nprozore diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53c147b29d..2c547d0fdc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2767,6 +2767,9 @@ Deep Mély Lit from Bottom Alulról megvilágított Center Glow Középső ragyogás + Close Document Dokumentum bezárása More tabs További lapok # #3013: Insert Table of Figures Include label and number Címkével és számmal + Switch Windows Ablakváltás + Switch\nWindows Ablak-\nváltás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 832be6ed56..cd7d52caf1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2783,6 +2783,7 @@ preview Title Esempio # Paper sizes, Go To, bookmarks and print labels the Croatian, Swedish and Vietnamese catalogs added B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Close Document Chiudi documento Executive 7.25" × 10.50" Executive 7,25" × 10,50" Legal 8.50" × 14.00" Legal 8,50" × 14,00" Letter 8.50" × 11.00" Letter 8,50" × 11,00" @@ -2799,3 +2800,5 @@ template Letter Lettera More tabs Altre schede # #3013: Insert Table of Figures Include label and number Includi etichetta e numero + Switch Windows Cambia finestra + Switch\nWindows Cambia\nfinestra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0bb1ed700..3820f1bd62 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2360,6 +2360,7 @@ Deep ディープ Lit from Bottom 下から照明 Center Glow 中央の光彩 + Close Document 文書を閉じる # File › Home greeting Good morning おはようございます Good afternoon こんにちは @@ -2367,3 +2368,5 @@ More tabs その他のタブ # #3013: Insert Table of Figures Include label and number ラベルと番号を含める + Switch Windows ウィンドウの切り替え + Switch\nWindows ウィンドウの\n切り替え diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index aea928d13e..f75d54cfd7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2614,6 +2614,9 @@ preview Title 제목 Deep 진하게 Lit from Bottom 아래에서 조명 Center Glow 가운데 광선 + Close Document 문서 닫기 More tabs 탭 더 보기 # #3013: Insert Table of Figures Include label and number 레이블 및 번호 포함 + Switch Windows 창 전환 + Switch\nWindows 창\n전환 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 85421447df..ae4fd52cb5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2601,6 +2601,7 @@ preview Title Titel AutoFit to window Aanpassen aan venster Chapter 1 Hoofdstuk 1 Chicago Chicago + Close Document Document sluiten Commas Komma's Distance from text Afstand tot tekst Distance from text: Afstand tot tekst: @@ -2668,4 +2669,6 @@ preview Title Titel Format Shape Pane Format Shape Pane Full Screen View Full Screen View Personal Templates Personal Templates + Switch Windows Wisselen tussen vensters + Switch\nWindows Wisselen tussen\nvensters Underline Color Underline Color diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4197f85cdc..8c7d03c48c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2783,6 +2783,7 @@ preview Title Tytuł # English text until translated (keys the Italian catalog has) B4 (JIS) 10.12" × 14.33" B4 (JIS) 10.12" × 14.33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7.17" × 10.12" + Close Document Zamknij dokument Enter bookmark name: Enter bookmark name: Envelope #10 4.12" × 9.50" Envelope #10 4.12" × 9.50" Envelope DL 4.33" × 8.66" Envelope DL 4.33" × 8.66" @@ -2791,6 +2792,8 @@ preview Title Tytuł Legal 8.50" × 14.00" Legal 8.50" × 14.00" Letter 8.50" × 11.00" Letter 8.50" × 11.00" Statement 5.50" × 8.50" Statement 5.50" × 8.50" + Switch Windows Przełącz okna + Switch\nWindows Przełącz\nokna Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" This document has no bookmarks. This document has no bookmarks. Type a page number, or +2 / -1. Type a page number, or +2 / -1. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 35486428f4..8e39f0fa1e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2372,6 +2372,7 @@ Deep Profundo Lit from Bottom Iluminado de baixo Center Glow Brilho central + Close Document Fechar Documento # File › Home greeting Good morning Bom dia Good afternoon Boa tarde @@ -2379,3 +2380,5 @@ More tabs Mais guias # #3013: Insert Table of Figures Include label and number Incluir rótulo e número + Switch Windows Alternar Janelas + Switch\nWindows Alternar\nJanelas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 1f1098962c..b018953937 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2693,6 +2693,7 @@ template Letter Scrisoare Add gradient stop Add gradient stop Angle Angle Center Glow Center Glow + Close Document Închidere document Continue from previous section Continue from previous section Dark Variations Dark Variations Deep Deep @@ -2756,6 +2757,8 @@ template Letter Scrisoare Show the ribbon Show the ribbon Soft Soft Solid fill Solid fill + Switch Windows Comutare ferestre + Switch\nWindows Comutare\nferestre Templates you save in your templates folder show up here. Templates you save in your templates folder show up here. Underline Color Underline Color User templates: User templates: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 48b77c61e7..4e157029b8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2770,6 +2770,9 @@ preview Title Пример Deep Глубокий Lit from Bottom Освещение снизу Center Glow Свечение из центра + Close Document Закрыть документ More tabs Другие вкладки # #3013: Insert Table of Figures Include label and number Включить подпись и номер + Switch Windows Перейти в другое окно + Switch\nWindows Перейти в\nдругое окно diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 8bf77d27ae..5adf8740f4 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2645,6 +2645,7 @@ preview Title Naslov AutoFit to window AutoFit to window Center Glow Center Glow Chapter 1 Chapter 1 + Close Document Zapri dokument Commas Commas Continue from previous section Continue from previous section Dark Variations Dark Variations @@ -2740,6 +2741,8 @@ preview Title Naslov Soft Soft Solid fill Solid fill Spike Spike + Switch Windows Preklopi okna + Switch\nWindows Preklopi\nokna Table size Table size Templates you save in your templates folder show up here. Templates you save in your templates folder show up here. The measurement must be between {min} and {max}. The measurement must be between {min} and {max}. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 65e5fd9e83..409cab6ed2 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2370,6 +2370,7 @@ Deep Dubok Lit from Bottom Osvetljen odozdo Center Glow Sjaj iz sredine + Close Document Zatvori dokument # File › Home greeting Good morning Dobro jutro Good afternoon Dobar dan @@ -2377,3 +2378,5 @@ More tabs Još kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + Switch Windows Promeni prozore + Switch\nWindows Promeni\nprozore diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 910aa9b739..54a35e144d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2374,6 +2374,7 @@ Deep Дубок Lit from Bottom Осветљен одоздо Center Glow Сјај из средине + Close Document Затвори документ # File › Home greeting Good morning Добро јутро Good afternoon Добар дан @@ -2381,3 +2382,5 @@ More tabs Још картица # #3013: Insert Table of Figures Include label and number Укључи ознаку и број + Switch Windows Промени прозоре + Switch\nWindows Промени\nпрозоре diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f1304d4c7e..4859483e62 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2792,6 +2792,7 @@ template Letter Brev 📂 Browse… 📂 Bläddra… B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Close Document Stäng dokument Envelope #10 4.12" × 9.50" Kuvert #10 4,12" × 9,50" Envelope DL 4.33" × 8.66" Kuvert DL 4,33" × 8,66" Executive 7.25" × 10.50" Executive 7,25" × 10,50" @@ -2802,3 +2803,5 @@ template Letter Brev More tabs Fler flikar # #3013: Insert Table of Figures Include label and number Ta med etikett och nummer + Switch Windows Växla fönster + Switch\nWindows Växla\nfönster diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b56c6f27fa..66b8185664 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2769,6 +2769,9 @@ preview Title Örnek Deep Koyu Lit from Bottom Alttan aydınlatılmış Center Glow Merkez parıltısı + Close Document Belgeyi Kapat More tabs Diğer sekmeler # #3013: Insert Table of Figures Include label and number Etiketi ve numarayı ekle + Switch Windows Pencere Değiştir + Switch\nWindows Pencere\nDeğiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index be03ee7f95..705f3df67d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2372,6 +2372,7 @@ Deep Глибокий Lit from Bottom Освітлення знизу Center Glow Сяйво з центру + Close Document Закрити документ # File › Home greeting Good morning Доброго ранку Good afternoon Добрий день @@ -2379,3 +2380,5 @@ More tabs Інші вкладки # #3013: Insert Table of Figures Include label and number Додавати мітку й номер + Switch Windows Перейти до іншого вікна + Switch\nWindows Перейти до\nіншого вікна diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5c7324cc2f..422af6c6dd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2811,6 +2811,9 @@ template Letter Thư Deep Sâu Lit from Bottom Chiếu sáng từ dưới Center Glow Phát sáng ở giữa + Close Document Đóng tài liệu More tabs Các tab khác # #3013: Insert Table of Figures Include label and number Bao gồm nhãn và số + Switch Windows Chuyển cửa sổ + Switch\nWindows Chuyển\ncửa sổ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eda14a5304..d80810263e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2372,6 +2372,7 @@ Deep 深沉 Lit from Bottom 底部照亮 Center Glow 中心光晕 + Close Document 关闭文档 # File › Home greeting Good morning 早上好 Good afternoon 下午好 @@ -2379,3 +2380,5 @@ More tabs 更多选项卡 # #3013: Insert Table of Figures Include label and number 包含标签和编号 + Switch Windows 切换窗口 + Switch\nWindows 切换\n窗口 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c0e4756a01..6082a0e19f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2361,6 +2361,7 @@ Deep 深沉 Lit from Bottom 底部照亮 Center Glow 中央光暈 + Close Document 關閉文件 # File › Home greeting Good morning 早安 Good afternoon 午安 @@ -2368,3 +2369,5 @@ More tabs 更多索引標籤 # #3013: Insert Table of Figures Include label and number 包含標籤和編號 + Switch Windows 切換視窗 + Switch\nWindows 切換\n視窗 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5dede3611d..fa3163fe31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,7 @@ pub mod dialogs_ribbon; pub mod dialogs_smart_art; pub mod dialogs_table_style; pub mod dialogs_templates; +pub mod documents; pub mod equation_tab; /// The built-in file browser, for desktops without a system file dialog (#576). #[cfg(not(target_arch = "wasm32"))] @@ -659,6 +660,12 @@ pub struct WordApp { no_document: Option, /// Comment and reply text typed but not yet written to the document (#1348). pub(crate) comment_drafts: panes::CommentDrafts, + /// The document the window's per-document state belongs to; when the session's active + /// document is another, [`WordApp::sync_document`] puts that state aside and brings the + /// other's. + shown_document: u64, + /// The per-document state of the open documents not shown. + parked: std::collections::HashMap, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -686,6 +693,7 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } + let session_document = session.document_id(); WordApp { session, ui: UiState::default(), @@ -760,6 +768,8 @@ impl WordApp { recovery_seen: false, no_document: None, comment_drafts: Default::default(), + shown_document: session_document, + parked: Default::default(), } } @@ -1065,17 +1075,13 @@ impl WordApp { // Its HTML too when it has some (an equation as MathML, #816). crate::system_clipboard::put_result(self, ctx, v); } - // 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. + // Another document is shown (not when Open only showed its picker): the window's state + // for the one it showed is put aside. if self.session.document_id() != document { // A requested New/Open wins over the asynchronous startup recovery scan. // Older copies remain available through File > Recover. self.recovery_seen = true; - self.autosave_path = None; - if matches!(self.dialog, Some(dialogs::Dialog::SaveChanges { .. })) { - self.dialog = None; - } - crate::table_pen::stop(self); + self.sync_document(); } // An explicit save to the document's own file lets AutoSave keep writing there. if let Ok(v) = &r @@ -1294,6 +1300,9 @@ impl WordApp { async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => { if save.marked_saved && save.document == current { self.session.dirty = true; + } else if save.marked_saved { + // Another document is shown: the one saved is unsaved again where it waits. + let _ = self.session.with_document(save.document, |s| s.dirty = true); } } } @@ -1362,33 +1371,46 @@ impl WordApp { return true; } // A comment draft the document refuses is an unsaved change: the prompt can drop it. - let refused = panes::flush_comment_drafts(self).is_err(); - if !self.session.dirty && !self.save_unconfirmed() && !refused { + let _ = panes::flush_comment_drafts(self); + if !self.has_unsaved_documents() { return true; } - // `quit`: once the prompt is answered the window closes too, not just the document. - let _ = self.run("file.close", json!({"quit": true})); - false + // Each document with unsaved work is shown and asked about in turn; Cancel stops. + self.ask_before_quit(); + self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the document and keeps the window, as Word does - /// (#1712): it shows no page, as after Esc on the Start screen (#1129), and File › New or Open - /// goes on from there. `{"quit": true}`, from the window's close button, quits instead. With no - /// document open there is nothing to close. Unsaved changes are never discarded unasked: a - /// dirty document closes only with `{"force": true}` (Don't Save in the prompt passes it). + /// File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, as Word does + /// (#1712): the next open document shows, or, when it was the only one, no page, as after Esc + /// on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// from the window's close button, goes on quitting: the shown document's prompt has been + /// answered, so it closes and the next document with unsaved changes is asked about + /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved + /// changes are never discarded unasked: a dirty document closes only with `{"force": true}` + /// (Don't Save in the prompt passes it). fn close_document(&mut self, params: &Value) -> Result { if let Some(e) = control::close_refused(self, "file.close", params) { return Err(e.into()); } + let last = self.session.documents().len() <= 1; if params.get("quit").and_then(Value::as_bool) == Some(true) { - self.quit_requested = true; - return Ok(json!({"quit": true})); + if last { + self.quit_requested = true; + return Ok(json!({"quit": true})); + } + self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.sync_document(); + return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.execute("file.new", json!({}))?; - self.no_document = Some(self.session.document_id()); + self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.recovery_seen = true; + self.sync_document(); + if last { + self.no_document = Some(self.session.document_id()); + } self.ui.backstage = false; Ok(json!({"closed": true})) } @@ -1439,6 +1461,13 @@ impl WordApp { /// Requests commands make of the UI (open a dialog…). fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there). + if let Some(document) = req.get("document").and_then(Value::as_u64) + && document != self.session.document_id() + { + self.handle_request_for(document, req); + return; + } // Ctrl+F: type straight into the Navigation pane's search box (#712). if req.get("focus").and_then(Value::as_str) == Some("navSearch") { self.nav_search_focus = true; @@ -1484,6 +1513,33 @@ impl WordApp { } } + /// A request made for document `document`, which isn't shown: the interface theme is the + /// app's and changes; Close closes that document if it has nothing unsaved; the rest (dialogs, + /// panes, focus, scrolling) are about the window's page and are dropped, as the page shows + /// another document. + fn handle_request_for(&mut self, document: u64, req: &Value) { + let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if theme_only { + let mut req = req.clone(); + if let Some(o) = req.as_object_mut() { + o.remove("document"); + } + self.handle_request(&req); + return; + } + if req.get("close").is_some() { + let dirty = self.session.documents().iter().any(|d| d.id == document && d.dirty); + if dirty { + log::warn!("not closing document {document}: it has unsaved changes"); + } else if let Err(e) = self.session.close_document(document) { + log::warn!("closing document {document}: {e}"); + } + self.sync_document(); + return; + } + log::info!("dropped a request for document {document}, which isn't shown: {req}"); + } + /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no /// system theme; see [`desktop_theme`]). The desktop app calls this once at start-up; unit /// tests and `ui_shot` don't, so what they draw doesn't depend on the host's desktop theme. @@ -2054,7 +2110,9 @@ impl WordApp { Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()), None => match &self.session.file_name { Some(name) => name.clone(), - None if self.session.doc.core.title.is_empty() => tl!("Document1").into(), + None if self.session.doc.core.title.is_empty() => { + documents::untitled_title(&self.session.resolved_filenames().0).unwrap_or_else(|| tl!("Document1").into()) + } None => self.session.doc.core.title.clone(), }, } @@ -2177,13 +2235,13 @@ impl WordApp { self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); + // A script, an agent or a later result may have made another document the active one. + self.sync_document(); let document = self.session.document_id(); self.session.recovery_tick(); if self.session.document_id() != document { - self.autosave_path = None; - self.dialog = None; self.ui.backstage = false; - self.canvas.want_focus = true; + self.sync_document(); } if !self.recovery_seen && self.session.recovery_ready() { self.recovery_seen = true; @@ -2830,17 +2888,13 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that replace or close the document (New, also from a template; `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`) and an envelope added to this -/// document (`add`) leave it alone. +/// User commands that close or replace the shown document: Close, and restoring a recovered +/// copy in its place. New, Open, Envelopes, Labels and Finish & Merge open their document beside +/// it (Open replaces only an untouched blank document, which has nothing to lose). pub(crate) fn discards_document(id: &str, params: &Value) -> bool { match id { - "file.new" | "file.newFromTemplate" | "file.close" | "mailings.labels" => true, - "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), - "file.open" => params.get("path").is_some(), + "file.close" => true, "file.recover" => params.get("restore").is_some(), - "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), _ => false, } } @@ -5351,27 +5405,19 @@ mod tests { path } - /// Surfaces finding 1: Mod+N (and the File › New tiles) replaced unsaved work without asking. + /// Mod+N (and the File › New tiles) open a new document beside unsaved work, asking nothing: + /// nothing is replaced. #[test] - fn new_asks_before_discarding_unsaved_work() { + fn new_opens_beside_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. + let first = a.session.document_id(); 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"); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "the unsaved document is still open"); } #[test] @@ -5395,9 +5441,8 @@ mod tests { 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"); + assert!(body_text(&a).contains(UNSAVED), "nothing opens until the password fits"); let state = serde_json::to_string(&a.dialog).unwrap(); assert!(state.contains("locked.docx") && !state.contains("sesame")); @@ -5410,20 +5455,24 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Open (after the file is picked), recent files and dropped files all go through `file.open`. + /// Open (after the file is picked), recent files and dropped files all go through `file.open`: + /// the file opens beside unsaved work, asking nothing, and opening it again shows it. #[test] - fn open_asks_before_discarding_unsaved_work() { + fn open_opens_beside_unsaved_work() { let dir = scratch("open"); let path = docx_on_disk(&dir, "Signed contract"); let mut a = typed(); + let first = a.session.document_id(); 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_eq!(prompt(&a), None); assert!(body_text(&a).contains("Signed contract")); assert_eq!(a.session.path.as_deref(), Some(path.as_path())); + let opened = a.session.document_id(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(a.session.document_id(), opened, "an open file is shown, not opened twice"); + assert_eq!(a.session.documents().len(), 2); let _ = std::fs::remove_dir_all(&dir); } @@ -6144,15 +6193,15 @@ mod tests { } } - /// 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. + /// A prompt about document A outlived an agent showing B instead, and Don't Save then ran + /// the pending Close 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(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let asked = a.dialog.clone(); for command in [ @@ -6711,11 +6760,11 @@ mod tests { 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(); + a.run("file.close", 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"); + assert!(!a.has_document(), "the saved document closed"); 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); @@ -6730,10 +6779,10 @@ mod tests { 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.run("file.close", 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_eq!(r["done"], false, "{ext}: the Close 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"); @@ -6818,9 +6867,9 @@ mod tests { assert!(a.execute("file.save", json!({})).is_err()); assert!(a.session.dirty); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; - // A failed save is an error, not a quiet Cancel (#1445); the New doesn't run. + // A failed save is an error, not a quiet Cancel (#1445); the Close doesn't run. let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}"); assert!(body_text(&a).contains(UNSAVED)); @@ -6845,21 +6894,21 @@ mod tests { (a, saves, last) } - /// #1315: Save in the prompt before New waits for the picker: New runs only once the file + /// #1315: Save in the prompt before Close waits for the picker: Close runs only once the file /// is written and closed. #[test] - fn web_save_before_new_waits_for_the_picker_to_finish() { + fn web_save_before_close_waits_for_the_picker_to_finish() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(r["pending"], "save", "{r}"); assert_eq!(saves.pending(), 1); a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing is replaced while the picker is open"); + assert!(body_text(&a).contains(UNSAVED), "nothing closes while the picker is open"); saves.finish(last.get(), async_saves::SaveOutcome::Saved); a.settle_async_saves(); - assert!(!body_text(&a).contains(UNSAVED), "the New ran once the save finished"); + assert!(!body_text(&a).contains(UNSAVED), "the Close ran once the save finished"); assert!(!a.session.dirty); assert_eq!(prompt(&a), None); } @@ -6867,12 +6916,12 @@ mod tests { /// #1315: cancelling the picker, or a write/close that fails, keeps the document: its text, /// its undo, still unsaved, and the prompt back with the reason. Nothing is replaced. #[test] - fn web_save_before_new_that_is_cancelled_or_fails_keeps_the_document() { + fn web_save_before_close_that_is_cancelled_or_fails_keeps_the_document() { for (outcome, reason) in [(async_saves::SaveOutcome::Cancelled, "cancel"), (async_saves::SaveOutcome::Failed("the disk is full".into()), "the disk is full")] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.status_msg = None; saves.finish(last.get(), outcome.clone()); @@ -6892,11 +6941,11 @@ mod tests { /// #1315 review: the prompt's Save is in the picker (ticket 1), the user edits and saves /// again (ticket 2, which marks the document saved at once). Ticket 1 ending doesn't let the - /// held-back New replace the document while ticket 2 hasn't written its edits. + /// held-back Close close the document while ticket 2 hasn't written its edits. #[test] fn web_overlapping_saves_hold_the_replacement() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let first = last.get(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); @@ -6909,11 +6958,11 @@ mod tests { } /// #1315 review: edited while the picker was open, then the save ends: those edits aren't - /// in the file, so the prompt asks again instead of running New. + /// in the file, so the prompt asks again instead of running Close. #[test] fn web_save_that_ends_after_an_edit_asks_again() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); saves.finish(last.get(), async_saves::SaveOutcome::Saved); @@ -6924,12 +6973,12 @@ mod tests { assert_eq!(prompt_message(&a).as_deref(), Some("")); } - /// #1315 review: the status bar says New/Open is waiting for the save; a cancelled picker + /// #1315 review: the status bar says Close is waiting for the save; a cancelled picker /// says it was cancelled, not that saving failed. #[test] fn web_save_prompt_says_it_waits_and_that_a_cancel_was_a_cancel() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); @@ -6941,62 +6990,61 @@ mod tests { assert!(!message.contains("Couldn't save"), "a cancel isn't a failure: {message}"); } - /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) waits - /// for the save too, then opens. + /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) opens + /// beside one whose save is still in the picker, asking nothing. #[test] - fn web_save_before_open_opens_the_file_once_saved() { + fn web_open_opens_beside_a_save_in_flight() { let dir = scratch("web-open-after-save"); let bytes = std::fs::read(docx_on_disk(&dir, "Picked document")).unwrap(); - let (mut a, saves, last) = web_picker_app(); + let (mut a, _saves, _last) = web_picker_app(); + let first = a.session.document_id(); + a.execute("file.save", json!({})).unwrap(); let data = wordcraft_engine::cmd::insert::base64_encode(&bytes); a.run("file.open", json!({"path": "picked.docx", "data": data})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing opened while the picker is open"); - saves.finish(last.get(), async_saves::SaveOutcome::Saved); - a.settle_async_saves(); + assert_eq!(prompt(&a), None); assert!(body_text(&a).contains("Picked document"), "the picked file opened: {:?}", body_text(&a)); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED), "the first document is still open"); let _ = std::fs::remove_dir_all(&dir); } - /// #1315: while the picker save is in flight the document's save isn't confirmed, so New - /// asks again instead of replacing it. + /// #1315: while the picker save is in flight the document's save isn't confirmed, so Close + /// asks again instead of closing it. #[test] - fn web_new_while_a_picker_save_is_in_flight_asks_first() { + fn web_close_while_a_picker_save_is_in_flight_asks_first() { let (mut a, _saves, _last) = web_picker_app(); a.execute("file.save", json!({})).unwrap(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); assert!(body_text(&a).contains(UNSAVED), "nothing was replaced"); } - /// #1315: a save that ends after the document was replaced neither runs the held-back New on - /// the new document nor marks it unsaved. + /// #1315: a save that ends after another document is shown neither runs the held-back Close + /// on that document nor marks it unsaved. #[test] fn web_picker_save_ending_after_the_document_changed_leaves_the_new_one_alone() { for outcome in [async_saves::SaveOutcome::Saved, async_saves::SaveOutcome::Failed("gone".into())] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - // Another document replaces it (an agent's or a script's command) while the picker is open. + // Another document is shown (an agent's or a script's command) while the picker is open. a.execute("file.new", json!({})).unwrap(); a.execute("text.insert", json!({"text": "Second document"})).unwrap(); a.session.dirty = false; saves.finish(last.get(), outcome.clone()); a.settle_async_saves(); - assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back New didn't run on it"); + assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back Close didn't run on it"); assert!(!a.session.dirty, "{outcome:?}: the new document isn't marked unsaved"); assert_eq!(prompt(&a), None, "{outcome:?}"); } } - /// A new document has no path: Save goes through Save As, and cancelling that cancels the New. + /// A new document has no path: Save goes through Save As, and cancelling that cancels the Close. #[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("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(prompt(&a), None); assert!(body_text(&a).contains(UNSAVED)); @@ -7100,35 +7148,21 @@ mod tests { } } - /// 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. + /// Envelopes, Labels and Finish & Merge open their result as a new document beside the + /// unsaved one, like New, asking nothing. #[test] - fn mailings_ask_before_replacing_unsaved_work() { + fn mailings_open_beside_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, mailing(id)).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, mailing(id)).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!(prompt(&a), None, "{id}"); + assert_ne!(a.session.document_id(), document, "{id}: the result is a new document"); assert_eq!(a.session.path, None); + a.run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}: the unsaved document is still open"); } - // 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 @@ -7157,7 +7191,7 @@ mod tests { } /// 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. + /// file the original came from. #[test] fn mailings_results_never_save_over_the_original() { for id in MAILINGS { @@ -7170,14 +7204,9 @@ mod tests { assert!(a.autosaves()); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); a.run("text.insert", json!({"text": "Again "})).unwrap(); - a.run(id, mailing(id)).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" - ); + a.run(id, mailing(id)).unwrap(); + assert_eq!(prompt(&a), None); assert_eq!(a.session.path, None, "{id}"); assert!(!a.saved_here() && !a.autosaves(), "{id}: AutoSave doesn't cover the result"); @@ -7210,26 +7239,28 @@ mod tests { } } - /// 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. + /// Review finding: an agent's Undo while a prompt about the mailing result B was up must not + /// bring document A back under B, where Don't Save would throw A away. The result is its + /// own document: Undo stays in B, and Don't Save closes B and shows A, unsaved work intact. #[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(); + let first = a.session.document_id(); 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_ne!(b, first, "{id}"); + a.run("text.insert", json!({"text": "Edited result "})).unwrap(); + a.run("file.close", 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"); + assert!(!a.session.is_open(b), "{id}: Don't Save closed the document it asked about"); + assert_eq!(a.session.document_id(), first, "{id}"); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}"); } } @@ -8637,10 +8668,11 @@ mod tests { assert!(!a.recipient_list_pending); inbox.lock().unwrap().push(("notes.txt".into(), b"A plain text document.\nAnother line.\n".to_vec())); a.drain_inbox(); - assert_eq!(prompt(&a), Some("saveChanges"), "the TXT opens like any document"); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay"); + assert_eq!(prompt(&a), None, "the TXT opens like any document, beside the first"); assert!(body_text(&a).contains("A plain text document."), "the TXT opened as a document"); + let first = a.session.documents().first().map(|d| d.id).unwrap(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay with their document"); // A cancelled picker leaves nothing pending once the user does something else. a.run("ui.openRecipientList", json!({})).unwrap(); a.run("format.bold", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fa37d7a940..3b9afb6113 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -514,6 +514,11 @@ impl CommentDrafts { } self } + + /// Nothing typed and not yet written. + pub(crate) fn is_empty(&self) -> bool { + self.edits.is_empty() && self.replies.is_empty() + } } /// The pending draft of comment `id`'s text in the open document, if any. diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index ddbe273c3e..721776f829 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -296,6 +296,11 @@ impl Previews { Some(h) } + /// Another document is shown: its revisions count apart from the last one's. + pub(crate) fn document_changed(&mut self) { + self.styles_hash = (u64::MAX, 0); + } + /// Hash of the style sheet (recomputed only when the document revision changes). fn styles_key(&mut self, app_doc: &Document, rev: u64) -> u64 { if self.styles_hash.0 != rev { diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 0d3d0fb251..8d77a1bab0 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -29,7 +29,8 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { app.read_aloud_error = Some(e.clone()); app.status(e); } - let current = app.session.read_aloud.current().map(|u| u.start); + // The caret follows the reading only in the document being read. + let current = if reads_shown_document(app) { app.session.read_aloud.current().map(|u| u.start) } else { None }; if current != app.read_aloud_at { if let Some(start) = ¤t { app.session.sel = wordcraft_engine::Selection::caret(app.session.doc.clamp(start)); @@ -42,13 +43,18 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// The page rectangles (in page units) of the sentence being read, to highlight. pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> { - if !app.session.read_aloud.open { + if !app.session.read_aloud.open || !reads_shown_document(app) { return None; } let u = app.session.read_aloud.current()?; Some((app.session.doc.clamp(&u.start), app.session.doc.clamp(&u.end))) } +/// Whether the reading is of the document the window shows: its sentence positions are in it. +fn reads_shown_document(app: &WordApp) -> bool { + app.session.read_aloud.document == Some(app.session.document_id()) +} + /// The player window. pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c741b85636..c2be6597c2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2262,6 +2262,16 @@ fn view(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); big(ui, app, "split", "Split", "view.split", json!({}), false); + menu_button( + ui, + app, + "switchWindows", + Some("Switch\nWindows"), + "Switch Windows", + true, + "view.switchWindows", + crate::documents::switch_windows_menu, + ); // The Full Screen button carries its name for assistive technology (and for the // regression test that clicks it): big buttons are otherwise drawn unlabeled. Its // enabled state is the command's own, as every other ribbon control reports: the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 555307d015..0116cfa331 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -35,7 +35,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-19 | trivial | Insert Table of Figures opens a Table of Figures dialog: caption label (Figure, Table, Equation, the user's own and those the document's captions use), Include label and number, page numbers, right margin and tab leader, with a preview; the ribbon button, KeyTip and command search reach it (#3013) | From a8e39fec5332d97aad320d064ed7a54e06762010 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:06:45 +0000 Subject: [PATCH 10/87] Engine: find_open resolves paths through io, inside the readable roots --- crates/engine/src/io.rs | 14 ++++++++++++++ crates/engine/src/session/documents.rs | 17 ++--------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 0c25931968..f1829db32d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -111,6 +111,20 @@ pub fn is_empty_file(_path: &std::path::Path) -> bool { false } +/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two +/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn canonical(path: &std::path::Path) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + std::fs::canonicalize(path).ok() +} + +/// [`canonical`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn canonical(_path: &std::path::Path) -> Option { + None +} + /// Can files with this extension be saved with a password (Word's packages)? pub fn can_encrypt(name: &str) -> bool { is_word_package(&ext_of(name)) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d27c791d68..27625a151f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -507,21 +507,8 @@ impl Session { /// The open document saved at `path`, if any: the same file reached by another spelling /// (`./a.docx`, a symbolic link) counts on desktop. pub fn find_open(&self, path: &FsPath) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let wanted = std::fs::canonicalize(path).ok(); - #[cfg(target_arch = "wasm32")] - let wanted: Option = None; - let same = |p: &FsPath| { - if p == path { - return true; - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(w) = &wanted { - return std::fs::canonicalize(p).ok().as_ref() == Some(w); - } - let _ = &wanted; - false - }; + let wanted = crate::io::canonical(path); + let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; self.order.iter().copied().find(|&id| { if id == self.document_id { self.path.as_deref().is_some_and(same) From 4480fd50d9dc09c0eb816066588d67d8eb3cc9c5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:26:38 +0000 Subject: [PATCH 11/87] W2: Recovery: every open document keeps its own copy One recovery worker and store serve every open document. Each document keeps its slot (record id, revision, timer) and is ticked in place, parked or active. Closing a document deletes its copy, Don't Save when quitting does too, and a clean exit covers every open document. File > Recover lists only copies no open document keeps; Restore opens a copy beside the open documents or in place of an untouched blank one. --- ROADMAP.md | 4 +- crates/engine/src/recovery.rs | 157 +++++-- crates/engine/src/recovery/desktop.rs | 593 ++++++++++++++++--------- crates/engine/src/session.rs | 17 +- crates/engine/src/session/documents.rs | 67 ++- crates/ui-egui/src/backstage.rs | 16 +- crates/ui-egui/src/lib.rs | 19 +- docs/recovery.md | 45 +- 8 files changed, 630 insertions(+), 288 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cd54d1c1a6..8f7ccadecc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. @@ -574,6 +575,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 3306a9d619..ce210f5acc 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -14,7 +14,9 @@ pub struct RecoveryInfo { #[cfg(not(target_arch = "wasm32"))] mod desktop; #[cfg(not(target_arch = "wasm32"))] -pub(crate) use desktop::Recovery; +use desktop::{Applied, Request}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use desktop::{DocRecovery, Recovery, Slot}; impl Session { /// The desktop host supplies the profile folder and a thread-safe wakeup. @@ -24,10 +26,13 @@ impl Session { self.recovery = Some(Recovery::start(root, wake)?); Ok(()) } + /// Copies left by an earlier run (or another window's documents that have closed), newest + /// first. The copies this session's open documents keep are not listed: they are not lost. pub fn recovery_documents(&self) -> Vec { #[cfg(not(target_arch = "wasm32"))] if let Some(r) = &self.recovery { - return r.documents.clone(); + let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); + return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); } Vec::new() } @@ -52,23 +57,19 @@ impl Session { } None } - /// A delayed restore can't replace edits made while it was loading. Restores have no path. + /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the + /// next job. Every open document's copy is kept, the parked ones' too, without making them + /// active: a frame compares each document's revision and timer, and clones a document only + /// when its copy is due. pub fn recovery_tick(&mut self) { #[cfg(not(target_arch = "wasm32"))] { let Some(mut r) = self.recovery.take() else { return }; - if let Some((snapshot, expected)) = r.poll() { - if expected == (self.document_id(), self.rev()) && self.password.is_none() { - let id = snapshot.info.id; - self.set_document(snapshot.document); - self.path = None; - self.dirty = true; - r.adopt(id, self.document_id(), self.rev()); - } else { - r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); - } + if let Some(e) = r.receive(false) { + self.apply_recovery(&mut r, e); } - if let Err(e) = r.tick(self, std::time::Instant::now()) { + self.open_held_restore(&mut r); + if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { r.error = Some(e); } if let Some(e) = &r.error { @@ -77,46 +78,119 @@ impl Session { self.recovery = Some(r); } } + #[cfg(not(target_arch = "wasm32"))] + fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { + match r.apply(e) { + // Opened by the next tick, never in the middle of a command. + Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), + applied => { + for d in self.recovery_docs() { + if let Applied::Failed { document, .. } = &applied + && *document != d.id + { + continue; + } + d.slot.apply(&applied); + } + } + } + } + /// A restored copy opens as another document, or replaces the untouched blank document that + /// asked for it once that document is active, so no work is ever replaced. The restored + /// document keeps its copy in the record it came from. + #[cfg(not(target_arch = "wasm32"))] + fn open_held_restore(&mut self, r: &mut Recovery) { + let Some((_, request)) = &r.held else { return }; + let request = *request; + if request.replace && self.is_open(request.document) && request.document != self.document_id() { + return; + } + let Some((snapshot, _)) = r.held.take() else { return }; + let id = snapshot.info.id.clone(); + if !request.replace { + self.add_document(snapshot.document); + } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { + self.set_document(snapshot.document); + self.path = None; + } else { + r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); + return; + } + self.dirty = true; + self.recovery_slot.adopt(id, self.rev()); + } pub fn recovery_wait(&self) -> Option { #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery - && self.dirty - && self.password.is_none() - && !r.busy - { - return r.wait(self.document_id(), self.rev()); + if let Some(r) = &self.recovery { + return r.wait(self.recovery_slots()); } None } - /// Waits for recovery to finish anything already under way, discarding this session's own - /// copy first if it no longer needs one (the document was saved, say). Desktop hosts call - /// this right before the process exits, the same as they wait out pending print jobs: - /// otherwise a save just before close can race the process exiting before a now-redundant - /// recovery copy actually gets removed, so the next launch wrongly offers to recover a - /// document that was in fact saved cleanly. + /// Waits for recovery to finish anything already under way, then deletes the copies no + /// open document needs: closed documents', and those of documents saved or protected since + /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the + /// process exits, the same as they wait out pending print jobs: otherwise a save just before + /// close can race the process exiting before a now-redundant recovery copy actually gets + /// removed, so the next launch wrongly offers to recover a document that was in fact saved + /// cleanly. pub fn finish_recovery(&mut self) { #[cfg(not(target_arch = "wasm32"))] if let Some(mut r) = self.recovery.take() { - if let Err(e) = r.finish(self) { + if let Some(e) = r.receive(true) { + self.apply_recovery(&mut r, e); + } + let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); + discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); + for (id, document) in discards { + match r.discard_now(&id, document) { + Ok(e) => self.apply_recovery(&mut r, e), + Err(e) => r.error = Some(e), + } + } + if let Some(e) = &r.error { log::warn!("recovery: {e}"); } self.recovery = Some(r); } } + /// The user discarded the active document's unsaved changes without closing it (Don't Save + /// when quitting): its recovery copy goes too, and another is made only after a further edit. + pub fn discard_recovery_copy(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &mut self.recovery + && let Some(id) = self.recovery_slot.id.take() + { + r.close(id); + self.recovery_slot.revision = Some(self.rev()); + } + } /// Only an explicit protect/save action waits for the worker and removes plaintext copies. /// A failure prevents applying the password; typing never waits. pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { #[cfg(not(target_arch = "wasm32"))] - { - let document = self.document_id(); - if let Some(r) = &mut self.recovery - && r.document_is(document) - { - r.purge().map_err(CmdError::Failed)?; - } + if let Some(mut r) = self.recovery.take() { + let result = self.purge_recovery(&mut r); + self.recovery = Some(r); + result.map_err(CmdError::Failed)?; } Ok(()) } + /// Waits out the job under way (it may be this document's copy being written), then deletes + /// the active document's copy. + #[cfg(not(target_arch = "wasm32"))] + fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { + if let Some(e) = r.receive(true) { + self.apply_recovery(r, e); + } + let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; + let e = r.discard_now(&id, Some(self.document_id()))?; + self.apply_recovery(r, e); + if self.recovery_slot.id.is_some() { + return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); + } + self.recovery_slot.revision = None; + Ok(()) + } pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { let restore = v.get("restore"); let discard = v.get("discard"); @@ -126,14 +200,19 @@ impl Session { if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { #[cfg(not(target_arch = "wasm32"))] { - let expected = (self.document_id(), self.rev()); + let request = + Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; + // Only listed copies: never one an open document keeps. + let listed = self.recovery_documents(); + let known = |id: Option<&str>, what: &str| -> Result { + let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; + if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } + }; let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; if let Some(id) = restore { - r.restore(id.as_str().ok_or_else(|| CmdError::Params("restore must be a recovery id".into()))?, expected) - .map_err(CmdError::Failed)?; + r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; } else if let Some(id) = discard { - r.discard(id.as_str().ok_or_else(|| CmdError::Params("discard must be a recovery id".into()))?, expected) - .map_err(CmdError::Failed)?; + r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { r.refresh().map_err(CmdError::Failed)?; } else { diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 92bbf7eee2..4570ddefb3 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -1,5 +1,4 @@ use super::RecoveryInfo; -use crate::Session; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; @@ -373,98 +372,175 @@ impl Write for Limited { } } enum Job { - Save(Box), + /// Write `document`'s copy. + Save(Box, u64), Scan, - Load(String, (u64, u64)), - Discard(String), + Load(String, Request), + /// Delete a record: an open document's copy it no longer needs, a closed document's, or one + /// from an earlier run that the user discarded (`None`). + Discard(String, Option), } enum Outcome { Done, Discarded(String), - Loaded(Box, (u64, u64)), + Loaded(Box, Request), } -struct Event { +pub(crate) struct Event { documents: Vec, outcome: Result, /// The failure would repeat for the same document; see [`SaveError`]. permanent: bool, + /// The open document a failed save or discard was for. + document: Option, + /// The record a failed discard was for. + discard: Option, } +/// Who asked for a restore, and whether the copy replaces that document (an untouched blank +/// one) or opens as another document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub document: u64, + pub revision: u64, + pub replace: bool, +} +/// One open document's recovery state: the record its copy is kept in, the revision that copy +/// holds, and when the next copy may be written. Each open document has its own, parked with it. +#[derive(Clone, Debug)] +pub(crate) struct Slot { + pub id: Option, + pub revision: Option, + pub due: Instant, + /// A failed discard of this document's copy is tried again from then on. + pub retry: Option, +} +impl Default for Slot { + fn default() -> Self { + Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } + } +} +impl Slot { + /// The document now keeps its copy in record `id`, which holds `revision`. + pub fn adopt(&mut self, id: String, revision: u64) { + self.id = Some(id); + self.revision = Some(revision); + self.due = Instant::now() + INTERVAL; + self.retry = None; + } +} +/// A worker report, for the session to apply to its documents. +pub(crate) enum Applied { + Nothing, + /// Record `id` is gone: no document keeps its copy there any more. + Discarded(String), + /// A restored copy, to open. + Loaded(Box, Request), + /// Writing or discarding `document`'s copy failed. + Failed { + document: u64, + discard: bool, + permanent: bool, + }, +} +impl Slot { + /// Take in a report about this document's copy. + pub fn apply(&mut self, applied: &Applied) { + match applied { + Applied::Discarded(id) if self.id.as_deref() == Some(id) => { + self.id = None; + self.retry = None; + } + Applied::Failed { discard, permanent, .. } => { + let now = Instant::now(); + if *discard { + self.retry = Some(now + INTERVAL); + } else if !permanent { + // A transient failure (a lock, an I/O error) is retried at the next + // interval; one the snapshot itself causes waits for the next edit. + self.revision = None; + } + self.due = now + INTERVAL; + } + _ => {} + } + } +} +/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. +pub(crate) struct DocRecovery<'a> { + pub id: u64, + /// Dirty and without a password: the document should have a current copy. + pub wants_copy: bool, + pub rev: u64, + pub doc: &'a Document, + pub path: Option<&'a Path>, + pub slot: &'a mut Slot, +} +/// The process's recovery worker: one thread and one store for every open document, so records +/// are locked once per process and the list of copies is the same whichever document is shown. pub(crate) struct Recovery { tx: mpsc::SyncSender, rx: mpsc::Receiver, + /// Every record the store found or wrote, this process's own included. pub documents: Vec, pub ready: bool, pub busy: bool, pub error: Option, - document: Option, - id: Option, - revision: Option, - due: Instant, alive: bool, - /// The profile folder and wakeup, for [`Recovery::sibling`]. - root: PathBuf, - wake: Arc, + /// Records of closed documents waiting to be deleted, and whether that is queued. + closed: Vec<(String, bool)>, + /// A loaded copy that replaces its (untouched blank) document once that document is active. + pub held: Option<(Box, Request)>, } impl Recovery { - /// Recovery for another open document: its own worker on the same profile folder, keeping - /// that document's copy as this one keeps its document's. - pub fn sibling(&self) -> Option { - Self::start(self.root.clone(), self.wake.clone()).map_err(|e| log::warn!("recovery: {e}")).ok() - } pub fn start(root: PathBuf, wake: Arc) -> Result { - let (worker_root, worker_wake) = (root.clone(), wake.clone()); let (tx, jobs) = mpsc::sync_channel(1); let (events, rx) = mpsc::channel(); std::thread::Builder::new() .name("wordcraft-recovery".into()) .spawn(move || { - let (root, wake) = (worker_root, worker_wake); let mut store = match Store::new(root) { Ok(s) => s, Err(e) => { - let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false }); + let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); wake(); return; } }; let initial = store.scan().map(|_| Outcome::Done); - if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() { + if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { return; } wake(); while let Ok(job) = jobs.recv() { let mut permanent = false; + let (mut document, mut discard) = (None, None); let result = match job { - Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| { - permanent = e.permanent; - e.message - }), + Job::Save(s, d) => { + document = Some(d); + store.save(*s).map(|_| Outcome::Done).map_err(|e| { + permanent = e.permanent; + e.message + }) + } Job::Scan => store.scan().map(|_| Outcome::Done), - Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)), - Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)), + Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), + Job::Discard(id, d) => { + document = d; + let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); + discard = Some(id); + r + } }; - if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() { + if result.is_ok() { + (document, discard) = (None, None); + } + if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { break; } wake(); } }) .map_err(|e| e.to_string())?; - Ok(Self { - tx, - rx, - documents: Vec::new(), - ready: false, - busy: true, - error: None, - document: None, - id: None, - revision: None, - due: Instant::now() + INTERVAL, - alive: true, - root, - wake, - }) + Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) } fn queue(&mut self, job: Job) -> Result<(), String> { if !self.alive { @@ -478,33 +554,23 @@ impl Recovery { self.error = None; Ok(()) } - pub fn poll(&mut self) -> Option<(Snapshot, (u64, u64))> { - match self.rx.try_recv() { + /// The worker's answer to the job under way: waits for it when `block`, else only takes one + /// that has arrived. + pub fn receive(&mut self, block: bool) -> Option { + let e = if block { + if !self.busy { + return None; + } + self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) + } else { + self.rx.try_recv() + }; + match e { Ok(e) => { self.busy = false; self.ready = true; - self.documents = e.documents; - let event_permanent = e.permanent; - match e.outcome { - Ok(Outcome::Done) => None, - Ok(Outcome::Discarded(id)) => { - if self.id.as_deref() == Some(&id) { - self.id = None; - } - None - } - Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)), - Err(e) => { - self.error = Some(e); - // A transient failure (a lock, an I/O error) is retried at the next - // interval; one the snapshot itself causes waits for the next edit. - if !event_permanent { - self.revision = None; - } - self.due = Instant::now() + INTERVAL; - None - } - } + self.documents.clone_from(&e.documents); + Some(e) } Err(mpsc::TryRecvError::Empty) => None, Err(mpsc::TryRecvError::Disconnected) => { @@ -517,140 +583,144 @@ impl Recovery { } } } - pub fn adopt(&mut self, id: String, document: u64, revision: u64) { - self.id = Some(id); - self.document = Some(document); - self.revision = Some(revision); - self.due = Instant::now() + INTERVAL; + /// Run `job` now and wait for its answer. + fn run_now(&mut self, job: Job) -> Result { + self.queue(job)?; + self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) } - pub fn tick(&mut self, s: &Session, now: Instant) -> Result<(), String> { - if self.document != Some(s.document_id()) { - self.document = Some(s.document_id()); - self.id = None; - self.revision = None; + /// Discard record `id` now (the worker must be idle) and wait for it. + pub fn discard_now(&mut self, id: &str, document: Option) -> Result { + self.run_now(Job::Discard(id.into(), document)) + } + /// A closed document's record: deleted at the next chance, and not listed meanwhile. + pub fn close(&mut self, id: String) { + if !self.closed.iter().any(|(c, _)| *c == id) { + self.closed.push((id, false)); } + } + /// Records the open documents of this process don't hold any more but haven't deleted yet. + pub fn closing(&self) -> impl Iterator { + self.closed.iter().map(|(id, _)| id.as_str()) + } + /// Closed documents' records not yet queued for deletion. + pub fn take_closed(&mut self) -> Vec { + self.closed + .iter_mut() + .filter(|(_, queued)| !*queued) + .map(|(id, queued)| { + *queued = true; + id.clone() + }) + .collect() + } + /// Take in what the worker reported: the record lists here, and what the session applies + /// to its documents. + pub fn apply(&mut self, e: Event) -> Applied { + match e.outcome { + Ok(Outcome::Done) => Applied::Nothing, + Ok(Outcome::Discarded(id)) => { + self.closed.retain(|(c, _)| *c != id); + Applied::Discarded(id) + } + Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), + Err(message) => { + self.error = Some(message); + if let Some(id) = &e.discard { + // A closed document's copy that can't be deleted stays listed, for the user + // to restore or discard. + self.closed.retain(|(c, _)| c != id); + } + match e.document { + Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, + None => Applied::Nothing, + } + } + } + } + /// Queue at most one job: a closed document's discard first, then, document by document in + /// the order given, a discard of a copy its document no longer needs (saved, closed without + /// saving, password-protected) or a new copy of a dirty document whose interval has passed. + pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { if self.busy || !self.ready || !self.alive { return Ok(()); } - if !s.dirty || s.password.is_some() { - if let Some(id) = self.id.clone() { - self.queue(Job::Discard(id))?; + if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { + let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; + self.queue(Job::Discard(id, None))?; + if let Some(c) = self.closed.get_mut(at) { + c.1 = true; } return Ok(()); } - if self.revision == Some(s.rev()) || now < self.due { + for d in docs { + if !d.wants_copy { + if let Some(id) = d.slot.id.clone() + && d.slot.retry.is_none_or(|t| now >= t) + { + return self.queue(Job::Discard(id, Some(d.id))); + } + continue; + } + if d.slot.revision == Some(d.rev) || now < d.slot.due { + continue; + } + let id = match &d.slot.id { + Some(id) => id.clone(), + None => new_id()?, + }; + let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); + let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; + self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; + d.slot.id = Some(id); + d.slot.revision = Some(d.rev); + d.slot.due = now + INTERVAL; return Ok(()); } - let id = match &self.id { - Some(id) => id.clone(), - None => new_id()?, - }; - let name = s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); - let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; - self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: s.doc.clone() })))?; - self.id = Some(id); - self.revision = Some(s.rev()); - self.due = now + INTERVAL; Ok(()) } - pub fn document_is(&self, document: u64) -> bool { - self.document == Some(document) - } - pub fn wait(&self, document: u64, revision: u64) -> Option { - if !self.alive || !self.ready || self.busy || (self.document == Some(document) && self.revision == Some(revision)) { + /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. + pub fn wait<'a>(&self, docs: impl Iterator) -> Option { + if !self.alive || !self.ready || self.busy { return None; } - Some(self.due.saturating_duration_since(Instant::now()).max(Duration::from_millis(100))) + let now = Instant::now(); + let min = Duration::from_millis(100); + if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { + return Some(min); + } + docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { + (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), + (false, None) => None, + (true, _) if slot.revision == Some(rev) => None, + (true, _) => Some(slot.due.saturating_duration_since(now)), + }) + .min() + .map(|d| d.max(min)) } - pub fn restore(&mut self, id: &str, expected: (u64, u64)) -> Result<(), String> { + pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { if !self.documents.iter().any(|d| d.id == id) { return Err("no such recovery copy".into()); } - self.queue(Job::Load(id.into(), expected)) + if self.held.is_some() { + return Err("another restored copy is waiting for its document".into()); + } + self.queue(Job::Load(id.into(), request)) } - pub fn discard(&mut self, id: &str, current: (u64, u64)) -> Result<(), String> { + pub fn discard(&mut self, id: &str) -> Result<(), String> { if !self.documents.iter().any(|d| d.id == id) { return Err("no such recovery copy".into()); } - self.queue(Job::Discard(id.into()))?; - if self.id.as_deref() == Some(id) { - self.revision = Some(current.1); - } - Ok(()) + self.queue(Job::Discard(id.into(), None)) } pub fn refresh(&mut self) -> Result<(), String> { self.queue(Job::Scan) } - pub fn purge(&mut self) -> Result<(), String> { - let Some(id) = self.id.clone() else { return Ok(()) }; - if self.busy { - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.documents = e.documents; - self.busy = false; - self.ready = true; - if matches!(e.outcome, Ok(Outcome::Loaded(..))) { - return Err("finish recovery before protecting this document".into()); - } - } - self.queue(Job::Discard(id))?; - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.busy = false; - self.documents = e.documents; - e.outcome?; - self.id = None; - self.revision = None; - Ok(()) - } - /// Waits out any job already in flight (an autosave queued just before shutdown, say), then, - /// if the document turns out to need no recovery copy kept (not dirty, or password-protected - /// — [`tick`](Self::tick)'s own condition), discards it and waits for that too. Desktop hosts - /// call this before the process exits, the same as the print spooler's own pending-job wait - /// (`finish_pending_jobs`): otherwise a save that lands right before close can race the - /// process exiting before the now-redundant recovery copy is actually removed, so the *next* - /// launch wrongly offers to recover a document that was in fact saved cleanly. - pub fn finish(&mut self, s: &Session) -> Result<(), String> { - let mut error = None; - if self.busy { - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.documents = e.documents; - self.busy = false; - self.ready = true; - // An in-flight job failing (e.g. a stray autosave) doesn't mean the document itself - // wasn't saved cleanly by an explicit Save — still try the discard below, so one - // doesn't keep the other from doing its job; log it once there's nothing further to do. - if let Err(e) = e.outcome { - error = Some(e); - } - } - // Same guard `tick` uses: a document switch with no regular tick in between to notice and - // reset `self.id` must not let the *new* document's clean state discard the *previous* - // document's still-live recovery copy under a stale identity. - if self.document == Some(s.document_id()) - && let Some(id) = self.id.clone() - && (!s.dirty || s.password.is_some()) - { - self.queue(Job::Discard(id))?; - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.busy = false; - self.documents = e.documents; - match e.outcome { - Ok(_) => { - self.id = None; - self.revision = None; - } - Err(e) => error = Some(e), - } - } - match error { - Some(e) => Err(e), - None => Ok(()), - } - } } #[cfg(test)] mod tests { use super::*; + use crate::Session; use serde_json::json; use wordcraft_doc::StoryRef; @@ -719,11 +789,28 @@ mod tests { settle(&mut s); s } + /// Every open document's interval has passed. + fn due_now(s: &mut Session) { + for d in s.recovery_docs() { + d.slot.due = Instant::now(); + } + } fn save_now(s: &mut Session) { - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(s); s.recovery_tick(); settle(s); } + /// The records in the profile folder: the copies on disk. + fn records(root: &Path) -> Vec { + let mut ids: Vec = fs::read_dir(root) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + ids.sort(); + ids + } /// #3140: a save that lands right before the app closes can race the process exiting before /// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to @@ -737,17 +824,13 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document"); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); // A real Save does this, then the app can close on the very next frame, with no regular // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. s.dirty = false; - assert_eq!( - s.recovery_documents().len(), - 1, - "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself" - ); + assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); s.finish_recovery(); - assert!(s.recovery_documents().is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); + assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); } /// `tick` resets `self.id` the moment the session's document no longer matches what it last @@ -761,11 +844,12 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document"); - s.set_document(Document::new()); // e.g. File > New, with no tick() in between + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + s.set_document(Document::new()); // the document is replaced, with no tick() in between assert!(!s.dirty, "a freshly switched-to document starts clean"); s.finish_recovery(); - assert_eq!(s.recovery_documents().len(), 1, "switching to a new, unrelated document must not discard the previous one's recovery copy"); + assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); + assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); } #[test] @@ -779,6 +863,8 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); save_now(&mut s); + drop(s); + let mut s = session(&profile.0); let id = s.recovery_documents()[0].id.clone(); confine(Some(&roots), || { // A command cannot use a file path or choose a different storage folder. @@ -794,9 +880,12 @@ mod tests { assert!(s.path.is_none()); assert!(s.dirty); assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); - s.run("file.recover", &json!({"discard": id})).unwrap(); - settle(&mut s); + // The restored document keeps its copy there: it isn't listed, and can't be discarded. assert!(s.recovery_documents().is_empty()); + let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + settle(&mut s); + assert_eq!(records(&profile.0), std::slice::from_ref(&id)); }); assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); @@ -878,7 +967,7 @@ mod tests { save_now(&mut s); assert!(s.recovery_error().is_some()); assert!(s.recovery_wait().is_none()); - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(&mut s); s.recovery_tick(); assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); assert!(s.recovery_error().is_some()); @@ -887,7 +976,7 @@ mod tests { s.run("text.insert", &json!({"text": "!"})).unwrap(); save_now(&mut s); assert!(s.recovery_error().is_none()); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&t.0).len(), 1); } #[test] @@ -969,11 +1058,16 @@ mod tests { settle(&mut s); assert_eq!(s.doc, expected); assert!(s.dirty && s.path.is_none()); + assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); + // A crash leaves the restored document's copy, in the same record. + drop(s); + let mut s = session(&t.0); + assert_eq!(s.recovery_documents().len(), 1); s.run("file.recover", &json!({"discard": id})).unwrap(); settle(&mut s); save_now(&mut s); - assert!(s.recovery_documents().is_empty(), "discard isn't undone by the next timer"); - assert_eq!(s.doc, expected, "discard deletes the copy, not the editing document"); + assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); } #[test] @@ -999,7 +1093,7 @@ mod tests { let saved = t.0.join("recovered.json"); s.run("file.save", &json!({"path": saved})).unwrap(); settle(&mut s); - assert!(s.recovery_documents().is_empty()); + assert!(records(&root).is_empty()); assert_eq!(fs::read(file).unwrap(), original); } @@ -1008,18 +1102,18 @@ mod tests { let t = Temp::new(); let mut s = session(&t.0); s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(&mut s); s.recovery_tick(); assert!(s.recovery_busy()); s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); save_now(&mut s); assert!(fs::read_dir(&t.0).unwrap().next().is_none()); // Removing protection makes future recovery possible again. s.run("file.encrypt", &json!({"password": null})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&t.0).len(), 1); } #[test] @@ -1028,7 +1122,7 @@ mod tests { let mut s = session(&t.0); s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); save_now(&mut s); - let id = s.recovery_documents()[0].id.clone(); + let id = records(&t.0).remove(0); let dir = t.0.join(id); // An unknown file prevents removal: don't report that plaintext was purged. let first = Store::files(&dir).unwrap()[0].clone(); @@ -1102,7 +1196,7 @@ mod tests { s.run("file.new", &json!({})).unwrap(); s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&root).len(), 1); let saved = t.0.join("saved.docx"); s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); settle(&mut s); @@ -1126,9 +1220,8 @@ mod tests { s.activate(first).unwrap(); s.run("text.insert", &json!({"text": ", edited"})).unwrap(); save_now(&mut s); - let records = fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(); - assert_eq!(records, 2, "one copy per document"); - assert_eq!(s.recovery_documents().len(), 1, "the first document's own copy"); + assert_eq!(records(&t.0).len(), 2, "one copy per document"); + assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); } #[test] @@ -1142,7 +1235,109 @@ mod tests { // Protection can be commanded before the host's next recovery tick. s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); settle(&mut s); - assert_eq!(s.with_document(first, |s| s.recovery_documents().len()).unwrap(), 1); - assert_eq!(fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(), 1, "the first draft is kept"); + assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); + assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); + } + + /// Two dirty documents, one of them parked since before its first copy: both get copies once + /// their interval has passed, without being made active. + #[test] + fn parked_documents_keep_their_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); + let parked = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); + let active = s.document_id(); + assert_ne!(parked, active); + save_now(&mut s); + assert_eq!(s.document_id(), active, "the parked document was not made active"); + assert_eq!(records(&t.0).len(), 2, "both documents have copies"); + // An edit to the parked document reaches its copy too. + s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); + let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); + save_now(&mut s); + drop(s); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); + } + + /// Closing a dirty document and saving another delete both their copies; a third document + /// keeps its own. + #[test] + fn closing_or_saving_a_document_deletes_only_its_copy() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Closed draft", "Saved draft", "Kept draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let kept = s.recovery_slot.id.clone().unwrap(); + s.close_document(ids[0]).unwrap(); + s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + settle(&mut s); + assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); + assert!(s.recovery_documents().is_empty()); + } + + /// A crash with two dirty documents leaves two copies; the next run lists both and restores + /// each into a document of its own, beside the work already there. + #[test] + fn a_crash_leaves_every_dirty_document_s_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); // no finish_recovery: the process died + let mut s = session(&t.0); + let copies = s.recovery_documents(); + assert_eq!(copies.len(), 2); + s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); + let work = s.document_id(); + for copy in &copies { + s.run("file.recover", &json!({"restore": copy.id})).unwrap(); + settle(&mut s); + } + assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); + let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); + texts.sort(); + assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); + assert!(s.with_document(work, |s| s.dirty).unwrap()); + } + + /// A clean exit deletes the copies of documents saved or closed since their last copy, the + /// parked ones' too, and keeps the dirty ones'. + #[test] + fn finish_recovery_covers_every_open_document() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Saved draft", "Closed draft", "Dirty draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let dirty = s.recovery_slot.id.clone().unwrap(); + // No tick between these and the exit. + s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + s.close_document(ids[1]).unwrap(); + s.finish_recovery(); + assert_eq!(records(&t.0), [dirty]); } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e0df7a7a7e..e2c19aa32c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -508,8 +508,12 @@ pub struct Session { /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected /// file that was opened, cleared when the document is replaced. pub password: Option, + /// The recovery worker every open document's copy is kept through (desktop hosts only). #[cfg(not(target_arch = "wasm32"))] pub(crate) recovery: Option, + /// The active document's recovery copy. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery_slot: crate::recovery::Slot, /// More pictures and shapes selected along with the one the selection holds (Shift+click), /// for Group. Cleared by any edit or selection change other than adding to it. pub also_selected: Vec, @@ -719,7 +723,7 @@ impl Session { math, password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery_slot, also_selected, column, column_mode, @@ -787,7 +791,9 @@ impl Session { read_aloud: Default::default(), password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, column, column_mode, widened, @@ -1296,6 +1302,11 @@ impl Session { self.hf_return = None; self.go_back_step = 0; self.view.outline.collapsed.clear(); + // A copy the replaced document kept stays in the recovery list. + #[cfg(not(target_arch = "wasm32"))] + { + self.recovery_slot = Default::default(); + } self.reset_history(); self.touch(); self.dirty = false; @@ -1392,6 +1403,8 @@ impl Session { password: _, #[cfg(not(target_arch = "wasm32"))] recovery: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, also_selected: _, // Column selection and a widened selection's ends, like `sel`'s shape: valid only while // `sel` matches them. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 27625a151f..b6f8d5d50f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -74,7 +74,7 @@ pub struct DocState { pub(super) math: Option, pub(super) password: Option, #[cfg(not(target_arch = "wasm32"))] - pub(super) recovery: Option, + pub(super) recovery_slot: crate::recovery::Slot, pub(super) also_selected: Vec, pub(super) column: Option, pub(super) column_mode: bool, @@ -124,7 +124,7 @@ impl DocState { math: None, password: None, #[cfg(not(target_arch = "wasm32"))] - recovery: None, + recovery_slot: Default::default(), also_selected: Vec::new(), column: None, column_mode: false, @@ -220,7 +220,7 @@ impl Session { math: _, password: _, #[cfg(not(target_arch = "wasm32"))] - recovery: _, + recovery_slot: _, also_selected: _, column: _, column_mode: _, @@ -254,6 +254,8 @@ impl Session { others: _, order: _, untitled_count: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, } = self; let DocState { doc, @@ -291,7 +293,7 @@ impl Session { math, password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery_slot, also_selected, column, column_mode, @@ -334,7 +336,7 @@ impl Session { swap(&mut self.math, math); swap(&mut self.password, password); #[cfg(not(target_arch = "wasm32"))] - swap(&mut self.recovery, recovery); + swap(&mut self.recovery_slot, recovery_slot); swap(&mut self.also_selected, also_selected); swap(&mut self.column, column); swap(&mut self.column_mode, column_mode); @@ -358,8 +360,9 @@ impl Session { /// Take parked document `id` out of the session; false when it isn't parked here. fn remove_parked(&mut self, id: u64) -> bool { let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; - self.others.swap_remove(at); + let d = self.others.swap_remove(at); self.order.retain(|d| *d != id); + self.forget_recovery_copy(&d); true } @@ -417,10 +420,6 @@ impl Session { let mut view = self.view.clone(); view.outline.collapsed.clear(); let mut d = DocState::new(doc, untitled, view); - #[cfg(not(target_arch = "wasm32"))] - { - d.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); - } let id = d.document_id; self.order.push(id); self.swap_doc(&mut d); @@ -429,7 +428,48 @@ impl Session { id } - /// Close document `id` without asking: its unsaved changes are lost (callers ask first). + /// A closed document's recovery copy goes with it, as its unsaved changes do. + fn forget_recovery_copy(&mut self, d: &DocState) { + #[cfg(not(target_arch = "wasm32"))] + if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { + r.close(id.clone()); + } + #[cfg(target_arch = "wasm32")] + let _ = d; + } + + /// What recovery reads from every open document, the active one first, with each + /// document's slot to update. Nothing is cloned or laid out. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { + let active = crate::recovery::DocRecovery { + id: self.document_id, + wants_copy: self.dirty && self.password.is_none(), + rev: self.rev, + doc: &self.doc, + path: self.path.as_deref(), + slot: &mut self.recovery_slot, + }; + std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { + id: d.document_id, + wants_copy: d.dirty && d.password.is_none(), + rev: d.rev, + doc: &d.doc, + path: d.path.as_deref(), + slot: &mut d.recovery_slot, + })) + } + + /// Every open document's recovery slot, with whether it should have a current copy and + /// its revision. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_slots(&self) -> impl Iterator { + std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) + .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first), + /// and so is its recovery copy. /// Closing the active document makes the one opened before it active (else the one after). /// Closing the last open document leaves a new blank one, so a session always has an /// active document; what a window does then is the front end's choice. An id that isn't @@ -455,12 +495,9 @@ impl Session { let mut view = self.view.clone(); view.outline.collapsed.clear(); let mut fresh = DocState::new(doc, self.untitled_count, view); - #[cfg(not(target_arch = "wasm32"))] - { - fresh.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); - } self.order = vec![fresh.document_id]; self.swap_doc(&mut fresh); + self.forget_recovery_copy(&fresh); } } self.stop_reading_elsewhere(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 7e9ea3bcff..6c606dec60 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -991,29 +991,27 @@ mod tests { #[test] #[cfg(not(target_arch = "wasm32"))] - fn recovery_restore_uses_save_changes_and_resets_autosave_permission() { + fn recovery_restore_opens_beside_unsaved_work_without_autosave_permission() { let (app, root) = recovery_app(); let mut h = recovery_harness(app); settle_recovery(&mut h); h.state_mut().execute("text.insert", json!({"text": "Current unsaved work"})).unwrap(); + let work = h.state().session.document_id(); h.state_mut().autosave_path = Some(root.join("must-not-write.docx")); h.state_mut().run("file.recover", json!({})).unwrap(); h.run_steps(4); assert_eq!(h.state().session.recovery_documents().len(), 1); h.get_by_label("Restore").click(); - h.run_steps(4); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); - h.get_by_label("Cancel").click(); - h.run_steps(4); - assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Current unsaved work"); - h.get_by_label("Restore").click(); - h.run_steps(4); - h.get_by_label("Don't Save").click(); settle_recovery(&mut h); + assert!(h.state().dialog.is_none(), "nothing is replaced, so nothing to ask"); + assert_ne!(h.state().session.document_id(), work); assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Recovered synthetic work"); assert!(h.state().session.dirty && h.state().session.path.is_none()); assert!(h.state().autosave_path.is_none()); assert!(!h.state().ui.backstage); + let kept = h.state_mut().session.with_document(work, |s| s.doc.plain_text(wordcraft_doc::StoryRef::Body)).unwrap(); + assert_eq!(kept, "Current unsaved work"); + assert!(h.state().session.recovery_documents().is_empty(), "the restored document keeps the copy"); assert!(!root.join("must-not-write.docx").exists()); drop(h); let _ = std::fs::remove_dir_all(root); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa3163fe31..0525c30e21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1395,6 +1395,11 @@ impl WordApp { let last = self.session.documents().len() <= 1; if params.get("quit").and_then(Value::as_bool) == Some(true) { if last { + // Don't Save: the document stays open until the app exits, but its changes, + // and so its recovery copy, are discarded. + if params.get("force").and_then(Value::as_bool) == Some(true) { + self.session.discard_recovery_copy(); + } self.quit_requested = true; return Ok(json!({"quit": true})); } @@ -2888,15 +2893,11 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that close or replace the shown document: Close, and restoring a recovered -/// copy in its place. New, Open, Envelopes, Labels and Finish & Merge open their document beside -/// it (Open replaces only an untouched blank document, which has nothing to lose). -pub(crate) fn discards_document(id: &str, params: &Value) -> bool { - match id { - "file.close" => true, - "file.recover" => params.get("restore").is_some(), - _ => false, - } +/// User commands that close the shown document: Close. New, Open, restoring a recovered copy, +/// Envelopes, Labels and Finish & Merge open their document beside it (Open and restoring +/// replace only an untouched blank document, which has nothing to lose). +pub(crate) fn discards_document(id: &str, _params: &Value) -> bool { + id == "file.close" } /// Wall-clock milliseconds since the Unix epoch: the system clock, or the browser's on the web. diff --git a/docs/recovery.md b/docs/recovery.md index b4c059f4e8..6640105ca1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -6,21 +6,35 @@ unless a document was explicitly created or opened before the startup scan compl In particular, a window launched for New/Open keeps the requested document visible; older copies remain available through File › Recover. Recovery uses the regular interface palette so its labels and buttons stay readable in light and dark themes. -Restore opens an unsaved document with no file path. Save As chooses where to keep it. -Recovery never writes to the original document. AutoSave's permission to write to an earlier -file is cleared on restore. +Restore opens the copy as another unsaved document with no file path, beside the open +documents. It replaces only an untouched blank document, so it never replaces work and asks +nothing. Save As chooses where to keep it. Recovery never writes to the original document. +The restored document has no AutoSave permission to write to an earlier file, and keeps its +copy in the record it came from. -Continue without restoring leaves copies available. Discard asks for confirmation and deletes -the selected copy, including its retained generation. It does not delete the editing document. -After discarding that document's copy, another copy is made only after a further edit. -A successful save removes that document's recovery copy on the next worker cycle while it -remains open. Copies left after switching documents can still be explicitly discarded. +File › Recover lists copies from earlier runs: crashed sessions, documents replaced in this +one, and copies whose deletion failed. The copies the open documents keep are not listed; +they are not lost. Continue without restoring leaves copies available. Discard asks for +confirmation and deletes the selected copy, including its retained generation. Documents with a password do not get plaintext recovery copies. Applying a password waits for any pending snapshot, removes the current document's recovery generations, and fails if cleanup fails. Encrypted recovery is not implemented. Documents opened with a password keep recovery disabled for that session. Removing the password allows future recovery again. +## Several open documents + +Every dirty open document keeps its own copy, whether it is shown or not. A crash with several +unsaved documents leaves a copy of each, and the next start lists them all. + +| Event | That document's copy | +|---|---| +| Save | Deleted on the next worker cycle | +| Close without saving (Don't Save, `file.closeDocument`) | Deleted on the next worker cycle | +| Don't Save when quitting | Deleted before the app exits | +| Clean exit | Waits for the worker, then deletes the copies of documents saved, closed or protected since their last copy; dirty documents keep theirs | +| Crash | Kept | + ## Storage and timing Copies live in `recovery/` under the app's profile folder. `WORDCRAFT_PROFILE_DIR` selects a @@ -28,9 +42,11 @@ separate profile for isolated runs, including preferences, logs and control keys `WORDCRAFT_NO_PREFS` disables recovery as well as preferences. Headless engine/CLI sessions and the browser do not create recovery files implicitly. -The desktop host checks for a dirty document every frame but snapshots at most once per ten -seconds, and only when its revision changes. It clones the shared document model for a bounded -worker queue. Encoding, file I/O, full-document validation and discovery run on the worker. +One worker thread and one store serve every open document, so a record is locked once per +process. Every frame the desktop host compares each open document's revision and timer without +making it active, and queues at most one job. A document is snapshotted at most once per ten +seconds, and only when its revision changes. Only a document whose copy is due is cloned (the +shared document model) for the worker. Encoding, file I/O, full-document validation and discovery run on the worker. An unchanged document does not schedule repeated recovery repaints. The most recent edits can be lost if a crash happens before the next snapshot or while its write is still pending. @@ -68,9 +84,10 @@ interval, English fallback for new labels, and no cloud, version-history or brow `file.recover={}` reads cached `{enabled,busy,documents,error}`. Each document has `id`, `name` and `savedAt`. `{"refresh":true}`, `{"restore":"id"}` and `{"discard":"id"}` queue worker -operations. Poll until `busy` is false; inspect `error` for failure. A restore is cancelled if the -document id or revision changes while it loads. Programmatic restores do not open dialogs; -user restores over dirty work use the existing Save Changes prompt. +operations, and accept only listed ids. Poll until `busy` is false; inspect `error` for failure. +A restore asked for in an untouched blank document replaces it once that document is active, +and is cancelled if the document changes or closes while the copy loads. Restores never open +dialogs. `ui.recoveryDiscard={"id":"id"}` opens confirmation; `{"answer":"discard"}` or `{"answer":"cancel"}` answers it. From 3d0fcaccbf7d24390e3c9de947f3d29f204ac0dd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:28:19 +0000 Subject: [PATCH 12/87] W2: The first blank document from the Start screen is Document1 The Start screen's stand-in document, and the one left after closing the last document, give back their untitled name when it is the newest. --- crates/engine/src/session/documents.rs | 11 +++++++++++ crates/engine/src/tests_documents.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/documents.rs | 14 ++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 47 insertions(+) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b6f8d5d50f..d302aa5ff3 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -504,6 +504,17 @@ impl Session { Ok(()) } + /// The active document stands in for no document at all (a Start screen's, or what is left + /// after closing the last one): an untouched blank document gives back its untitled name + /// when it is the newest, so the first document a person sees is `Document1`, the next + /// `Document2`. + pub fn release_untitled_name(&mut self) { + if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { + self.untitled_count = self.untitled_count.saturating_sub(1); + self.untitled = 0; + } + } + /// Whether the active document is a blank one nobody has used: no file and no name, never /// changed, nothing to undo or redo and no text. Opening a file replaces such a document /// rather than opening beside it. diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index 329c25c9d0..a1dd44a143 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -253,3 +253,23 @@ fn document_ids_are_unique_across_sessions_and_replacements() { assert_ne!(one.document_id(), before); assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); } + +/// A Start screen's stand-in document gives back its name: the first document a person makes is +/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` +/// again only if no other document has taken it. +#[test] +fn a_stand_in_document_gives_back_its_untitled_name() { + let mut s = s(); + let placeholder = s.document_id(); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + s.close_document(placeholder).unwrap(); + assert_eq!(s.documents()[0].title, "Document1"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[1].title, "Document2"); + // A document with work keeps its name. + run(&mut s, "text.insert", json!({"text": "Work"})); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 85a3a5154a..1b1c0c16db 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -286,4 +286,18 @@ mod tests { assert_eq!(super::untitled_title("Documents"), None); assert_eq!(super::untitled_title("Report"), None); } + + /// From the Start screen, Blank document is `Document1` and New after it `Document2`: the + /// Start screen's stand-in document doesn't use up a name. + #[test] + fn start_screen_blank_document_is_document1() { + let mut a = app(); + a.ui.start_screen = true; + assert!(a.start_screen()); + a.run("file.new", json!({})).unwrap(); + let titles = |a: &WordApp| a.session.documents().into_iter().map(|d| d.title).collect::>(); + assert_eq!(titles(&a), ["Document1"]); + a.run("file.new", json!({})).unwrap(); + assert_eq!(titles(&a), ["Document1", "Document2"]); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0525c30e21..9c68eb45e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -790,6 +790,7 @@ impl WordApp { self.ui.backstage = true; self.ui.backstage_page = "home".into(); self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); } self.ui.start_screen } @@ -1415,6 +1416,7 @@ impl WordApp { self.sync_document(); if last { self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); } self.ui.backstage = false; Ok(json!({"closed": true})) From 3963cf3012898e6bd8cfe0333ff1800da05c7592 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:28:37 +0000 Subject: [PATCH 13/87] W2: Requests dropped for a document that isn't shown are logged as warnings --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c68eb45e3..5c093d3b49 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1544,7 +1544,7 @@ impl WordApp { self.sync_document(); return; } - log::info!("dropped a request for document {document}, which isn't shown: {req}"); + log::warn!("dropped a request for document {document}, which isn't shown: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no From 4ca965d99029a646df57f6066a6e8c280a40e95a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:09:38 +0000 Subject: [PATCH 14/87] W3a: UI: per-window state that a window swaps in WindowUi holds what one window shows and is doing (canvas, dialogs, menus, status, ribbon tab, Backstage, keytips, file dialogs...) for windows not being drawn; WordApp::swap_window exchanges it with the drawn window's fields by moves. The swap destructures WordApp and UiState exhaustively, so every new field must be classified as per window, per document or app-wide. WordApp::new takes its window defaults from WindowUi::new. --- crates/ui-egui/src/documents.rs | 15 +- crates/ui-egui/src/lib.rs | 133 ++++++++--- crates/ui-egui/src/window_ui.rs | 385 ++++++++++++++++++++++++++++++++ 3 files changed, 491 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/window_ui.rs diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 1b1c0c16db..a6b5344cb8 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,8 +1,10 @@ //! Several open documents in one window. The window shows the session's active document; what -//! it keeps about a document (AutoSave's file, comment drafts, the page's drags, menus and -//! composition…) follows the active one ([`WordApp::sync_document`]). View › Window › Switch -//! Windows lists the open documents, File › Close closes the shown one and shows the next, and -//! quitting asks about every document with unsaved changes, one after another. +//! it keeps about a document (AutoSave's file, comment drafts) follows the active one, and the +//! window's own state about the page (drags, menus, the composition…) starts afresh when the +//! document changes ([`WordApp::sync_document`]; per-window state is listed in +//! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close +//! closes the shown one and shows the next, and quitting asks about every document with unsaved +//! changes, one after another. use serde_json::{Value, json}; use wordcraft_engine::DocInfo; @@ -21,7 +23,8 @@ pub(crate) struct Parked { impl WordApp { /// Follow the session's active document. When it isn't the one the window last showed, the /// shown document's own state is put aside (or dropped, if it was closed or replaced), the - /// new one's comes back, and everything about the page that was shown starts afresh: open + /// new one's comes back, and the window's state about the page that was shown starts afresh + /// (all of it per-window state, [`crate::window_ui::WindowUi`]): open /// dialogs and menus, drags, the input method's composition, the status message, caches /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per /// document; the zoom is in the session's view). Runs after every command and every frame. @@ -60,7 +63,7 @@ impl WordApp { self.read_aloud_at = None; self.info_editing = None; // Caches keyed by the revision alone: another document's revision can be the same number. - self.word_count = (u64::MAX, 0); + self.word_count = crate::window_ui::NOT_COUNTED; self.find_fonts_for = None; self.previews.document_changed(); // The Start screen's placeholder (#1129) goes once a document is open beside it. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c093d3b49..66ab2ea658 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,7 @@ pub mod theme; pub mod widgets; pub mod window_close; pub mod window_geometry; +mod window_ui; pub mod wordart_menus; pub mod zotero; @@ -693,39 +694,86 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } - let session_document = session.document_id(); + // What the window shows starts as a new window's. + let window_ui::WindowUi { + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = window_ui::WindowUi::new(session.document_id()); WordApp { session, - ui: UiState::default(), + ui: UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ..UiState::default() + }, services, printing: Default::default(), print_feedback_seen: None, - canvas: canvas::CanvasState::default(), - controls: Default::default(), - dialog: None, - colors: None, - restrict: Default::default(), - status_msg: None, - previews: previews::Previews::default(), integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), - focus_ribbon: false, control_rx: None, shot_token: 0, queued_shots: Vec::new(), pending_shots: Vec::new(), synthetic: Vec::new(), - control_click: false, - keytip_rects: Vec::new(), - nav_search_focus: false, styled: false, fonts_hans: false, fonts_korean: false, fonts_system_cjk: false, want_system_cjk: false, - comment_fonts_for: None, recent_fonts_for: Vec::new(), - find_fonts_for: None, fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -737,39 +785,52 @@ impl WordApp { system_animations: None, applied_animations: None, frame_ms: 0.0, - sent_title: String::new(), - sent_full_screen: false, - seen_full_screen: None, - escape_free: false, quit_requested: false, autosave: true, - word_count: (0, 0), - last_autosave: 0.0, - equation_prev_tab: None, - smart_art_item: None, - smart_art_typing: None, - smart_art_row_focused: false, - outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, - read_aloud_at: None, read_aloud_error: None, autosave_path: None, saves_in_flight: Vec::new(), - awaiting_save: None, - change_picture_target: None, - recipient_list_pending: false, font_generation: wordcraft_fonts::FontDb::global().generation(), - dialog_pick: None, - file_dialog: None, - #[cfg(not(target_arch = "wasm32"))] - file_browser: file_browser::State::default(), - info_editing: None, recovery_seen: false, no_document: None, comment_drafts: Default::default(), - shown_document: session_document, parked: Default::default(), + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, } } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 0000000000..d79827adf8 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,385 @@ +//! What one window shows and is doing, apart from the rest of the app. [`WordApp`] holds the +//! state of the window being drawn in its own fields, so drawing code reads `app.canvas`, +//! `app.dialog`, `app.ui.tab` whatever window it is; every other window keeps its state in a +//! [`WindowUi`]. [`WordApp::swap_window`] exchanges the two by moving owned fields, so it is +//! cheap enough to run for every window on every frame. +//! +//! Every field of [`WordApp`] and [`UiState`] is one of: +//! - per window: listed in [`WindowUi`] and swapped; +//! - per document: kept for the documents not shown in [`WordApp`]'s `parked` map +//! ([`WordApp::sync_document`]) or in the engine's session; +//! - app-wide: the session, preferences, fonts, theme, the control channel, timers and workers. +//! +//! `swap_window` names every field, so a new field doesn't compile until it is classified. + +use crate::{AwaitingSave, UiState, WordApp, canvas, colors, content_controls, dialogs, dialogs_review, file_dialogs, keytips, previews, print_page}; + +/// A window's state while another window is drawn. Fields mirror their namesakes in [`WordApp`] +/// and, for the ribbon tab, Backstage, printing and keytips, in [`UiState`]. +pub(crate) struct WindowUi { + pub(crate) canvas: canvas::CanvasState, + pub(crate) controls: content_controls::ControlUi, + pub(crate) dialog: Option, + pub(crate) colors: Option, + pub(crate) restrict: dialogs_review::RestrictPane, + pub(crate) status_msg: Option<(String, f64)>, + pub(crate) previews: previews::Previews, + pub(crate) change_picture_target: Option, + pub(crate) recipient_list_pending: bool, + pub(crate) dialog_pick: Option, + pub(crate) focus_ribbon: bool, + pub(crate) control_click: bool, + pub(crate) keytip_rects: Vec<(egui::Rect, String)>, + pub(crate) nav_search_focus: bool, + pub(crate) comment_fonts_for: Option<(u64, u64)>, + pub(crate) find_fonts_for: Option<(String, usize, u64)>, + pub(crate) sent_title: String, + pub(crate) sent_full_screen: bool, + pub(crate) seen_full_screen: Option, + pub(crate) escape_free: bool, + pub(crate) word_count: (u64, usize), + pub(crate) last_autosave: f64, + pub(crate) equation_prev_tab: Option, + pub(crate) smart_art_item: Option, + pub(crate) smart_art_row_focused: bool, + pub(crate) smart_art_typing: Option, + pub(crate) outline_prev_tab: Option, + pub(crate) read_aloud_at: Option, + pub(crate) awaiting_save: Option, + pub(crate) file_dialog: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) file_browser: crate::file_browser::State, + pub(crate) info_editing: Option<(&'static str, u64)>, + pub(crate) shown_document: u64, + // From UiState. + pub(crate) tab: String, + pub(crate) backstage: bool, + pub(crate) backstage_page: String, + pub(crate) print: print_page::PrintSettings, + pub(crate) nav_collapsed: std::collections::HashSet, + pub(crate) keytip_prefix: String, + pub(crate) keytips: keytips::Phase, + pub(crate) alt_pressed: bool, + pub(crate) alt_chord_used: bool, + pub(crate) alt_key_down: bool, + pub(crate) alt_modifier_seen: bool, +} + +/// A word count that matches no document revision, so the next frame counts. +pub(crate) const NOT_COUNTED: (u64, usize) = (u64::MAX, 0); + +impl WindowUi { + /// A new window showing open document `document`: nothing open over the page, Home tab. + pub(crate) fn new(document: u64) -> WindowUi { + let UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + .. + } = UiState::default(); + WindowUi { + canvas: Default::default(), + controls: Default::default(), + dialog: None, + colors: None, + restrict: Default::default(), + status_msg: None, + previews: Default::default(), + change_picture_target: None, + recipient_list_pending: false, + dialog_pick: None, + focus_ribbon: false, + control_click: false, + keytip_rects: Vec::new(), + nav_search_focus: false, + comment_fonts_for: None, + find_fonts_for: None, + sent_title: String::new(), + sent_full_screen: false, + seen_full_screen: None, + escape_free: false, + word_count: NOT_COUNTED, + last_autosave: 0.0, + equation_prev_tab: None, + smart_art_item: None, + smart_art_row_focused: false, + smart_art_typing: None, + outline_prev_tab: None, + read_aloud_at: None, + awaiting_save: None, + file_dialog: None, + #[cfg(not(target_arch = "wasm32"))] + file_browser: Default::default(), + info_editing: None, + shown_document: document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } + } +} + +/// Swap each named field of the drawn window (bound by the caller's destructuring) with its +/// namesake in the [`WindowUi`]. The `WindowUi` pattern is exhaustive, so a field added there +/// and left out here doesn't compile. +macro_rules! swap_fields { + ($w:ident: $($(#[$attr:meta])* $field:ident),* $(,)?) => { + let WindowUi { $($(#[$attr])* $field: _),* } = &*$w; + $($(#[$attr])* std::mem::swap($field, &mut $w.$field);)* + }; +} + +impl WordApp { + /// Exchange the drawn window's state with `w`'s: afterwards the app draws the window `w` + /// held, and `w` holds the one that was drawn. App-wide and per-document state stays. The + /// session's active document is not changed: the caller activates `w`'s document + /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch + /// within the window. + #[cfg_attr(not(test), expect(dead_code, reason = "drawing one native window per document swaps windows in"))] + pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { + let WordApp { + // App-wide. + session: _, + services: _, + printing: _, + print_feedback_seen: _, + font_generation: _, + integrated_titlebar: _, + mac_titlebar: _, + control_rx: _, + shot_token: _, + queued_shots: _, + pending_shots: _, + synthetic: _, + styled: _, + fonts_hans: _, + fonts_korean: _, + fonts_system_cjk: _, + want_system_cjk: _, + recent_fonts_for: _, + fonts_frames: _, + applied_theme: _, + native_scale: _, + legacy_interface_size: _, + desktop_theme: _, + desktop_dark: _, + applied_editing: _, + system_animations: _, + applied_animations: _, + frame_ms: _, + quit_requested: _, + autosave: _, + zotero: _, + ctx: _, + read_aloud_error: _, + saves_in_flight: _, + recovery_seen: _, + no_document: _, + // Per document: the shown document's, swapped with `parked` on a document switch. + autosave_path: _, + comment_drafts: _, + parked: _, + // Per window. + ui, + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + } = self; + let UiState { + // App-wide: preferences, recent lists, and the settings the session owns while + // running. + ribbon_collapsed: _, + quick_access_toolbar: _, + recent: _, + recent_symbols: _, + recent_colors: _, + recent_fonts: _, + theme: _, + interface_size: _, + legacy_interface_size: _, + animations: _, + legacy_dark: _, + nav_tab: _, + nav_pane: _, + show_discord: _, + author: _, + window: _, + language: _, + editing: _, + read_aloud_rate: _, + read_aloud_skip_citations: _, + keytips_enabled: _, + dark_page: _, + check_spelling: _, + caret_animation: _, + keyboard: _, + ribbon: _, + caption_labels: _, + last_caption_label: _, + ink_pens: _, + initials: _, + options_pane: _, + recent_count: _, + mini_toolbar: _, + alignment_guides: _, + start_screen: _, + print_backgrounds: _, + update_fields_before_print: _, + dictionary: _, + // Per window. + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = ui; + swap_fields!(w: + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_engine::Session; + + use super::WindowUi; + use crate::{Services, WordApp, dialogs::Dialog}; + + /// Swapping in a new window shows its fresh state; swapping back restores the first window's + /// dialog, status message, composition, scroll request and tab. The session and the + /// preferences stay put both ways. + #[test] + fn swap_window_round_trip() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let doc = a.session.document_id(); + let rev = a.session.rev(); + a.ui.recent_count = 7; + a.ui.tab = "Insert".into(); + a.dialog = + Some(Dialog::SaveChanges { name: "Report".into(), then: "file.close".into(), params: json!({}), document: doc, message: String::new() }); + a.status_msg = Some(("Saved".into(), 1.0)); + a.canvas.ime_preedit = "ni".into(); + a.canvas.ime_preedit_range = Some(0..2); + a.canvas.scroll_to_caret = false; + a.canvas.nav_to_caret = true; + + let mut w = WindowUi::new(doc); + a.swap_window(&mut w); + assert!(a.dialog.is_none() && a.status_msg.is_none()); + assert!(a.canvas.ime_preedit.is_empty() && a.canvas.ime_preedit_range.is_none()); + assert!(a.canvas.scroll_to_caret && !a.canvas.nav_to_caret, "a new window goes to the caret"); + assert_eq!(a.ui.tab, crate::DEFAULT_TAB); + assert_eq!(w.tab, "Insert"); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7), "app-wide state stays"); + + a.swap_window(&mut w); + assert_eq!(a.dialog.as_ref().map(Dialog::name), Some("saveChanges")); + assert_eq!(a.status_msg.as_ref().map(|(m, _)| m.as_str()), Some("Saved")); + assert_eq!((a.canvas.ime_preedit.as_str(), a.canvas.ime_preedit_range.clone()), ("ni", Some(0..2))); + assert!(!a.canvas.scroll_to_caret && a.canvas.nav_to_caret); + assert_eq!(a.ui.tab, "Insert"); + assert!(w.dialog.is_none() && w.tab == crate::DEFAULT_TAB); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7)); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept"); + } +} From 9feaa3d480318ea18f5dacfe71120ba021335dfc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:54:37 +0000 Subject: [PATCH 15/87] W3b: UI: one native window per open document New and Open open the document in a window of its own (an egui viewport, craft-window's window list and driver), focused and cascaded; Open replaces an untouched blank document in its window and brings an already-open file's window forward. Each window keeps its own interface state and draws its own document. Close and a window's close button close that window, asking first; the root viewport adopts another window when its own closes; the last window stays. Quitting asks about each document in its window. Requests for another window's document go to that window. Control input, screenshots and the saved geometry follow the active window. The web app keeps one window. --- Cargo.lock | 10 + apps/wordcraft/src/apple_events.rs | 7 +- apps/wordcraft/src/main.rs | 12 +- crates/engine/src/session/documents.rs | 8 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/backstage.rs | 9 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/documents.rs | 82 ++- crates/ui-egui/src/lib.rs | 263 +++++-- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/previews.rs | 5 +- crates/ui-egui/src/read_aloud.rs | 8 + crates/ui-egui/src/window_ui.rs | 3 +- crates/ui-egui/src/windows.rs | 984 +++++++++++++++++++++++++ crates/ui-egui/src/zotero.rs | 12 + 16 files changed, 1306 insertions(+), 110 deletions(-) create mode 100644 crates/ui-egui/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 538b4542f8..3887eda6ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,15 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2#4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -5401,6 +5410,7 @@ version = "0.9.0" dependencies = [ "cfb", "craft-ui", + "craft-window", "egui", "egui_kittest", "image", diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 71d0d05b4d..64f534392d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -55,7 +55,12 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { } } } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + // Quitting asks about every document with unsaved changes, each in its window; the + // app closes once that's done (`quit_requested`). + Event::Quit => { + app.quit(); + ctx.request_repaint(); + } } } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 85410340da..094ad6c20f 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -54,8 +54,9 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } + // The geometry saved for the next start is the active window's. let prev = self.0.ui.window; - self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); + self.0.ui.window = ctx.input_for(self.0.active_viewport(), |i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a // window that opened at its normal size so it never flashed on screen half-opened. let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some())); @@ -65,9 +66,11 @@ impl eframe::App for App { if let Some(command) = maximize { ctx.send_viewport_cmd(command); } - // The window's close button: with unsaved changes, ask first (`WordApp::close_requested`). + // The window's close button: with other windows open only this window closes (the app + // handles that while drawing it); the last one quits, asking first about unsaved changes + // (`WordApp::close_requested`). let closing = ctx.input(|i| i.viewport().close_requested()); - if closing && !self.0.close_requested() { + if closing && (self.0.closes_a_window() || !self.0.close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } else if (closing || self.0.quit_requested) && !self.0.printing.busy() { // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in @@ -397,6 +400,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64"))); load_user_fonts(); + // Windows opened for further documents start like the first one, before its saved place. + let window_builder = options.viewport.clone(); let restored = saved_window(); if let Some(window) = restored { options.viewport = window.apply(options.viewport); @@ -447,6 +452,7 @@ fn main() -> eframe::Result { } } app.ui.window = restored; + app.set_window_builder(window_builder); app.integrated_titlebar = cfg!(target_os = "macos"); #[cfg(target_os = "macos")] if let Some(bar) = traffic_lights::title_bar() { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d302aa5ff3..8d5c2fbbcd 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -400,6 +400,14 @@ impl Session { Ok(()) } + /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud + /// reading of another document carries on: for a front end that shows each document in a + /// window of its own, and makes each window's document active to draw it. An id that isn't + /// open is an error. + pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id) + } + /// Open `doc` as another document and make it active; the one that was active stays open. /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed412196..65d3e1056f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +# One native window per open document: the window list, the Window menu and the egui viewports. +craft-window = { git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", features = ["egui"] } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6c606dec60..cceb2b5fa8 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -325,7 +325,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); - let id = egui::Id::new("backstage_template_search"); + let id = app.window_id("backstage_template_search"); let mut query = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_default(); let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0)); if search.changed() { @@ -1073,14 +1073,15 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); // The search covers both. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string())); + let search = h.state().window_id("backstage_template_search"); + h.ctx.data_mut(|d| d.insert_temp(search, "memo".to_string())); h.run_steps(3); assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string())); + h.ctx.data_mut(|d| d.insert_temp(search, "zzz".to_string())); h.run_steps(3); assert!(h.query_by_label("No templates match your search.").is_some()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new())); + h.ctx.data_mut(|d| d.insert_temp(search, String::new())); h.run_steps(3); h.get_by_label("Team memo").click(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2803ef2ef1..112e08e904 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -49,7 +49,7 @@ fn err(e: impl std::fmt::Display) -> Outcome { } pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { - let r = ctx.content_rect(); + let r = ctx.input_for(app.active_viewport(), |i| i.content_rect()); let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h})); json!({ "ui": app.prefs(), @@ -358,11 +358,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } "ui.resize" => { let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(json!({})) } "ui.focus" => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::Focus); app.canvas.want_focus = true; ok(json!({})) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a945bf075..4512a542d8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3763,7 +3763,8 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f /// Search's "Find … in the document": the Navigation pane opens on its results for `text`. fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string())); + let qid = app.window_id("nav_query"); + ctx.data_mut(|d| d.insert_temp(qid, text.to_string())); app.ui.nav_tab = "results".into(); let _ = app.run("view.navigationPane", json!({"value": true})); let _ = app.run("edit.find", json!({"text": text})); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index a6b5344cb8..5f8645ccf9 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,10 +1,11 @@ -//! Several open documents in one window. The window shows the session's active document; what -//! it keeps about a document (AutoSave's file, comment drafts) follows the active one, and the -//! window's own state about the page (drags, menus, the composition…) starts afresh when the -//! document changes ([`WordApp::sync_document`]; per-window state is listed in +//! Several open documents. On the desktop each has a window of its own ([`crate::windows`]); the +//! web app shows them in one window, which shows the session's active document. What the app +//! keeps about a document (AutoSave's file, comment drafts) follows the active one. In one +//! window, the window's own state about the page (drags, menus, the composition…) starts afresh +//! when the document changes ([`WordApp::sync_document`]; per-window state is listed in //! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close -//! closes the shown one and shows the next, and quitting asks about every document with unsaved -//! changes, one after another. +//! closes the shown one, and quitting asks about every document with unsaved changes, one after +//! another, each in its window. use serde_json::{Value, json}; use wordcraft_engine::DocInfo; @@ -28,23 +29,22 @@ impl WordApp { /// dialogs and menus, drags, the input method's composition, the status message, caches /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per /// document; the zoom is in the session's view). Runs after every command and every frame. + /// With native windows ([`crate::windows`]) the window that shows the document comes to the + /// front instead, or a new one opens for it. pub(crate) fn sync_document(&mut self) { let id = self.session.document_id(); + if self.windows.native() { + self.prune_windows(); + } if id == self.shown_document { return; } - let left = std::mem::replace(&mut self.shown_document, id); - let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; - if self.session.is_open(left) { - self.parked.insert(left, leaving); + if self.windows.native() && self.follow_in_windows(id) { + return; } - let back = self.parked.remove(&id); - let seen = back.is_some(); - let back = back.unwrap_or_default(); - self.autosave_path = back.autosave_path; - self.comment_drafts = back.comment_drafts; - let session = &self.session; - self.parked.retain(|d, _| session.is_open(*d)); + let left = std::mem::replace(&mut self.shown_document, id); + let seen = self.carry_document_state(left, id); + self.window_shows(id); self.canvas.show_other_document(seen); self.controls = Default::default(); @@ -76,6 +76,32 @@ impl WordApp { } } + /// The active document is `to` and no longer `from`: `from`'s own state is put aside (or + /// dropped, if it was closed or replaced) and `to`'s comes back. Makes `to` the session's + /// active document. Returns whether `to` had state put aside. + pub(crate) fn carry_document_state(&mut self, from: u64, to: u64) -> bool { + if self.session.document_id() != to + && let Err(e) = self.session.switch_document(to) + { + log::error!("couldn't show document {to}: {e}"); + } + if from == to { + return false; + } + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(from) { + self.parked.insert(from, leaving); + } + let back = self.parked.remove(&to); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + seen + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; @@ -153,13 +179,18 @@ pub(crate) fn untitled_title(english: &str) -> Option { }) } -/// View › Window › Switch Windows: the open documents in the order they were opened, the shown -/// one marked, unsaved ones with the title bar's dot. Choosing one shows it. +/// View › Window › Switch Windows: the open windows (in one window, the open documents) in the +/// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one +/// brings its window to the front (in one window, shows the document). pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { - for d in app.session.documents() { - let label = format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }); - if ui.add(egui::Button::selectable(d.active, label).min_size(egui::vec2(220.0, 0.0))).clicked() { - let _ = app.run("view.switchWindows", json!({"id": d.id})); + let entries: Vec<(u64, String, bool)> = if app.windows.native() { + app.switch_windows_entries() + } else { + app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() + }; + for (doc, label, current) in entries { + if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + let _ = app.run("view.switchWindows", json!({"id": doc})); ui.close(); } } @@ -268,11 +299,12 @@ mod tests { assert!(a.close_requested()); } - /// A request an agent's command makes for a document that isn't shown never acts on the - /// shown one: Print doesn't open Backstage over it. + /// In one window (the web app), a request an agent's command makes for a document that isn't + /// shown never acts on the shown one: Print doesn't open Backstage over it. #[test] fn requests_for_a_document_not_shown_leave_the_window_alone() { let mut a = app(); + a.windows.single(); let first = a.session.document_id(); a.run("file.new", json!({})).unwrap(); a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66ab2ea658..0f1ccf9b74 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ pub mod widgets; pub mod window_close; pub mod window_geometry; mod window_ui; +mod windows; pub mod wordart_menus; pub mod zotero; @@ -367,6 +368,9 @@ pub struct UiState { /// no longer applies (a contextual tab after the selection moved away, a tab hidden in Customize /// Ribbon). Word opens on Home every launch; see [`UiState::tab`]. pub const DEFAULT_TAB: &str = "Home"; + +/// ⌘Q, which only the Mac sends: quit. +const QUIT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q); /// Most recent documents kept (File › Options › Advanced offers 0–50, as Word does). pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. @@ -667,6 +671,10 @@ pub struct WordApp { shown_document: u64, /// The per-document state of the open documents not shown. parked: std::collections::HashMap, + /// The open windows ([`windows`]). + pub(crate) windows: windows::Windows, + /// The document the last print job was for: its window reports how the job went. + print_document: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -797,6 +805,8 @@ impl WordApp { no_document: None, comment_drafts: Default::default(), parked: Default::default(), + windows: windows::Windows::new(shown_document), + print_document: None, canvas, controls, dialog, @@ -1442,9 +1452,10 @@ impl WordApp { self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, as Word does - /// (#1712): the next open document shows, or, when it was the only one, no page, as after Esc - /// on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// File › Close (Mod+W, Mod+F4) closes the shown document. Its window closes too while other + /// windows are open, and the one focused before it comes forward (in the web app's one window, + /// the next document shows). The last window stays, as Word's does (#1712), with no page, as + /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, /// from the window's close button, goes on quitting: the shown document's prompt has been /// answered, so it closes and the next document with unsaved changes is asked about /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved @@ -1465,21 +1476,23 @@ impl WordApp { self.quit_requested = true; return Ok(json!({"quit": true})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; - self.sync_document(); + self.close_shown_document()?; return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.close_shown_document()?; self.recovery_seen = true; - self.sync_document(); if last { self.no_document = Some(self.session.document_id()); self.session.release_untitled_name(); } - self.ui.backstage = false; + // The window that stays leaves the File page; another window that came forward keeps + // its own. + if last || !self.windows.native() { + self.ui.backstage = false; + } Ok(json!({"closed": true})) } @@ -1528,12 +1541,16 @@ impl WordApp { } /// Requests commands make of the UI (open a dialog…). - fn handle_request(&mut self, req: &Value) { - // Made for a document that isn't shown (an agent's command run there). + pub(crate) fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there): its window handles + // it when it draws. if let Some(document) = req.get("document").and_then(Value::as_u64) && document != self.session.document_id() { - self.handle_request_for(document, req); + let app_wide = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if app_wide || req.get("close").is_some() || !self.queue_for_window(document, req) { + self.handle_request_for(document, req); + } return; } // Ctrl+F: type straight into the Navigation pane's search box (#712). @@ -1581,10 +1598,10 @@ impl WordApp { } } - /// A request made for document `document`, which isn't shown: the interface theme is the - /// app's and changes; Close closes that document if it has nothing unsaved; the rest (dialogs, - /// panes, focus, scrolling) are about the window's page and are dropped, as the page shows - /// another document. + /// A request made for document `document`, which isn't shown and which no other window + /// handles: the interface theme is the app's and changes; Close closes that document if it + /// has nothing unsaved; the rest (dialogs, panes, focus, scrolling) are about a window's page + /// and are dropped, as no window shows the document. fn handle_request_for(&mut self, document: u64, req: &Value) { let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); if theme_only { @@ -1605,7 +1622,7 @@ impl WordApp { self.sync_document(); return; } - log::warn!("dropped a request for document {document}, which isn't shown: {req}"); + log::warn!("dropped a request for document {document}, which no window shows: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no @@ -1936,6 +1953,7 @@ impl WordApp { /// Submit the selected print job; the implementation lives beside its localized controls. fn print_to_system(&mut self, p: &Value) -> Result { + self.print_document = Some(self.session.document_id()); printing::submit(self, p) } @@ -2189,6 +2207,11 @@ impl WordApp { /// Terminal feedback also reaches the normal window when printing starts through control. pub fn poll_printing(&mut self, ctx: &egui::Context) { self.printing.poll(ctx); + self.print_feedback(); + } + + /// Say how the print job went, once, in the window showing the printed document. + fn print_feedback(&mut self) { if self.printing.message == self.print_feedback_seen { return; } @@ -2228,17 +2251,6 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); - // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's - // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does - // resetting the focus direction: the item keeps focus after its level changes. - if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { - let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); - let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); - if shift_tab || tab { - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); - } - } // 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): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later @@ -2294,12 +2306,16 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - self.poll_printing(ctx); + let native = self.windows.native(); + if native { + self.printing.poll(ctx); + } else { + self.poll_printing(ctx); + } self.drain_control(ctx); - zotero::poll(self, ctx); - read_aloud::poll(self, ctx); - self.clear_stale_change_picture(); - let _ = self.poll_file_dialog(); + // The active window's own work; with native windows each window does it again as it + // draws, for itself. + self.window_work(ctx); self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); @@ -2321,15 +2337,62 @@ impl WordApp { if let Some(wait) = self.session.recovery_wait() { ctx.request_repaint_after(wait); } + if !native { + self.window_input(ctx); + } + self.issue_screenshots(ctx); + self.send_control_input(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + // The open above may have changed the file name the caption draws. + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_recent_fonts(); + self.ensure_find_fonts(); + self.install_ui_fonts(ctx, lang); + self.report_dirty(); + } + + /// A window's own work, with its state in place: print feedback for its document, Zotero, + /// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the + /// active window and, with native windows, as each window draws. + fn window_work(&mut self, ctx: &egui::Context) { + if self.windows.native() { + let doc = self.session.document_id(); + if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) { + self.print_feedback(); + } + } + zotero::poll(self, ctx); + read_aloud::poll(self, ctx); + self.clear_stale_change_picture(); + let _ = self.poll_file_dialog(); 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(); + } + + /// A window's own input before it draws: the SmartArt Text Pane's Tab keys, ⌘Q, screenshots + /// of it, files dropped on it. Runs in [`WordApp::logic`] with one window, else as each + /// window draws (each native window has its own input). + fn window_input(&mut self, ctx: &egui::Context) { + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's + // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does + // resetting the focus direction: the item keeps focus after its level changes. + if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } } + if self.windows.native() && ctx.input_mut(|i| i.consume_shortcut(&QUIT)) { + self.quit(); + } + self.collect_screenshots(ctx); #[cfg(not(target_arch = "wasm32"))] let mut placed_picture = false; #[cfg(not(target_arch = "wasm32"))] @@ -2354,13 +2417,12 @@ impl WordApp { }; } } - // The open above may have changed the file name the caption draws. - self.ensure_caption_fonts(); - self.ensure_comment_fonts(); - self.ensure_recent_fonts(); - self.ensure_find_fonts(); - self.install_ui_fonts(ctx, lang); - self.report_dirty(); + // The drawn window's caption, comments and Find results borrow faces it may not have. + if self.windows.native() { + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_find_fonts(); + } } /// Install the interface fonts when the language, the CJK fallback, or a caption fallback @@ -2369,17 +2431,29 @@ impl WordApp { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk { - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &self.canvas.ime_faces); + // Every window's borrowed faces: the fonts are the app's. + let mut faces = self.canvas.ime_faces.clone(); + for w in self.other_windows() { + for face in &w.canvas.ime_faces { + if !faces.iter().any(|f| f.id() == face.id()) { + faces.push(face.clone()); + } + } + } + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); 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. // egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph - // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. + // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. egui would close only the + // window it was pressed in, so with native windows the app quits itself + // ([`WordApp::window_logic_before`]). + let quit = (!self.windows.native()).then_some(QUIT).into_iter().collect(); ctx.options_mut(|o| { o.zoom_with_keyboard = false; - o.quit_shortcuts = vec![egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q)]; + o.quit_shortcuts = quit; }); self.styled = true; } @@ -2447,23 +2521,17 @@ impl WordApp { } /// Read a Control-click as the right-click on macOS, then inject synthetic input events from - /// the control channel (one pointer event per frame). + /// the control channel (one pointer event per frame) when the active window draws in the root + /// viewport; [`windows::ViewportInput`] does both for the other windows. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic.is_empty() { + if self.ctx.as_ref().is_some_and(|ctx| !self.control_input_is_roots(ctx)) { 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()); - if self.synthetic.iter().take(n).any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + let Some(events) = self.next_synthetic() else { return }; + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { // Control input belongs to this window's remembered widget even while another app // is active. Give only this input frame egui focus, without requesting OS activation. raw.focused = true; @@ -2472,11 +2540,44 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events); } - /// Lay out the window. + /// The control channel's next input frame: one pointer event (after a move to it), or keys + /// and text up to the next key release. + fn next_synthetic(&mut self) -> Option> { + if self.synthetic.is_empty() { + return None; + } + 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), + }; + let mut events = Vec::new(); + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + events.extend(self.synthetic.drain(..n)); + Some(events) + } + + /// Lay out the windows: on the desktop each open document's in its own native window + /// ([`windows`]), on the web the one window. pub fn ui(&mut self, ui: &mut egui::Ui) { + if self.windows.native() { + self.windows_ui(ui); + } else { + self.window_ui(ui); + } + } + + /// Lay out the drawn window. A command that brings another window's state in (New, Open, + /// Switch Windows) ends this window's drawing for the frame: the rest would be the other + /// window's. + fn window_ui(&mut self, ui: &mut egui::Ui) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; let t0 = now_ms(); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.ui.language)); @@ -2517,38 +2618,58 @@ impl WordApp { chrome::screen_bar(self, ui, screen); } else { chrome::title_bar(self, ui); + if moved(self) { + return; + } ribbon::show(self, ui); + if moved(self) { + return; + } chrome::status_bar(self, ui); panes::show(self, ui); } + if moved(self) { + return; + } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { canvas::show(self, ui); }); - if self.focus_ribbon { + if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, &ctx); } } + if moved(self) { + return; + } content_controls::popup(self, &ctx); dialogs::show(self, &ctx); + if moved(self) { + return; + } #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, &ctx); colors::show(self, &ctx); zotero::show_alert(self, &ctx); read_aloud::show(self, &ctx); crate::keytips::show(self, &ctx, ui); + if moved(self) { + return; + } keys::global_shortcuts(self, &ctx); + if moved(self) { + return; + } self.refocus_after_chrome_click(&ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } - let title = if self.has_document() { - format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) - } else { - "WordCraft".to_string() - }; + // With native windows the viewport driver gives the window its title. + let title = self.window_title(); if title != self.sent_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + if !self.windows.driven() { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + } self.sent_title = title; } self.sync_full_screen(&ctx); @@ -2700,12 +2821,14 @@ impl WordApp { self.control_rx = Some(rx); } + /// Ask the active window for the screenshots `ui.screenshot` queued. fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); + let viewport = self.active_viewport(); 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))); + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { true @@ -6707,7 +6830,8 @@ mod tests { assert!(!closes(&h), "Ctrl+Q doesn't close the window"); h.event(key(egui::Key::Q, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND)); h.step(); - assert!(closes(&h), "⌘Q still quits on the Mac"); + // Quitting asks about the typed text first, in the document's window. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "⌘Q still quits on the Mac"); } /// #3424: Pressing Ctrl+Alt+Q (German shortcut for @, or AltGr+Q) types '@' and does not @@ -8909,7 +9033,8 @@ mod tests { let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); h.step(); - let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::new("nav_query"))).unwrap_or_default(); + let nav_query = + |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(h.state().window_id("nav_query"))).unwrap_or_default(); // Ctrl+F: the pane, and typing goes into its search box. h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3b9afb6113..35eabcf428 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.navigationPane", json!({"value": false})); return; } - let qid = egui::Id::new("nav_query"); + let qid = app.window_id("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). let focus = std::mem::take(&mut app.nav_search_focus); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 721776f829..fd0fe5b539 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -522,7 +522,8 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); - let start = ui.data(|d| d.get_temp::(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let start_id = app.window_id("gallery_start"); + let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(styles.len().saturating_sub(1)); // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); @@ -595,7 +596,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let _ = app.run("view.stylesPane", json!({"value": true})); } else { let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; - ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n)); + ui.data_mut(|d| d.insert_temp(start_id, n)); } } } diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 8d77a1bab0..ea275b477d 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -60,6 +60,14 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { return; } + // With a window per document, the player is in the window of the document being read. + if app.windows.native() + && let Some(d) = app.session.read_aloud.document + && d != app.session.document_id() + && app.session.is_open(d) + { + return; + } let t = Tokens::get(ctx); let state = app.session.read_aloud.state(); let (index, count) = app.session.read_aloud.progress(); diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index d79827adf8..95c8b99333 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -151,7 +151,6 @@ impl WordApp { /// session's active document is not changed: the caller activates `w`'s document /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch /// within the window. - #[cfg_attr(not(test), expect(dead_code, reason = "drawing one native window per document swaps windows in"))] pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { let WordApp { // App-wide. @@ -191,6 +190,8 @@ impl WordApp { saves_in_flight: _, recovery_seen: _, no_document: _, + windows: _, + print_document: _, // Per document: the shown document's, swapped with `parked` on a document switch. autosave_path: _, comment_drafts: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 0000000000..2ff535c23c --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,984 @@ +//! One native window per open document. Each window is an egui viewport, driven by +//! [`craft_window::Viewports`]: one window draws in the root viewport, every other one in an +//! immediate viewport of its own, and each draws the whole interface (title bar, ribbon, page, +//! status bar, dialogs) for its own document. [`WordApp`]'s fields hold the drawn window's state +//! ([`crate::window_ui`]); the other windows keep theirs in their [`Window`]. Between frames the +//! fields hold the active window's (the one focused last), so the control channel, the MCP +//! bridge and the host's menus act on it. +//! +//! A frame: [`WordApp::logic`] does the app-wide work once (the control channel, recovery, +//! timers, printing). [`WordApp::ui`] then draws the windows in turn: for each it swaps the +//! window's state in, makes its document the session's active one, does the window's own work +//! (its file dialog, AutoSave, dropped files, Read Aloud, requests queued for it) and draws it. +//! Last, the active window's state comes back. +//! +//! Which window shows what follows the session ([`WordApp::sync_document`]): a document made +//! active that no window shows (New, Open) gets a new window, focused and cascaded from the last +//! one; a document a window shows brings that window to the front; a window whose document closed +//! closes, and the window focused before it comes forward. Opening a file replaces a blank +//! document nobody used in its own window, and the last window stays open with no document (the +//! empty window the Start screen also leaves). The web app has one window and switches documents +//! in it ([`crate::documents`]). + +use std::collections::HashMap; + +use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; +use egui::{ViewportCommand, ViewportId}; +use serde_json::{Value, json}; + +use crate::{WordApp, documents, window_ui::WindowUi}; + +/// The window title for a document called `name`. +pub(crate) fn title(name: &str, dirty: bool) -> String { + format!("{name}{} - WordCraft", if dirty { " •" } else { "" }) +} + +/// An open window. +pub(crate) struct Window { + /// The app's key for the window. A window made while the windows are being drawn gets its + /// [`WindowId`] once the frame's drawing ends. + key: u64, + id: Option, + /// The document it shows. + doc: u64, + /// Its state while another window's is in [`WordApp`]'s fields; `None` for that window. + ui: Option>, + /// Requests commands made for its document while another window was drawn + /// ([`WordApp::handle_request`]), handled when it draws next. + requests: Vec, + /// The title it last drew, for its viewport. + title: String, +} + +/// The app's windows. See the module documentation. +pub struct Windows { + /// Separate native windows (desktop). Off, one window shows every document in turn (web). + native: bool, + /// Every window, with its key, for the viewport driver: opening and focus order, positions. + /// Lent to the driver while the windows draw. + list: WindowList, + viewports: Option, + /// Open windows in opening order. + open: Vec, + /// The window whose state is in [`WordApp`]'s fields. + shown: u64, + next_key: u64, + /// The windows are being drawn: `list` is lent out, so windows opened or closed now join or + /// leave it when the drawing ends. + showing: bool, + /// Window keys, most recently focused first, as the drawing started. + recent: Vec, + /// A window to bring to the front when the drawing ends. + focus: Option, + /// View › Window › Switch Windows and the window titles, as the frame's drawing started. + menu: WindowMenu, + /// What a new window's viewport starts from: size, icon, app id ([`WordApp::set_window_builder`]). + builder: egui::ViewportBuilder, + /// [`ViewportInput`] is registered with egui. + input_plugin: bool, + /// The viewport driver draws the windows this frame, and titles them. + driven: bool, +} + +impl Windows { + /// One window, showing document `doc`. + pub(crate) fn new(doc: u64) -> Windows { + let mut list = WindowList::new(); + let id = list.open(1); + let mut viewports = Viewports::new(Frame::Desktop); + viewports.set_root(Some(id)); + Windows { + native: cfg!(not(target_arch = "wasm32")), + list, + viewports: Some(viewports), + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new() }], + shown: 1, + next_key: 2, + showing: false, + recent: vec![1], + focus: None, + menu: WindowMenu::default(), + builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]), + input_plugin: false, + driven: false, + } + } + + /// Whether documents get native windows of their own. + pub(crate) fn native(&self) -> bool { + self.native + } + + /// One window for every document, as in the web app. + #[cfg(test)] + pub(crate) fn single(&mut self) { + self.native = false; + } + + /// Whether the viewport driver titles the windows (else the drawn window sends its title). + pub(crate) fn driven(&self) -> bool { + self.driven + } + + /// The key of the window whose state is in [`WordApp`]'s fields. + pub(crate) fn shown(&self) -> u64 { + self.shown + } + + fn get(&self, key: u64) -> Option<&Window> { + self.open.iter().find(|w| w.key == key) + } + + fn get_mut(&mut self, key: u64) -> Option<&mut Window> { + self.open.iter_mut().find(|w| w.key == key) + } + + /// The window showing document `doc`, the most recently focused if several do. + fn of_doc(&self, doc: u64) -> Option { + self.recent_keys().into_iter().find(|&k| self.get(k).is_some_and(|w| w.doc == doc)) + } + + /// Open windows, most recently focused first; windows opened during this frame's drawing + /// come first. + fn recent_keys(&self) -> Vec { + let mut keys: Vec = self.open.iter().rev().filter(|w| w.id.is_none()).map(|w| w.key).collect(); + let listed: Vec = + if self.showing { self.recent.clone() } else { self.list.recent().filter_map(|id| self.list.get(id).copied()).collect() }; + keys.extend(listed.into_iter().filter(|&k| self.get(k).is_some())); + for w in &self.open { + if !keys.contains(&w.key) { + keys.push(w.key); + } + } + keys + } + + /// The document of the window focused last before `key`'s, if any. + fn next_doc(&self, key: u64) -> Option { + if !self.native { + return None; + } + self.recent_keys().into_iter().find(|&k| k != key).and_then(|k| self.get(k)).map(|w| w.doc) + } + + /// The window's viewport. + fn viewport(&self, key: u64) -> ViewportId { + match (self.get(key).and_then(|w| w.id), &self.viewports) { + (Some(id), Some(v)) => v.viewport_id(id), + _ => ViewportId::ROOT, + } + } + + /// The title View › Window › Switch Windows gives the window, as the frame started. + fn menu_title(&self, key: u64) -> Option<&str> { + self.menu.title(self.get(key)?.id?) + } + + /// Bring the list up to date with the windows opened, closed and focused since it was last + /// lent out, and give the root viewport a window when its own closed. + fn settle(&mut self, ctx: Option<&egui::Context>) { + if self.showing { + return; + } + let gone: Vec = self.list.ids().filter(|id| !self.open.iter().any(|w| w.id == Some(*id))).collect(); + for id in gone { + self.list.close(id); + } + let active = self.list.active(); + let mut opened = false; + for w in self.open.iter_mut().filter(|w| w.id.is_none()) { + w.id = Some(self.list.open(w.key)); + opened = true; + } + let focus = self.focus.take().and_then(|k| self.get(k)).and_then(|w| w.id); + // A window opened behind the others (nothing asked to focus it) leaves the active one so. + if let Some(id) = focus.or(active.filter(|_| opened)) { + self.list.focus(id); + } + let Some(viewports) = self.viewports.as_mut() else { return }; + if !viewports.root().is_some_and(|r| self.list.contains(r)) + && let Some(next) = self.list.active() + { + match ctx { + Some(ctx) => viewports.adopt(ctx, &self.list, next), + None => viewports.set_root(Some(next)), + } + } + if let (Some(id), Some(ctx)) = (focus, ctx) { + viewports.focus(ctx, id); + } + if opened && let Some(ctx) = ctx { + ctx.request_repaint(); + } + } +} + +/// Control-channel input for a window in a viewport of its own: eframe's raw-input hook +/// ([`WordApp::raw_input_hook`]) sees only the root viewport's input, and egui runs a plugin's +/// input hook for every viewport. On macOS it also reads Control-click as the secondary button +/// in those windows, as the raw-input hook does in the root's. +#[derive(Default)] +pub(crate) struct ViewportInput { + /// The viewport the queued events go to, on its next pass. + target: Option, + events: Vec, + /// A viewport whose pass got keyboard input this way, until its window has looked. + keys_for: Option, + control_click: HashMap, +} + +impl egui::Plugin for ViewportInput { + fn debug_name(&self) -> &'static str { + "wordcraft_viewport_input" + } + + fn input_hook(&mut self, _ctx: &egui::Context, input: &mut egui::RawInput) { + let viewport = input.viewport_id; + if viewport == ViewportId::ROOT { + return; + } + if cfg!(target_os = "macos") { + crate::control_click_as_secondary(&mut input.events, self.control_click.entry(viewport).or_default()); + } + if self.target != Some(viewport) || self.events.is_empty() { + return; + } + self.target = None; + let events = std::mem::take(&mut self.events); + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + input.focused = true; + self.keys_for = Some(viewport); + } + input.events.extend(events); + } +} + +impl WordApp { + /// How many windows are open. + pub fn window_count(&self) -> usize { + if self.windows.native { self.windows.open.len() } else { 1 } + } + + /// The egui viewport of the active window, the one focused last: control input, + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. + pub fn active_viewport(&self) -> ViewportId { + if !self.windows.native { + return ViewportId::ROOT; + } + let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.windows.viewport(key) + } + + /// The viewport new windows start from: the host's size, icon and app id. Their title and + /// place are the app's. + pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { + self.windows.builder = builder; + } + + /// The root viewport was asked to close while other windows are open: only its window closes + /// (through [`WindowEvent::CloseRequested`] in [`WordApp::ui`]), so the host keeps the app + /// running. + pub fn closes_a_window(&self) -> bool { + self.window_count() > 1 && !self.quit_requested + } + + /// Quit the app (the app menu's Quit): ask about every document with unsaved work, each in + /// its window, as [`WordApp::close_requested`] does. True when the app may quit now; the + /// host closes it once `quit_requested` is set. + pub fn quit(&mut self) -> bool { + let go = self.close_requested(); + if go { + self.quit_requested = true; + } + go + } + + /// Put the drawn window's state away and draw window `key`, with its document active. False + /// when there is no such window. + pub(crate) fn enter_window(&mut self, key: u64) -> bool { + if key == self.windows.shown { + return true; + } + let Some(mut state) = self.windows.get_mut(key).and_then(|w| w.ui.take()) else { + return false; + }; + let from = self.shown_document; + self.swap_window(&mut state); + let left = std::mem::replace(&mut self.windows.shown, key); + if let Some(w) = self.windows.get_mut(left) { + w.doc = state.shown_document; + w.ui = Some(state); + } + let to = self.shown_document; + self.carry_document_state(from, to); + true + } + + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn + /// leaves the File page: whatever it was opening is open. + fn open_window(&mut self, doc: u64) { + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(doc)); + // Faces borrowed for the interface (non-Latin file names) stay installed. + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.ui.backstage = false; + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.enter_window(key); + self.focus_window(key); + } + + /// The drawn window's document has closed: draw window `to` and close this one. + fn close_shown_window(&mut self, to: u64) { + let closing = self.windows.shown; + if !self.enter_window(to) { + return; + } + self.windows.open.retain(|w| w.key != closing); + self.focus_window(to); + } + + /// Bring window `key` to the front (now, or once the frame's drawing ends) and make it the + /// active one. + pub(crate) fn focus_window(&mut self, key: u64) { + self.windows.focus = Some(key); + self.windows.settle(self.ctx.as_ref()); + } + + /// Keep one window per open document: windows whose document closed (an agent closed it) + /// close, and documents with no window (an agent made one while another was active) get one + /// behind the others. The active document is left to [`WordApp::sync_document`]. + pub(crate) fn prune_windows(&mut self) { + let shown = self.windows.shown; + let session = &self.session; + let before = self.windows.open.len(); + self.windows.open.retain(|w| w.key == shown || session.is_open(w.doc)); + let mut changed = self.windows.open.len() != before; + let active = self.session.document_id(); + for d in self.session.documents() { + if d.id == active || Some(d.id) == self.no_document || d.id == self.shown_document || self.windows.of_doc(d.id).is_some() { + continue; + } + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(d.id)); + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new() }); + changed = true; + } + if changed { + self.windows.settle(self.ctx.as_ref()); + } + } + + /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't + /// the drawn window's. Another open document shows in its window, which comes to the front, + /// or in a new window; when the drawn window's document has closed, its window closes and the + /// one showing `id` comes forward. False when the window itself is to show `id` instead: its + /// document was replaced, or it is the Start screen's empty window. + pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { + let left = self.shown_document; + let stand_in = self.no_document == Some(left); + if self.session.is_open(left) && !stand_in { + match self.windows.of_doc(id).filter(|&k| k != self.windows.shown) { + Some(key) => { + self.enter_window(key); + self.focus_window(key); + } + None => self.open_window(id), + } + return true; + } + if !self.session.is_open(left) + && let Some(key) = self.windows.of_doc(id).filter(|&k| k != self.windows.shown) + { + self.close_shown_window(key); + return true; + } + false + } + + /// The drawn window now shows document `id` (its own was replaced). + pub(crate) fn window_shows(&mut self, id: u64) { + let shown = self.windows.shown; + if let Some(w) = self.windows.get_mut(shown) { + w.doc = id; + } + } + + /// Close the active document. With windows, its window closes and the window focused before + /// it comes forward; else the window shows the next document. The session leaves a new blank + /// document when it was the last. + pub(crate) fn close_shown_document(&mut self) -> Result<(), String> { + let doc = self.session.document_id(); + let next = self.windows.next_doc(self.windows.shown); + self.session.close_document(doc).map_err(|e| e.to_string())?; + if let Some(next) = next + && self.session.is_open(next) + && let Err(e) = self.session.switch_document(next) + { + log::warn!("closing document {doc}: couldn't show document {next}: {e}"); + } + self.sync_document(); + Ok(()) + } + + /// A request made for open document `document`, which another window shows: that window + /// handles it when it draws next, and comes to the front when the request opens something + /// (a dialog, the Print page). False when no other window shows the document (or windows + /// aren't native). + pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { + if !self.windows.native { + return false; + } + let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; + let opens = req.get("open").is_some(); + if opens && !self.windows.showing { + // Between frames: the window comes forward and opens it now. + self.enter_window(key); + self.focus_window(key); + self.handle_request(req); + return true; + } + if let Some(w) = self.windows.get_mut(key) { + w.requests.push(req.clone()); + } + if opens { + self.focus_window(key); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + + /// View › Window › Switch Windows with native windows: every window by its title, the active + /// one marked, unsaved ones with the title bar's dot. Choosing one brings it to the front. + pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { + self.windows + .menu + .entries() + .iter() + .filter_map(|e| { + let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; + Some((w.doc, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + }) + .collect() + } + + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. + pub(crate) fn window_title(&self) -> String { + if !self.has_document() { + return "WordCraft".to_string(); + } + let name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + title(&name, self.session.dirty) + } + + /// Draw every window, each in its viewport (see the module documentation). + pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.prune_windows(); + self.windows.settle(Some(&ctx)); + self.windows.menu = self.window_menu(&self.windows.list); + // A backend that can't open native windows (egui embeds them in the root: tests, + // `ui_shot`) shows the active window alone. Embedded, every window would draw the same + // widgets in one viewport. + self.windows.driven = !ctx.embed_viewports() && self.windows.viewports.is_some(); + if !self.windows.driven { + let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.draw_window(ui, active); + self.after_windows(&ctx); + return; + } + let Some(mut viewports) = self.windows.viewports.take() else { return }; + let mut list = std::mem::take(&mut self.windows.list); + self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect(); + let titles: HashMap = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect(); + let base = self.windows.builder.clone(); + self.windows.showing = true; + let events = viewports.show( + ui, + &mut list, + |_, key| match titles.get(key).filter(|t| !t.is_empty()) { + Some(t) => base.clone().with_title(t.clone()), + None => base.clone().with_title("WordCraft"), + }, + |ui, _, key| self.draw_window(ui, *key), + ); + self.windows.showing = false; + self.windows.list = list; + self.windows.viewports = Some(viewports); + self.window_events(&ctx, &events); + self.after_windows(&ctx); + } + + /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the + /// active window's state comes back. + fn after_windows(&mut self, ctx: &egui::Context) { + self.prune_windows(); + self.windows.settle(Some(ctx)); + if let Some(&active) = self.windows.recent_keys().first() { + self.enter_window(active); + } + } + + /// View › Window › Switch Windows for the windows in `list`. + fn window_menu(&self, list: &WindowList) -> WindowMenu { + let docs = self.session.documents(); + let doc_of: HashMap = self.windows.open.iter().map(|w| (w.key, w.doc)).collect(); + WindowMenu::new(list, |_, key| { + let doc = doc_of.get(key).copied().unwrap_or_default(); + match docs.iter().find(|d| d.id == doc) { + Some(d) => Document { key: doc, name: documents::display_title(d), dirty: d.dirty }, + None => Document { key: doc, name: String::new(), dirty: false }, + } + }) + } + + /// Draw window `key` into its viewport's `ui`. + fn draw_window(&mut self, ui: &mut egui::Ui, key: u64) { + if !self.enter_window(key) { + return; + } + let ctx = ui.ctx().clone(); + let queued = self.windows.get_mut(key).map(|w| std::mem::take(&mut w.requests)).unwrap_or_default(); + for req in queued { + self.handle_request(&req); + } + self.control_keys_arrived(&ctx); + self.window_work(&ctx); + self.window_input(&ctx); + if self.windows.shown != key { + return; + } + self.window_ui(ui); + let title = self.sent_title.clone(); + if let Some(w) = self.windows.get_mut(key) { + w.title = title; + } + } + + /// The windows' focus changes and close requests from this frame. + fn window_events(&mut self, ctx: &egui::Context, events: &[WindowEvent]) { + for event in events { + let WindowEvent::CloseRequested(id) = *event else { continue }; + let Some(key) = self.windows.list.get(id).copied() else { continue }; + // The last window's close button quits: the host asks first ([`WordApp::close_requested`]). + if self.windows.open.len() <= 1 || self.quit_requested { + continue; + } + if self.windows.viewports.as_ref().and_then(Viewports::root) == Some(id) { + ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::CancelClose); + } + self.close_window(key); + } + } + + /// The close button of window `key` (not the last one): File › Close in that window, which + /// asks about unsaved changes first. + pub(crate) fn close_window(&mut self, key: u64) { + if !self.enter_window(key) { + return; + } + self.focus_window(key); + if !self.has_document() { + // An empty window beside others: nothing to ask about. + let _ = self.close_shown_document(); + return; + } + if let Err(e) = self.run("file.close", json!({})) { + log::warn!("closing a window: {e}"); + } + } + + /// The control channel's next input, for the active window when it has a viewport of its own: + /// handed to [`ViewportInput`], which gives it to that viewport's next pass. + pub(crate) fn send_control_input(&mut self, ctx: &egui::Context) { + if self.synthetic.is_empty() || self.control_input_is_roots(ctx) { + return; + } + if !self.windows.input_plugin { + ctx.add_plugin(ViewportInput::default()); + self.windows.input_plugin = true; + } + let target = self.active_viewport(); + let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); + if waiting { + return; + } + let Some(events) = self.next_synthetic() else { return }; + ctx.with_plugin(|p: &mut ViewportInput| { + p.target = Some(target); + p.events = events; + }); + ctx.request_repaint_of(target); + } + + /// Whether control input goes in through the root viewport's raw input: one window, the + /// active window drawn there, or viewports embedded in the root (tests). + pub(crate) fn control_input_is_roots(&self, ctx: &egui::Context) -> bool { + !self.windows.native || ctx.embed_viewports() || self.active_viewport() == ViewportId::ROOT + } + + /// Keys from the control channel reached this window's viewport: the page counts as focused + /// when its widget has the keyboard, as [`WordApp::raw_input_hook`] does for the root. + fn control_keys_arrived(&mut self, ctx: &egui::Context) { + if !self.windows.input_plugin { + return; + } + let viewport = ctx.viewport_id(); + let arrived = ctx + .with_plugin(|p: &mut ViewportInput| { + let hit = p.keys_for == Some(viewport); + if hit { + p.keys_for = None; + } + hit + }) + .unwrap_or(false); + if arrived && let Some((id, _)) = self.canvas.widget_id { + self.canvas.focused = ctx.memory(|m| m.has_focus(id)); + } + } + + /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a + /// search box's text, a gallery's first tile. + pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { + egui::Id::new(name).with(self.windows.shown) + } + + /// The windows other than the drawn one, for state every window contributes to (the faces + /// borrowed for the interface). + pub(crate) fn other_windows(&self) -> impl Iterator { + self.windows.open.iter().filter_map(|w| w.ui.as_deref()) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use egui::{RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportIdMap, ViewportInfo}; + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + thread_local! { + /// What the test backend tells each viewport about itself (focus, close requests). + static INFOS: RefCell> = RefCell::new(ViewportIdMap::default()); + /// Input events for each viewport's next pass. + static EVENTS: RefCell>> = RefCell::new(HashMap::new()); + /// The viewports that drew in the last frame, in order. + static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; + /// Viewport commands the app sent, by viewport. + static COMMANDS: RefCell>> = RefCell::new(HashMap::new()); + /// Textures uploaded (page rasters, the font atlas) since the counter was reset. + static UPLOADS: RefCell = const { RefCell::new(0) }; + /// Each viewport's last input-method output. + static IME: RefCell>> = RefCell::new(HashMap::new()); + } + + fn input(id: ViewportId) -> RawInput { + let mut raw = RawInput { + viewport_id: id, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + ..Default::default() + }; + raw.viewports = INFOS.with(|i| i.borrow().clone()); + raw.focused = raw.viewports.entry(id).or_default().focused.unwrap_or(false); + raw.events = EVENTS.with(|e| e.borrow_mut().remove(&id).unwrap_or_default()); + raw + } + + fn keep(out: egui::FullOutput, id: ViewportId) { + DRAWN.with(|d| d.borrow_mut().push(id)); + UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime)); + for (v, o) in &out.viewport_output { + COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); + } + out.drop_without_applying_deltas(); + } + + /// A backend with native viewports, as eframe's: each immediate viewport runs a pass of its + /// own, with its own input. + fn backend() -> egui::Context { + INFOS.with(|i| i.borrow_mut().clear()); + EVENTS.with(|e| e.borrow_mut().clear()); + COMMANDS.with(|c| c.borrow_mut().clear()); + UPLOADS.with(|u| *u.borrow_mut() = 0); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + egui::Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut raw = input(id); + raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + keep(ctx.run_ui(raw, |ui| cb(ui)), id); + }); + ctx + } + + /// One frame of every window. + fn frame(ctx: &egui::Context, a: &mut WordApp) { + DRAWN.with(|d| d.borrow_mut().clear()); + let mut raw = input(ViewportId::ROOT); + a.raw_input_hook(&mut raw); + let out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + keep(out, ViewportId::ROOT); + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + } + + fn frames(ctx: &egui::Context, a: &mut WordApp, n: usize) { + for _ in 0..n { + frame(ctx, a); + } + } + + /// Focus viewport `id` alone, as the desktop does when a window is clicked. + fn focus(id: ViewportId) { + INFOS.with(|i| { + let mut i = i.borrow_mut(); + for v in i.values_mut() { + v.focused = Some(false); + } + i.entry(id).or_default().focused = Some(true); + }); + } + + fn sent(id: ViewportId, cmd: &ViewportCommand) -> bool { + COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.contains(cmd))) + } + + fn app(text: &str) -> WordApp { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": text})).unwrap(); + a + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// New opens the document in a second native window, focused, with a fresh interface; the + /// first keeps its document, its ribbon tab and its open dialog, and gets them back when it + /// is focused again. + #[test] + fn new_opens_a_second_window_and_each_keeps_its_state() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 3); + a.ui.tab = "Insert".into(); + a.run("ui.dialog", json!({"name": "paragraph"})).unwrap(); + assert_eq!(prompt(&a), Some("paragraph")); + + a.run("file.new", json!({})).unwrap(); + let second = a.session.document_id(); + assert_ne!(second, first); + assert_eq!(a.window_count(), 2); + assert!(a.dialog.is_none() && a.ui.tab == crate::DEFAULT_TAB, "the new window starts afresh"); + frames(&ctx, &mut a, 3); + let child = a.active_viewport(); + assert_ne!(child, ViewportId::ROOT, "the new window has a viewport of its own"); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [child, ViewportId::ROOT], "both windows draw each frame"); + frames(&ctx, &mut a, 3); + assert!(UPLOADS.with(|u| *u.borrow()) > 0, "the pages were rendered"); + UPLOADS.with(|u| *u.borrow_mut() = 0); + frames(&ctx, &mut a, 2); + assert_eq!(UPLOADS.with(|u| *u.borrow()), 0, "a window whose document didn't change renders no page again"); + assert!(a.sent_title.starts_with("Document2"), "{}", a.sent_title); + assert_eq!(a.session.document_id(), second); + a.run("text.insert", json!({"text": "Second"})).unwrap(); + + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(a.session.document_id(), first); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("First".into(), "Insert", Some("paragraph"))); + focus(child); + frames(&ctx, &mut a, 2); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("Second".into(), crate::DEFAULT_TAB, None)); + } + + fn docx_on_disk(name: &str, body: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.docx")); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": body})).unwrap(); + s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + path + } + + /// Open of a file a window already shows brings that window to the front; Open with only an + /// untouched blank document open replaces it in its own window. + #[test] + fn open_focuses_the_files_window_or_reuses_a_blank_one() { + let path = docx_on_disk("report", "Report"); + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + let window = a.windows.shown(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((a.window_count(), a.windows.shown(), text(&a).as_str()), (1, window, "Report"), "the blank document's window shows it"); + let report = a.session.document_id(); + + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert_ne!(a.session.document_id(), report); + COMMANDS.with(|c| c.borrow_mut().clear()); + let r = a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["alreadyOpen"], true); + assert_eq!((a.window_count(), a.windows.shown(), a.session.document_id()), (2, window, report)); + frames(&ctx, &mut a, 1); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "the file's window comes to the front"); + assert_eq!(a.session.document_id(), report); + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } + + /// A window's close button closes it, asking first about unsaved changes; after Don't Save the + /// other window comes forward. When the root viewport's window closes, the other window moves + /// into the root viewport. + #[test] + fn closing_a_window_asks_first_and_the_root_adopts_another() { + let ctx = backend(); + let mut a = app("Kept"); + a.run("file.save", json!({"path": docx_on_disk("kept", "Kept").to_string_lossy()})).unwrap(); + let kept = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), Some("saveChanges"), "the dirty window asks"); + assert_eq!((a.window_count(), a.active_viewport()), (2, child)); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, kept, "Kept")); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT], "the closed window's viewport is gone"); + + a.run("file.new", json!({})).unwrap(); + let other = a.windows.shown(); + frames(&ctx, &mut a, 2); + COMMANDS.with(|c| c.borrow_mut().clear()); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close)); + assert!(a.closes_a_window(), "the host leaves the root's close to the app"); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::CancelClose), "the app keeps running"); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.windows.shown()), (1, other)); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); + assert!(a.has_document() && a.session.documents().len() == 1); + } + + /// A request an agent's command makes for the document of another window is that window's: + /// Print opens the Print page there and brings it forward, and the window that was active + /// keeps its own page. + #[test] + fn a_request_for_another_windows_document_is_handled_there() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let second = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert_eq!(a.session.document_id(), first, "the first window comes forward"); + assert!(a.ui.backstage && a.ui.backstage_page == "print"); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.ui.backstage, "the Print page stays in its window"); + let other = a.windows.get(second).and_then(|w| w.ui.as_ref()).map(|w| w.backstage); + assert_eq!(other, Some(false), "the other window keeps its page"); + } + + /// A composition in one window shows and commits only there: the other window draws no + /// composition and its document gets none of the text. + #[test] + fn a_composition_stays_in_its_window() { + let ctx = backend(); + let mut a = app("A"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "B"})).unwrap(); + let b = a.active_viewport(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 8); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.canvas.focused, "the page has the keyboard"); + EVENTS.with(|e| { + e.borrow_mut() + .entry(ViewportId::ROOT) + .or_default() + .push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほん".into(), active_range_chars: None })) + }); + frames(&ctx, &mut a, 2); + assert_eq!(a.canvas.ime_preedit, "にほん"); + let other = a.windows.open.iter().find_map(|w| w.ui.as_ref()); + assert!(other.is_some_and(|w| w.canvas.ime_preedit.is_empty()), "the other window composes nothing"); + assert!(IME.with(|i| i.borrow().get(&ViewportId::ROOT).cloned().flatten()).is_some(), "the composing window places the candidate window"); + + focus(b); + frames(&ctx, &mut a, 2); + assert!(a.canvas.ime_preedit.is_empty(), "the focused window has no composition"); + EVENTS.with(|e| e.borrow_mut().entry(ViewportId::ROOT).or_default().push(egui::Event::Ime(egui::ImeEvent::Commit("にほん".into())))); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); + } + + /// Quitting with two documents of unsaved changes asks about each in its window, the window + /// coming to the front; Cancel keeps both. + #[test] + fn quitting_asks_about_each_document_in_its_window() { + let ctx = backend(); + let mut a = app("One"); + let one = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Two"})).unwrap(); + let two = a.session.document_id(); + let child = a.active_viewport(); + frames(&ctx, &mut a, 2); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a)), (two, Some("saveChanges")), "the active window asks first"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.session.documents().len()), (2, 2), "Cancel keeps both"); + + assert!(!a.quit()); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.session.document_id(), prompt(&a)), (one, Some("saveChanges")), "then the other window asks"); + assert_eq!(a.window_count(), 1); + COMMANDS.with(|c| c.borrow_mut().clear()); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "its window comes to the front"); + assert!(!DRAWN.with(|d| d.borrow().contains(&child))); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } +} diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b2..2fff8966b8 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -156,6 +156,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { if app.zotero.busy.is_none() { return; } + // Each window polls as it draws: the window showing the document the command started in + // answers it. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } let mut changed = false; // One alert at a time: Zotero sends nothing else while it waits for the answer. while app.zotero.alert.is_none() { @@ -217,6 +222,10 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// Zotero's question, as a dialog. pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { let Some(a) = app.zotero.alert.as_ref() else { return }; + // In the window showing the document Zotero is working on. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } // Buttons and the value Zotero expects for each. // macOS puts the default button last (rightmost), with Cancel just before it. let buttons: &[(&str, i64)] = match (a.buttons, cfg!(target_os = "macos")) { @@ -336,6 +345,9 @@ mod tests { json!(["Document_complete", [1]]), ]); let mut app = WordApp::new(Session::new(Document::new()), Services::default()); + // One window showing each document in turn (the web app); with a window per document, + // the first document's window carries on with the citation. + app.windows.single(); app.zotero.options.addr = addr; let ctx = egui::Context::default(); app.ctx = Some(ctx.clone()); From 3a6f3fbb17bc68ddeb959b2b307c2a126d0928de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:54:37 +0000 Subject: [PATCH 16/87] W3b: docs: one window per document (control protocol, UI parity, progress log) --- ROADMAP.md | 4 +++- docs/control-protocol.md | 42 ++++++++++++++++++++++++++-------------- docs/ui-parity.md | 3 ++- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8f7ccadecc..f685f29ab0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. @@ -575,6 +576,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 681c21cb63..101c932738 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,18 +73,24 @@ Any other method name is treated as a command id: `{"method": "format.bold"}` wo ### Open documents Several documents can be open at once. Each has an id, a whole number that stays the same while -the document is open and is never reused by the running app. One document is the active one: the -window shows it, and commands work on it unless told otherwise. +the document is open and is never reused by the running app. On the desktop each open document +has a native window of its own; the web app shows them one at a time in its one window. One +document is the active one: on the desktop, the document of the window focused last (the active +window); in the browser, the one the window shows. Commands work on it unless told otherwise. -- `file.new` opens another document and makes it active. `file.open` does the same with a file; - it replaces a blank document nobody has used yet, and a file that is already open becomes - active again (`"alreadyOpen": true`). Its result carries the document's `id`. +- `file.new` opens another document in a new window, focused, and makes it active. `file.open` + does the same with a file; it replaces a blank document nobody has used yet in that document's + window, and a file that is already open becomes active again and its window comes to the front + (`"alreadyOpen": true`). Its result carries the document's `id`. - `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", - "dirty", "active"}]}`); with `{"id": n}` it makes that document active. -- `file.closeDocument {"id"?, "force"?}` closes a document without touching the window. + "dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to + the front. +- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it + is the last window. - `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work - on, next to their other params. The command runs on that document and the window keeps showing - the active one: + on, next to their other params. The command runs on that document and the active window stays + active. A dialog or page the command opens (Save As, Print) opens in that document's window, + which comes to the front: ```json {"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} @@ -92,7 +98,14 @@ window shows it, and commands work on it unless told otherwise. `doc` left out, or `null`, is the active document. An id that names no open document, or a value that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work -on the document on screen only: with the id of another document they are an error. +on the active window only: with the id of another document they are an error. + +### The active window + +Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`, +`ui.text`, `ui.wheel` and the other input methods (coordinates are that window's), +`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To +work in another window, make its document active first with `view.switchWindows {"id": n}`. The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). @@ -117,10 +130,11 @@ document with unsaved changes it replies with an error (the app keeps running an stay); save first (`file.save`), or pass `{"force": true}` to discard the changes and close. The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved -changes closes at once (#1268). Closing closes the document, not the app: the window stays and -shows no page, and `file.new` or `file.open` goes on from there (#1712). With `doc` naming a -document in the background, `file.close` closes that document under the same rule (unsaved -changes need `{"force": true}`), and the window keeps showing the active one. +changes closes at once (#1268). Closing closes the document and its window, and the window +focused before it becomes the active one. The last window stays open with no page, and +`file.new` or `file.open` goes on from there (#1712); closing it doesn't quit the app. With +`doc` naming a document in the background, `file.close` closes that document and its window under +the same rule (unsaved changes need `{"force": true}`), and the active window stays active. ### Native printing diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0116cfa331..bcee769746 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | From 6c595dfd160218b1757f3b25ff62a3da59ce666a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:56:50 +0000 Subject: [PATCH 17/87] W3b: a window moved into the root viewport keeps its place on the page; the active viewport is the root's when windows are embedded --- crates/ui-egui/src/canvas.rs | 5 +++-- crates/ui-egui/src/windows.rs | 39 +++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9caeec2150..c44995770e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -138,8 +138,9 @@ pub struct CanvasState { /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, - /// Explicit page turn to apply on the next frame (reading navigation buttons/keys). - scroll_request: Option, + /// Explicit scroll position to apply on the next frame (reading navigation buttons/keys, a + /// window moving into another viewport). + pub(crate) scroll_request: Option, /// The viewport (the content's visible rect on screen) at the end of last frame; the /// drag-select edge auto-scroll decides this frame's scroll from it (#688). pub(crate) viewport: Option, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 2ff535c23c..6e60370164 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -260,9 +260,10 @@ impl WordApp { } /// The egui viewport of the active window, the one focused last: control input, - /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. The root viewport + /// when the windows don't have viewports of their own (the web app, embedded viewports). pub fn active_viewport(&self) -> ViewportId { - if !self.windows.native { + if !self.windows.native || !self.windows.driven { return ViewportId::ROOT; } let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); @@ -275,9 +276,9 @@ impl WordApp { self.windows.builder = builder; } - /// The root viewport was asked to close while other windows are open: only its window closes - /// (through [`WindowEvent::CloseRequested`] in [`WordApp::ui`]), so the host keeps the app - /// running. + /// Whether a close request for the root viewport closes only the window drawn there: true + /// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]), + /// and the host keeps the app running. pub fn closes_a_window(&self) -> bool { self.window_count() > 1 && !self.quit_requested } @@ -342,7 +343,22 @@ impl WordApp { /// active one. pub(crate) fn focus_window(&mut self, key: u64) { self.windows.focus = Some(key); + self.settle_windows(); + } + + /// [`Windows::settle`]; a window that moved into the root viewport keeps its place on the + /// page (the page's scroll state is the viewport's). + fn settle_windows(&mut self) { + let root = |w: &Windows| w.viewports.as_ref().and_then(Viewports::root); + let before = root(&self.windows); self.windows.settle(self.ctx.as_ref()); + let Some(adopted) = root(&self.windows).filter(|r| before.is_some_and(|b| b != *r)) else { return }; + let Some(key) = self.windows.list.get(adopted).copied() else { return }; + if key == self.windows.shown { + self.canvas.scroll_request = Some(self.canvas.scroll_offset); + } else if let Some(w) = self.windows.get_mut(key).and_then(|w| w.ui.as_mut()) { + w.canvas.scroll_request = Some(w.canvas.scroll_offset); + } } /// Keep one window per open document: windows whose document closed (an agent closed it) @@ -367,7 +383,7 @@ impl WordApp { changed = true; } if changed { - self.windows.settle(self.ctx.as_ref()); + self.settle_windows(); } } @@ -479,7 +495,7 @@ impl WordApp { pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); self.prune_windows(); - self.windows.settle(Some(&ctx)); + self.settle_windows(); self.windows.menu = self.window_menu(&self.windows.list); // A backend that can't open native windows (egui embeds them in the root: tests, // `ui_shot`) shows the active window alone. Embedded, every window would draw the same @@ -488,7 +504,7 @@ impl WordApp { if !self.windows.driven { let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); self.draw_window(ui, active); - self.after_windows(&ctx); + self.after_windows(); return; } let Some(mut viewports) = self.windows.viewports.take() else { return }; @@ -510,14 +526,14 @@ impl WordApp { self.windows.list = list; self.windows.viewports = Some(viewports); self.window_events(&ctx, &events); - self.after_windows(&ctx); + self.after_windows(); } /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the /// active window's state comes back. - fn after_windows(&mut self, ctx: &egui::Context) { + fn after_windows(&mut self) { self.prune_windows(); - self.windows.settle(Some(ctx)); + self.settle_windows(); if let Some(&active) = self.windows.recent_keys().first() { self.enter_window(active); } @@ -885,6 +901,7 @@ mod tests { assert!(!a.quit_requested); assert_eq!((a.window_count(), a.windows.shown()), (1, other)); assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + assert_eq!(a.canvas.scroll_request, Some(a.canvas.scroll_offset), "its page stays where it was"); frames(&ctx, &mut a, 2); assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); assert!(a.has_document() && a.session.documents().len() == 1); From 338b5d6360c0d6f6759ad2279ed3aa64c6f63aca Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:57:38 +0000 Subject: [PATCH 18/87] W3b: test: control typing reaches a window in its own viewport --- crates/ui-egui/src/windows.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 6e60370164..3c07a8b06a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -967,6 +967,28 @@ mod tests { assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); } + /// Control-channel typing goes to the active window when it has a viewport of its own (the + /// root's raw-input hook doesn't see that window's input), once. + #[test] + fn control_typing_reaches_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused, "the new window's page has the keyboard"); + let (req, _reply) = crate::ControlRequest::new("ui.text", json!({"text": "Typed"})); + assert!(matches!(crate::control::handle(&mut a, &ctx, &req), crate::control::Outcome::Done(_))); + frames(&ctx, &mut a, 4); + assert_eq!(text(&a), "Typed"); + assert!(a.synthetic.is_empty()); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First", "the other window got none of it"); + } + /// Quitting with two documents of unsaved changes asks about each in its window, the window /// coming to the front; Cancel keeps both. #[test] From a6d5820e16ed2842b45f01d7017da79f9999fda9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:14:18 +0000 Subject: [PATCH 19/87] W3b review: quit asks from the empty window; background requests keep the active document - Quitting from the Start screen's empty window asks about other windows' documents. - A request for another window's document is handled when that window draws; the active window and document stay. - Keys after a window closes or goes to the back in the same frame are dropped. - A window whose document closed isn't entered. - A window's title is kept only when it is still the drawn one after drawing. - Control input queued for a closed viewport is dropped instead of stalling. - Comment reply state is keyed by document and comment. --- crates/ui-egui/src/documents.rs | 10 +-- crates/ui-egui/src/keys.rs | 14 +++ crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/panes.rs | 8 +- crates/ui-egui/src/windows.rs | 150 ++++++++++++++++++++++++-------- 5 files changed, 141 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 5f8645ccf9..035261d596 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -111,20 +111,18 @@ impl WordApp { /// The open documents with work that would be lost on quitting: the shown one first, then /// the others in the order they were opened. Unsaved changes, a save the browser hasn't - /// confirmed, or comment text not yet written to the document. + /// confirmed, or comment text not yet written to the document. The Start screen's + /// placeholder is never one, though other windows' documents are. fn unsaved_documents(&self) -> Vec { - if !self.has_document() { - return Vec::new(); - } let active = self.session.document_id(); let mut out = Vec::new(); - if self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self) { + if self.has_document() && (self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self)) { out.push(active); } for d in self.session.documents() { let unconfirmed = || self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == d.id); let drafts = || self.parked.get(&d.id).is_some_and(|p| !p.comment_drafts.is_empty()); - if d.id != active && (d.dirty || unconfirmed() || drafts()) { + if d.id != active && Some(d.id) != self.no_document && (d.dirty || unconfirmed() || drafts()) { out.push(d.id); } } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index abf9513909..c7d1864689 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -238,10 +238,14 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); if app.session.view.read_mode { // Read Mode is for navigation and selection, not typing into the document. The ribbon's // Edit Document button or Escape returns to Print Layout before editing. for e in &events { + if app.windows.shown() != window { + break; + } match e { // Not while a dialog or menu that takes Escape is open. egui::Event::Key { key: Key::Escape, pressed: true, .. } if app.escape_free => { @@ -276,6 +280,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.canvas.paste_mods = Some(held); } for e in events { + // A key closed the window (Mod+W) or brought another forward: the frame's other keys + // were typed in this one and are dropped. + if app.windows.shown() != window { + break; + } // Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod // can go down and up within one frame, leaving the frame's modifiers without it. match &e { @@ -712,7 +721,12 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); for e in events { + // The rest of the keys were pressed in a window that closed or went to the back. + if app.windows.shown() != window { + break; + } if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { if leave_full_screen(app, key, modifiers, false) { continue; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f1ccf9b74..94d58b4b7f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1438,12 +1438,14 @@ impl WordApp { if self.quit_requested { return true; } - // No document open (#1129): nothing to save. - if !self.has_document() { + // Nothing to save, also with no document open in this window (#1129) and none elsewhere. + if !self.has_unsaved_documents() { return true; } // A comment draft the document refuses is an unsaved change: the prompt can drop it. - let _ = panes::flush_comment_drafts(self); + if self.has_document() { + let _ = panes::flush_comment_drafts(self); + } if !self.has_unsaved_documents() { return true; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 35eabcf428..a6f511dd6f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -442,6 +442,8 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { ui.separator(); let list = app.session.run("review.comments", &json!({})).unwrap_or_default(); let t = Tokens::get(ui.ctx()); + // Comment ids are the document's own; another window's document has the same ones. + let document = app.session.document_id(); egui::ScrollArea::vertical().show(ui, |ui| { for c in list.as_array().cloned().unwrap_or_default() { let id = c.get("id").and_then(Value::as_u64).unwrap_or(0); @@ -460,7 +462,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); // Editable comment text (one undo step per edit, like the balloons). comment_editor(app, ui, id as u32, None, false); - let open_key = egui::Id::new(("comment_reply_open", id)); + let open_key = egui::Id::new(("comment_reply_open", document, id)); let mut replying = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); ui.horizontal(|ui| { if ui.small_button(tl!("Go to")).clicked() @@ -471,7 +473,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { if ui.small_button(tl!("Reply")).clicked() { replying = true; ui.data_mut(|d| d.insert_temp(open_key, true)); - ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", id)), true)); + ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", document, id)), true)); } let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false); if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() { @@ -482,7 +484,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { } }); if replying { - let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", id)))).unwrap_or(false); + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", document, id)))).unwrap_or(false); if reply_editor(app, ui, id as u32, None, focus) { ui.data_mut(|d| d.remove::(open_key)); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3c07a8b06a..303af77c74 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -295,14 +295,17 @@ impl WordApp { } /// Put the drawn window's state away and draw window `key`, with its document active. False - /// when there is no such window. + /// when there is no such window, or its document has closed ([`WordApp::prune_windows`] + /// closes it then); the drawn window stays. pub(crate) fn enter_window(&mut self, key: u64) -> bool { if key == self.windows.shown { return true; } - let Some(mut state) = self.windows.get_mut(key).and_then(|w| w.ui.take()) else { + let session = &self.session; + let Some(w) = self.windows.get_mut(key).filter(|w| w.ui.as_ref().is_some_and(|ui| session.is_open(ui.shown_document))) else { return false; }; + let Some(mut state) = w.ui.take() else { return false }; let from = self.shown_document; self.swap_window(&mut state); let left = std::mem::replace(&mut self.windows.shown, key); @@ -390,24 +393,22 @@ impl WordApp { /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't /// the drawn window's. Another open document shows in its window, which comes to the front, /// or in a new window; when the drawn window's document has closed, its window closes and the - /// one showing `id` comes forward. False when the window itself is to show `id` instead: its - /// document was replaced, or it is the Start screen's empty window. + /// one showing `id` comes forward. The Start screen's empty window stays beside the window + /// that shows `id`. False when the window itself is to show `id` instead: its document was + /// replaced, or it is the Start screen's empty window and no other window shows `id`. pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { let left = self.shown_document; let stand_in = self.no_document == Some(left); - if self.session.is_open(left) && !stand_in { - match self.windows.of_doc(id).filter(|&k| k != self.windows.shown) { - Some(key) => { - self.enter_window(key); - self.focus_window(key); - } - None => self.open_window(id), + let other = self.windows.of_doc(id).filter(|&k| k != self.windows.shown); + if self.session.is_open(left) { + match other { + Some(key) if self.enter_window(key) => self.focus_window(key), + _ if stand_in => return false, + _ => self.open_window(id), } return true; } - if !self.session.is_open(left) - && let Some(key) = self.windows.of_doc(id).filter(|&k| k != self.windows.shown) - { + if let Some(key) = other { self.close_shown_window(key); return true; } @@ -440,28 +441,17 @@ impl WordApp { } /// A request made for open document `document`, which another window shows: that window - /// handles it when it draws next, and comes to the front when the request opens something - /// (a dialog, the Print page). False when no other window shows the document (or windows - /// aren't native). + /// handles it when it draws next (a dialog opens there). The active window and document stay + /// as they are, so the control channel and agents go on working where they were. False when + /// no other window shows the document (or windows aren't native). pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { if !self.windows.native { return false; } let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; - let opens = req.get("open").is_some(); - if opens && !self.windows.showing { - // Between frames: the window comes forward and opens it now. - self.enter_window(key); - self.focus_window(key); - self.handle_request(req); - return true; - } if let Some(w) = self.windows.get_mut(key) { w.requests.push(req.clone()); } - if opens { - self.focus_window(key); - } if let Some(ctx) = &self.ctx { ctx.request_repaint(); } @@ -569,6 +559,11 @@ impl WordApp { return; } self.window_ui(ui); + // Drawing can close the window or bring another forward: the fields then hold another + // window's state. + if self.windows.shown != key { + return; + } let title = self.sent_title.clone(); if let Some(w) = self.windows.get_mut(key) { w.title = title; @@ -618,7 +613,17 @@ impl WordApp { ctx.add_plugin(ViewportInput::default()); self.windows.input_plugin = true; } + self.settle_windows(); let target = self.active_viewport(); + // Input queued for a window that has closed since (or moved into the root viewport) + // would wait forever. + let live: Vec = self.windows.open.iter().map(|w| self.windows.viewport(w.key)).filter(|&v| v != ViewportId::ROOT).collect(); + ctx.with_plugin(|p: &mut ViewportInput| { + if p.target.is_some_and(|t| !live.contains(&t)) { + p.target = None; + p.events.clear(); + } + }); let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); if waiting { return; @@ -908,29 +913,100 @@ mod tests { } /// A request an agent's command makes for the document of another window is that window's: - /// Print opens the Print page there and brings it forward, and the window that was active - /// keeps its own page. + /// Print opens the Print page there when it draws, and the active window and document stay + /// as they are, so the agent's next command works where it did. #[test] fn a_request_for_another_windows_document_is_handled_there() { let ctx = backend(); let mut a = app("First"); let first = a.session.document_id(); + let first_window = a.windows.shown(); frames(&ctx, &mut a, 2); a.run("file.new", json!({})).unwrap(); - let second = a.windows.shown(); + let second = a.session.document_id(); frames(&ctx, &mut a, 2); + let child = a.active_viewport(); a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); for req in std::mem::take(&mut a.session.ui_requests) { assert_eq!(req["document"], first); a.handle_request(&req); } - assert_eq!(a.session.document_id(), first, "the first window comes forward"); - assert!(a.ui.backstage && a.ui.backstage_page == "print"); + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child), "the active document stays"); + assert!(!a.ui.backstage); frames(&ctx, &mut a, 2); - assert_eq!(a.active_viewport(), ViewportId::ROOT); - assert!(a.ui.backstage, "the Print page stays in its window"); - let other = a.windows.get(second).and_then(|w| w.ui.as_ref()).map(|w| w.backstage); - assert_eq!(other, Some(false), "the other window keeps its page"); + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child)); + assert!(!a.ui.backstage, "the active window keeps its page"); + let other = a.windows.get(first_window).and_then(|w| w.ui.as_ref()).map(|w| (w.backstage, w.backstage_page.as_str())); + assert_eq!(other, Some((true, "print")), "the Print page opened in the document's window"); + } + + /// Quitting from the Start screen's empty window asks about the documents other windows + /// show (one an agent made and typed in). + #[test] + fn quitting_from_the_empty_window_asks_about_the_other_windows() { + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + a.run("file.close", json!({})).unwrap(); + assert!(!a.has_document()); + let empty = a.windows.shown(); + let placeholder = a.session.document_id(); + let other = a.session.with_document(placeholder, |s| s.add_document(wordcraft_doc::Document::new())).unwrap(); + a.session.with_document(other, |s| s.run("text.insert", &json!({"text": "Agent"}))).unwrap().unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.windows.shown()), (2, empty), "the agent's document has a window behind"); + assert!(!a.has_document()); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a), text(&a).as_str()), (other, Some("saveChanges"), "Agent")); + assert_ne!(a.windows.shown(), empty, "the document's own window asks"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } + + /// Mod+W in a window with no changes closes it; the keys typed after it in the same frame + /// don't reach the window that comes forward. + #[test] + fn keys_after_closing_a_window_go_nowhere() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused); + let mac = cfg!(target_os = "macos"); + let modifiers = egui::Modifiers { ctrl: !mac, mac_cmd: mac, command: true, ..Default::default() }; + EVENTS.with(|e| { + e.borrow_mut().entry(child).or_default().extend([ + egui::Event::Key { key: egui::Key::W, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Text("x".into()), + ]) + }); + frame(&ctx, &mut a); + assert_eq!(a.window_count(), 1, "Mod+W closed the window"); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First"); + } + + /// A window whose document closed without the windows following (an agent closed it) isn't + /// drawn or made active; the next frame closes it. + #[test] + fn a_window_whose_document_closed_is_not_entered() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + let first_window = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let (second, second_window) = (a.session.document_id(), a.windows.shown()); + frames(&ctx, &mut a, 2); + a.session.close_document(first).unwrap(); + assert!(!a.enter_window(first_window)); + assert_eq!((a.windows.shown(), a.session.document_id()), (second_window, second)); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.session.document_id()), (1, second)); } /// A composition in one window shows and commits only there: the other window draws no From f2c21cd62ce6c263a52f93d68d72d25c831b7864 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:30:33 +0000 Subject: [PATCH 20/87] W6: View: New Window opens another window on the document Each window on a document keeps its own view (selection, zoom, view mode, panes) as an engine WindowView, moved in and out of the session as the windows draw and clamped to edits made through another window. The document, undo history and save state are shared. Two windows on one document are titled Name:1 and Name:2; closing one of them asks nothing, the last asks, and quitting asks once per document. Switch Windows lists windows. The layout memo keeps two layouts and keys on every view option, so windows with different views don't lay out again each frame. --- ROADMAP.md | 3 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/session.rs | 63 ++++++--- crates/engine/src/session/documents.rs | 6 +- crates/engine/src/session/views.rs | 154 ++++++++++++++++++++++ crates/engine/src/tests_documents.rs | 56 ++++++++ crates/ui-egui/src/documents.rs | 13 +- crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/windows.rs | 175 +++++++++++++++++++++++-- docs/ui-parity.md | 3 +- 10 files changed, 445 insertions(+), 42 deletions(-) create mode 100644 crates/engine/src/session/views.rs diff --git a/ROADMAP.md b/ROADMAP.md index f685f29ab0..297200e8d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: View › New Window; previously trivial: progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1aec1ce4d..babf9bbc2f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,7 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, WindowView, blank_document, set_system_locale, }; pub use wordcraft_doc as doc; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e2c19aa32c..0aef2dac55 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -26,8 +26,10 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM use crate::{CmdError, Registry}; mod documents; +mod views; pub use documents::{DocInfo, DocState}; use documents::{document_id_mark, next_document_id, untitled_name}; +pub use views::WindowView; /// A selection: anchor (where it started) and focus (where the caret is). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -363,14 +365,15 @@ impl EditSnapshot { } } -/// The layout last made and what it was made for: (revision, width, read height, view, read line -/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body -/// deletions in balloons), numeral display, the word being typed). /// What `run` keeps to take a failed mutating command back: the document, the selection, the /// undo and redo stacks, the open typing group, the undo steps dropped so far and the column /// block. type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); +/// A layout made and what it was made for: (revision, width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field +/// codes shown)). type LayoutEntry = ( u64, f32, @@ -383,8 +386,13 @@ type LayoutEntry = ( (bool, wordcraft_layout::HideDeleted, bool), NumeralMode, Option, + (bool, bool), ); +/// The layouts last made, newest first. Two, so two windows on one document with different +/// views ([`WindowView`]) each find theirs as they draw in turn. +type LayoutSlots = [Option; 2]; + /// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) /// and the open documents. The active document's state is in the session's own fields, which /// every command works on; the other open documents wait in [`DocState`] values and are swapped @@ -457,7 +465,7 @@ pub struct Session { /// `rev` when the document was made, for [`Session::is_untouched_blank`]. made_rev: u64, cache: LayoutCache, - layout: Option, + layout: LayoutSlots, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). @@ -953,19 +961,30 @@ impl Session { let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); let typing = self.typing_word(); - if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout - && *r == self.rev - && (*w == width || mode == ViewMode::Print) - && *h == height - && *m == mode - && *sp == spacing - && *pf == self.view.proofing - && *n == self.view.numeral - && *hd == deleted - && *o == outline - && *t == typing + let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); + let (rev, proofing, numeral) = (self.rev, self.view.proofing, self.view.numeral); + let fits = |e: &Option| { + e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { + *r == rev + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing + && *pf == proofing + && *n == numeral + && *hd == deleted + && *o == outline + && *t == typing + && *sh == shown + }) + }; + if !fits(&self.layout[0]) && fits(&self.layout[1]) { + self.layout.swap(0, 1); + } + if fits(&self.layout[0]) + && let Some(e) = &self.layout[0] { - return l.clone(); + return e.5.clone(); } let (fn_name, fn_path) = self.resolved_filenames(); let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); @@ -974,14 +993,14 @@ impl Session { web_width: width, read_height: height, read_line_spacing: spacing.map(f32::from_bits), - show_hidden: self.view.marks || self.prefs.marks.hidden, + show_hidden: shown.0, hide_deleted, hide_marked: deleted.1, deletions_in_balloons: deleted.2, proofing: self.view.proofing, numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), comment_balloons: false, - field_codes: self.view.field_codes, + field_codes: shown.1, outline: outline.clone(), proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, typing_word: typing, @@ -989,7 +1008,11 @@ impl Session { filepath: filepath.clone(), }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing)); + let [newest, _] = std::mem::take(&mut self.layout); + self.layout = [ + Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), + newest, + ]; l } @@ -1050,7 +1073,7 @@ impl Session { /// Invalidate the cached layout (fonts changed etc.). pub fn relayout(&mut self) { - self.layout = None; + self.layout = Default::default(); self.cache.clear(); } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 8d5c2fbbcd..e76859d462 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -13,7 +13,7 @@ use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, Pos, StoryRef}; use wordcraft_layout::LayoutCache; -use super::{ColumnBlock, FindState, LayoutEntry, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; use crate::CmdError; /// Document ids are unique in the process, so an id an agent or a window holds never names @@ -63,7 +63,7 @@ pub struct DocState { pub(super) untitled: u32, pub(super) made_rev: u64, pub(super) cache: LayoutCache, - pub(super) layout: Option, + pub(super) layout: LayoutSlots, pub(super) typing: Option, pub(super) originals: std::collections::HashMap, pub(super) picture_looks: std::collections::HashMap, @@ -113,7 +113,7 @@ impl DocState { untitled, made_rev: 1, cache: LayoutCache::new(), - layout: None, + layout: Default::default(), typing: None, originals: Default::default(), picture_looks: Default::default(), diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs new file mode 100644 index 0000000000..98287badca --- /dev/null +++ b/crates/engine/src/session/views.rs @@ -0,0 +1,154 @@ +//! Several windows on one document. The document, its undo history, dirty flag, path and save +//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows +//! the document. The session holds one view per document (the drawn window's, for the active +//! document); a front end keeps the other windows' views and moves them in and out with +//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened}; + +/// One window's view of a document: its selection and caret state, and how it shows the page +/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through +/// another window; [`Session::put_view`] makes them valid again. +pub struct WindowView { + sel: Selection, + view: ViewState, + pending: Option, + goal_x: Option, + page_hint: usize, + hf_return: Option, + typing_open: bool, + join_next: bool, + typing: Option, + math: Option, + also_selected: Vec, + column: Option, + column_mode: bool, + widened: Option, + /// The document and its revision when the view was taken out. + document_id: u64, + rev: u64, +} + +impl WindowView { + /// The document this view is of. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// The view's selection. + pub fn selection(&self) -> &Selection { + &self.sel + } + + /// How the view shows the document. + pub fn view(&self) -> &ViewState { + &self.view + } +} + +impl Session { + /// Take the active document's view out of the session, for a window that stops being drawn. + /// The session is left with a caret at the start of the document and the default view + /// until [`Session::put_view`] puts a view back. + pub fn take_view(&mut self) -> WindowView { + use std::mem::{replace, take}; + let start = self.doc.start_of(StoryRef::Body); + WindowView { + sel: replace(&mut self.sel, Selection::caret(start)), + view: take(&mut self.view), + pending: self.pending.take(), + goal_x: self.goal_x.take(), + page_hint: take(&mut self.page_hint), + hf_return: self.hf_return.take(), + typing_open: take(&mut self.typing_open), + join_next: take(&mut self.join_next), + typing: self.typing.take(), + math: self.math.take(), + also_selected: take(&mut self.also_selected), + column: self.column.take(), + column_mode: take(&mut self.column_mode), + widened: self.widened.take(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// A copy of the active document's view, for a new window on it (View › New Window). + pub fn copy_view(&self) -> WindowView { + WindowView { + sel: self.sel.clone(), + view: self.view.clone(), + pending: self.pending.clone(), + goal_x: self.goal_x, + page_hint: self.page_hint, + hf_return: self.hf_return.clone(), + // The new window hasn't typed: its first key starts an undo step of its own. + typing_open: false, + join_next: false, + typing: None, + math: self.math.clone(), + also_selected: self.also_selected.clone(), + column: self.column.clone(), + column_mode: self.column_mode, + widened: self.widened.clone(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// Make `v` the active document's view. When the document changed since `v` was taken + /// (another window edited it, or `v` is of another document), its positions are clamped into + /// the document, what only made sense for the text it was taken from (a column block, a + /// widened table selection, more selected objects, the word being typed) is dropped, and the + /// window's typing no longer joins its last undo step. + pub fn put_view(&mut self, v: WindowView) { + let WindowView { + sel, + view, + pending, + goal_x, + page_hint, + hf_return, + typing_open, + join_next, + typing, + math, + also_selected, + column, + column_mode, + widened, + document_id, + rev, + } = v; + let current = document_id == self.document_id && rev == self.rev; + self.sel = sel; + self.view = view; + self.pending = pending; + self.goal_x = goal_x; + self.page_hint = page_hint; + self.column_mode = column_mode; + self.math = math; + if current { + self.hf_return = hf_return; + self.typing_open = typing_open; + self.join_next = join_next; + self.typing = typing; + self.also_selected = also_selected; + self.column = column; + self.widened = widened; + return; + } + self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); + self.typing_open = false; + self.join_next = false; + self.typing = None; + self.also_selected = Vec::new(); + self.column = None; + self.widened = None; + self.goal_x = None; + self.clamp_selection(); + } +} diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index a1dd44a143..6c83716fe7 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -273,3 +273,59 @@ fn a_stand_in_document_gives_back_its_untitled_name() { run(&mut s, "file.new", json!({})); assert_eq!(s.documents()[2].title, "Document3"); } + +/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another +/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either +/// window undoes the document's last edit, and typing in one window never joins the other's +/// undo step. +#[test] +fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + let mut b = s.copy_view(); + let a_caret = s.sel.focus.clone(); + + // Window B: zoom in; its caret is after "world". + let mut a = s.take_view(); + s.put_view(b); + run(&mut s, "view.zoom", json!({"value": 150})); + b = s.take_view(); + s.put_view(a); + assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); + + // Window A deletes " world", where B's caret is. + run(&mut s, "select.text", json!({"text": " world"})); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); + run(&mut s, "text.insert", json!({"text": "!"})); + assert_eq!(text(&s).trim(), "Hello!"); + + // Undo in A undoes B's typing; Undo in B then undoes A's delete. + b = s.take_view(); + s.put_view(a); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello world"); + + // Typing in A, then B, then A again: three undo steps. + b = s.take_view(); + s.put_view(a); + s.sel = crate::Selection::caret(a_caret); + run(&mut s, "text.insert", json!({"text": "x"})); + a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + b = s.take_view(); + s.put_view(a); + run(&mut s, "text.insert", json!({"text": "z"})); + run(&mut s, "edit.undo", json!({})); + assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); + drop(b); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 035261d596..601dd3c8f2 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -179,16 +179,21 @@ pub(crate) fn untitled_title(english: &str) -> Option { /// View › Window › Switch Windows: the open windows (in one window, the open documents) in the /// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one -/// brings its window to the front (in one window, shows the document). +/// brings that window to the front (in one window, shows the document). pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { - let entries: Vec<(u64, String, bool)> = if app.windows.native() { + let native = app.windows.native(); + let entries: Vec<(u64, String, bool)> = if native { app.switch_windows_entries() } else { app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() }; - for (doc, label, current) in entries { + for (key, label, current) in entries { if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { - let _ = app.run("view.switchWindows", json!({"id": doc})); + if native { + app.show_window(key); + } else { + let _ = app.run("view.switchWindows", json!({"id": key})); + } ui.close(); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 94d58b4b7f..aec650718b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -986,7 +986,10 @@ impl WordApp { self.dialog = dialogs::Dialog::open(name, self); return Ok(json!({"pending": name})); } - if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) && discards_document(id, ¶ms) { + if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) + && discards_document(id, ¶ms) + && !self.closes_only_its_window(id, ¶ms) + { let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, @@ -1454,7 +1457,8 @@ impl WordApp { self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the shown document. Its window closes too while other + /// File › Close (Mod+W, Mod+F4) closes the shown document, or only its window while other + /// windows show the document (View › New Window), asking nothing then. Its window closes too while other /// windows are open, and the one focused before it comes forward (in the web app's one window, /// the next document shows). The last window stays, as Word's does (#1712), with no page, as /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, @@ -1464,6 +1468,9 @@ impl WordApp { /// changes are never discarded unasked: a dirty document closes only with `{"force": true}` /// (Don't Save in the prompt passes it). fn close_document(&mut self, params: &Value) -> Result { + if self.closes_only_its_window("file.close", params) && self.close_window_only() { + return Ok(json!({"closed": true, "window": true})); + } if let Some(e) = control::close_refused(self, "file.close", params) { return Err(e.into()); } @@ -1592,6 +1599,7 @@ impl WordApp { } } "pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)), + "newWindow" => self.new_window(), other => self.dialog = dialogs::Dialog::open(other, self), } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 303af77c74..b83a5e207e 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -19,12 +19,20 @@ //! document nobody used in its own window, and the last window stays open with no document (the //! empty window the Start screen also leaves). The web app has one window and switches documents //! in it ([`crate::documents`]). +//! +//! View › New Window opens another window on a document. Each window on it has its own view of +//! it (selection, zoom, view mode, panes: [`WindowView`]); the session holds the drawn window's, +//! and the others keep theirs in their [`Window`]. What the app keeps per document +//! (AutoSave's file, comment drafts) stays in one place, shared by its windows. Closing one of a +//! document's windows leaves the document open and asks nothing; closing its last window closes +//! it. use std::collections::HashMap; use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; use egui::{ViewportCommand, ViewportId}; use serde_json::{Value, json}; +use wordcraft_engine::WindowView; use crate::{WordApp, documents, window_ui::WindowUi}; @@ -48,6 +56,10 @@ pub(crate) struct Window { requests: Vec, /// The title it last drew, for its viewport. title: String, + /// Its view of the document (caret, zoom, view mode…) while another window on the same + /// document is drawn. `None` while the session holds it: for the drawn window, and for the + /// window on each other document whose view is parked with that document. + view: Option, } /// The app's windows. See the module documentation. @@ -91,7 +103,7 @@ impl Windows { native: cfg!(not(target_arch = "wasm32")), list, viewports: Some(viewports), - open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new() }], + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new(), view: None }], shown: 1, next_key: 2, showing: false, @@ -306,6 +318,7 @@ impl WordApp { return false; }; let Some(mut state) = w.ui.take() else { return false }; + let view = w.view.take(); let from = self.shown_document; self.swap_window(&mut state); let left = std::mem::replace(&mut self.windows.shown, key); @@ -315,23 +328,76 @@ impl WordApp { } let to = self.shown_document; self.carry_document_state(from, to); + if let Some(view) = view { + self.swap_view_in(key, to, view); + } true } + /// Window `key`, on document `doc` (now the session's active one), brings its own view of the + /// document: the view the session holds goes to the window on `doc` it belongs to (the one + /// without a view of its own), and `view` takes its place, made valid for edits made since. + fn swap_view_in(&mut self, key: u64, doc: u64, view: WindowView) { + let held = self.session.take_view(); + if let Some(owner) = self.windows.open.iter_mut().find(|w| w.key != key && w.doc == doc && w.view.is_none()) { + owner.view = Some(held); + } + self.session.put_view(view); + } + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn - /// leaves the File page: whatever it was opening is open. - fn open_window(&mut self, doc: u64) { + /// leaves the File page: whatever it was opening is open. `view` is the new window's view of + /// a document another window shows (View › New Window); `None` for a document no window + /// shows yet. + fn open_window(&mut self, doc: u64, view: Option) { let key = self.windows.next_key; self.windows.next_key = self.windows.next_key.saturating_add(1); let mut state = Box::new(WindowUi::new(doc)); // Faces borrowed for the interface (non-Latin file names) stay installed. state.canvas.ime_faces = self.canvas.ime_faces.clone(); self.ui.backstage = false; - self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new(), view }); self.enter_window(key); self.focus_window(key); } + /// View › New Window: another window on the drawn window's document, with the same caret, + /// zoom and view, focused and cascaded from the others. Each window then has its own caret + /// and view; the document, its undo history and its save state are shared. In one window + /// (the web app) and with no document open, nothing happens. + pub(crate) fn new_window(&mut self) { + if !self.windows.native || !self.has_document() { + return; + } + let view = self.session.copy_view(); + self.open_window(self.shown_document, Some(view)); + } + + /// The window that comes forward when the drawn window closes and other windows show its + /// document, which then stays open: the one of all the others focused last. `None` when the + /// drawn window is its document's only one. + pub(crate) fn other_window_on_document(&self) -> Option { + let shown = self.windows.shown; + let doc = self.windows.get(shown)?.doc; + if !self.windows.native || !self.windows.open.iter().any(|w| w.key != shown && w.doc == doc) { + return None; + } + self.windows.recent_keys().into_iter().find(|&k| k != shown) + } + + /// Whether command `id` (File › Close) closes the drawn window alone, asking nothing: other + /// windows show its document. Quitting closes documents, not windows. + pub(crate) fn closes_only_its_window(&self, id: &str, params: &Value) -> bool { + id == "file.close" && params.get("quit").and_then(Value::as_bool) != Some(true) && self.other_window_on_document().is_some() + } + + /// Close the drawn window alone: other windows show its document, which stays open as it is. + pub(crate) fn close_window_only(&mut self) -> bool { + let Some(next) = self.other_window_on_document() else { return false }; + self.close_shown_window(next); + true + } + /// The drawn window's document has closed: draw window `to` and close this one. fn close_shown_window(&mut self, to: u64) { let closing = self.windows.shown; @@ -364,7 +430,7 @@ impl WordApp { } } - /// Keep one window per open document: windows whose document closed (an agent closed it) + /// Keep a window for every open document: windows whose document closed (an agent closed it) /// close, and documents with no window (an agent made one while another was active) get one /// behind the others. The active document is left to [`WordApp::sync_document`]. pub(crate) fn prune_windows(&mut self) { @@ -382,7 +448,7 @@ impl WordApp { self.windows.next_key = self.windows.next_key.saturating_add(1); let mut state = Box::new(WindowUi::new(d.id)); state.canvas.ime_faces = self.canvas.ime_faces.clone(); - self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new(), view: None }); changed = true; } if changed { @@ -404,7 +470,7 @@ impl WordApp { match other { Some(key) if self.enter_window(key) => self.focus_window(key), _ if stand_in => return false, - _ => self.open_window(id), + _ => self.open_window(id, None), } return true; } @@ -458,8 +524,10 @@ impl WordApp { true } - /// View › Window › Switch Windows with native windows: every window by its title, the active - /// one marked, unsaved ones with the title bar's dot. Choosing one brings it to the front. + /// View › Window › Switch Windows with native windows: every window by its title (two + /// windows on one document as `Name:1`, `Name:2`) with its key, the active one marked, + /// unsaved ones with the title bar's dot. Choosing one brings it to the front + /// ([`WordApp::show_window`]). pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { self.windows .menu @@ -467,11 +535,18 @@ impl WordApp { .iter() .filter_map(|e| { let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; - Some((w.doc, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + Some((w.key, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) }) .collect() } + /// Bring window `key` to the front and make it the active one, with its document. + pub(crate) fn show_window(&mut self, key: u64) { + if self.enter_window(key) { + self.focus_window(key); + } + } + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. pub(crate) fn window_title(&self) -> String { if !self.has_document() { @@ -1096,4 +1171,84 @@ mod tests { a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert!(a.quit_requested); } + + fn window_labels(a: &WordApp) -> Vec { + let mut l: Vec = a.switch_windows_entries().into_iter().map(|(_, label, _)| label).collect(); + l.sort(); + l + } + + /// View › New Window opens a second window on the document, titled `Name:1` / `Name:2`. Text + /// typed in one shows in the other; each keeps its own caret and zoom. Typing in a window + /// whose caret was in text the other window deleted lands at the end of what is left, and + /// Undo in that window undoes its own typing, then the other window's delete. + #[test] + fn new_window_shows_the_document_twice_each_with_its_own_caret_and_zoom() { + let ctx = backend(); + let mut a = app("Hello world"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id()), (2, doc)); + frames(&ctx, &mut a, 3); + let b = a.active_viewport(); + assert_ne!(b, ViewportId::ROOT); + assert_eq!(window_labels(&a), ["Document1:1 •", "Document1:2 •"]); + assert_eq!(a.window_title(), super::title("Document1:2", true)); + + a.run("view.zoom", json!({"value": 150})).unwrap(); + a.run("text.insert", json!({"text": "!"})).unwrap(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(text(&a), "Hello world!", "the edit shows in the other window"); + assert_eq!((a.session.view.zoom, a.session.sel.focus.off), (1.0, 11), "window A keeps its zoom and caret"); + + a.run("select.text", json!({"text": " world!"})).unwrap(); + a.run("text.delete", json!({})).unwrap(); + focus(b); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), b); + assert!((a.session.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + a.run("text.insert", json!({"text": "?"})).unwrap(); + assert_eq!(text(&a), "Hello?", "B's caret was clamped into what is left"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello world!", "Undo in B undoes A's delete"); + frames(&ctx, &mut a, 2); + assert_eq!(a.session.documents().len(), 1); + } + + /// Closing one of two windows on a document asks nothing, even with unsaved changes, and the + /// other window goes back to the plain name; closing the last one asks. Quitting with a + /// document in two windows asks once. + #[test] + fn closing_one_of_two_windows_on_a_document_asks_nothing() { + let ctx = backend(); + let mut a = app("Kept"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), None, "no prompt while another window shows the document"); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, doc, "Kept")); + assert!(a.session.dirty); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_title(), super::title("Document1", true)); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges"), "the last window asks"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert!(!a.quit()); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested, "one prompt for the document"); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bcee769746..f0d037c71b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | From 08da5c5433d0b494bc3ab6e7dcb5d9de34cd73c2 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:11:51 +0100 Subject: [PATCH 21/87] #3699: MCP bridge sends the control key only to 127.0.0.1, where the app listens Audit finding 9 (CWE-522). The bridge sent the reusable control key to any loopback peer (127.0.0.0/8, ::1) and tried every address `localhost` resolved to, while the app binds only 127.0.0.1. Another local user could listen on ::1 or 127.0.0.2 and receive the key. Now `localhost` connects to 127.0.0.1 only, and the key goes only to a 127.0.0.1 peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/backend.rs | 86 +++++++++++++++++++++++++++------------ docs/control-protocol.md | 4 +- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index ac32f5d32b..1cf17c61d5 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,7 +1,7 @@ //! Where MCP tool calls end up: a control-channel method call. use std::io::{BufRead, BufReader, Write}; -use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::time::Duration; @@ -26,7 +26,7 @@ pub struct Remote { addr: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, - /// The connection leads to a loopback address: requests carry the control key. + /// The connection leads to the app's 127.0.0.1: requests carry the control key. send_key: bool, /// The control key, looked up once per connection (the app picks a new one at every start). key: Option, @@ -55,25 +55,46 @@ fn find_key(addr: &str) -> Result { lookup_key(std::env::var("WORDCRAFT_CONTROL_KEY").ok(), wordcraft_control_key::settings_dir().as_deref(), port) } -/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names a loopback address: 127.0.0.0/8, -/// ::1 or `localhost`. Other host names are not resolved: they never count. -fn is_loopback_addr(addr: &str) -> bool { - let loopback = |ip: IpAddr| ip.to_canonical().is_loopback(); - if let Ok(sa) = addr.parse::() { - return loopback(sa.ip()); - } - if let Ok(ip) = addr.parse::() { - return loopback(ip); - } +/// The only address the app's control server binds (`apps/wordcraft/src/control_server.rs`). +const APP_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +/// The host part of `addr` (`HOST:PORT`, `[V6]:PORT`), without brackets. +fn host_of(addr: &str) -> &str { let host = addr.rsplit_once(':').map_or(addr, |(h, _)| h); - let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host); - host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), loopback) + host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host) } -/// The key goes only to a loopback `--connect` address, and only when the connection really -/// leads to one (`peer`, so a `localhost` that resolves elsewhere gets no key either). +/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names the app's own address: +/// 127.0.0.1 or `localhost`. Other loopback addresses (127.0.0.2, ::1) don't count: the app never +/// listens there, so whatever does is not the app. Other host names are not resolved. +fn is_app_addr(addr: &str) -> bool { + let app = |ip: IpAddr| ip.to_canonical() == APP_IP; + if let Ok(sa) = addr.parse::() { + return app(sa.ip()); + } + if let Ok(ip) = addr.parse::() { + return app(ip); + } + let host = host_of(addr); + host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), app) +} + +/// The key goes only to the app's `--connect` address, and only when the connection really +/// leads to 127.0.0.1 (`peer`): a listener on another loopback address (::1, 127.0.0.2), which +/// any local user can open, never sees it. fn may_send_key(addr: &str, peer: Option) -> bool { - is_loopback_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical().is_loopback()) + is_app_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical() == APP_IP) +} + +/// Where to connect for `addr`: `localhost` means the app's 127.0.0.1 (not whatever the resolver +/// lists first, which may be ::1); anything else as resolved. +fn targets(addr: &str) -> std::io::Result> { + if host_of(addr).eq_ignore_ascii_case("localhost") + && let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::().ok()) + { + return Ok(vec![SocketAddr::new(APP_IP, port)]); + } + Ok(addr.to_socket_addrs()?.collect()) } impl Remote { @@ -95,11 +116,12 @@ impl Remote { &self.addr } - /// Why requests go without the control key (the address is not loopback), if they do. + /// Why requests go without the control key (the address is not the app's 127.0.0.1), if they + /// do. pub fn key_warning(&self) -> Option { (!self.send_key).then(|| { format!( - "{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", + "{} is not the app's address, so the control key is not sent (it only goes to 127.0.0.1 and localhost, where the app listens) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", self.addr ) }) @@ -109,7 +131,7 @@ impl Remote { self.conn = None; self.key = None; let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); - for sa in self.addr.to_socket_addrs()? { + for sa in targets(&self.addr)? { match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { Ok(s) => { s.set_nodelay(true).ok(); @@ -256,15 +278,12 @@ mod tests { } #[test] - fn key_only_for_loopback_addresses() { + fn key_only_for_the_apps_address() { let peer = |s: &str| s.parse::().ok(); for (addr, at) in [ ("127.0.0.1:7981", "127.0.0.1:7981"), - ("127.0.0.2:7981", "127.0.0.2:7981"), - ("[::1]:7981", "[::1]:7981"), ("[::ffff:127.0.0.1]:7981", "[::ffff:127.0.0.1]:7981"), ("localhost:7981", "127.0.0.1:7981"), - ("localhost:7981", "[::1]:7981"), ("LocalHost:7981", "127.0.0.1:7981"), ] { assert!(may_send_key(addr, peer(at)), "{addr} at {at}"); @@ -274,6 +293,11 @@ mod tests { ("10.0.0.7:7981", "10.0.0.7:7981"), ("[fe80::1]:7981", "[fe80::1]:7981"), ("0.0.0.0:7981", "127.0.0.1:7981"), + // Loopback addresses the app never binds: another local user's listener (#3699). + ("127.0.0.2:7981", "127.0.0.2:7981"), + ("[::1]:7981", "[::1]:7981"), + ("localhost:7981", "[::1]:7981"), + ("127.0.0.1:7981", "127.0.0.2:7981"), // Host names other than localhost: even when they lead to this machine. ("workstation:7981", "127.0.1.1:7981"), ("example.org:7981", "93.184.215.14:7981"), @@ -287,6 +311,16 @@ mod tests { assert!(!may_send_key("127.0.0.1:7981", None)); } + #[test] + fn localhost_connects_to_the_apps_address_only() { + // Not the resolver's list, which may put ::1 (where anyone can listen) first. + let at: SocketAddr = "127.0.0.1:7981".parse().unwrap(); + assert_eq!(targets("localhost:7981").unwrap(), vec![at]); + assert_eq!(targets("LOCALHOST:7981").unwrap(), vec![at]); + assert_eq!(targets("127.0.0.1:7981").unwrap(), vec![at]); + assert_eq!(targets("[::1]:7981").unwrap(), vec!["[::1]:7981".parse::().unwrap()]); + } + /// A stand-in for the app on 127.0.0.1: every request line goes to the returned channel and /// gets `answer(request)`: a reply and whether to close the connection after it, or `None` /// to close it without a reply (the app quit). @@ -377,9 +411,9 @@ mod tests { let finder: KeyFinder = Box::new(|_| Err("the key must not be read for another host".to_string())); let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap(); r.send_key = false; - assert!(r.key_warning().is_some_and(|w| w.contains("not a loopback address"))); + assert!(r.key_warning().is_some_and(|w| w.contains("not the app's address"))); let e = r.call("document.inspect", json!({})).unwrap_err(); - assert!(e.contains("not a loopback address") && e.contains("not sent"), "{e}"); + assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}"); assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none()); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c62c3d91..a88e0be9f9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,7 +43,9 @@ cat ~/.config/wordcraft/control-key.local-7981 # Linux, outside Flatpak, port `wordcraft-cli mcp --connect` takes the key from `WORDCRAFT_CONTROL_KEY`, or else from the key file for the port it connects to, and reads it again on every new connection (the app may have -restarted). It sends the key only to loopback addresses (`127.0.0.0/8`, `::1`, `localhost`). For +restarted). It sends the key only to the app's own address, `127.0.0.1` (`localhost` connects +there too, never to `::1`): another loopback address such as `::1` or `127.0.0.2` is not where the +app listens, so whoever listens there gets no key (#3699). For any other host it sends no key, says so, and the app refuses its requests: to drive an app on another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`) and set `WORDCRAFT_CONTROL_KEY` to that app's key. From 3d51cd3db264f01ff36bf973824e97abec4d8193 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:13:28 +0100 Subject: [PATCH 22/87] #3692: MCP type_text types its paragraphs as one edit and one undo step Audit finding 18 (CWE-407). `type_text` with `paragraphs: true` ran a text.insert and a text.newParagraph per line, each a mutating command with its own whole-document undo snapshot, so a long text did quadratic work and filled the undo history. It now sends one raw text.insert with `\r` paragraph breaks (Enter's split): one edit, one undo step, all or nothing. More than MAX_TYPED_PARAGRAPHS (10 000) lines is refused before anything is typed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/tests.rs | 43 +++++++++++++++++++++++++++++++++-------- crates/mcp/src/tools.rs | 30 ++++++++++++++-------------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02b..e000371344 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,17 +120,16 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend whose paragraph break is refused while text insertion would succeed. +/// A backend that keeps every command it is sent and refuses text with a paragraph break. struct RefusesParagraph { - calls: std::sync::Arc>>, + calls: std::sync::Arc>>, } impl crate::backend::Backend for RefusesParagraph { fn call(&mut self, _method: &str, params: Value) -> Result { - let command = params["command"].as_str().unwrap_or("").to_string(); - self.calls.lock().unwrap().push(command.clone()); - match command.as_str() { - "text.newParagraph" => Err("paragraph break refused".into()), + self.calls.lock().unwrap().push(params.clone()); + match params["params"]["text"].as_str() { + Some(t) if t.contains('\r') => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -142,7 +141,8 @@ impl crate::backend::Backend for RefusesParagraph { } } -/// #1356: a refused paragraph break is reported and the next line is not typed after it. +/// #1356: a refused paragraph break is reported, and nothing of the text goes in without it: the +/// lines are one command, all or nothing. #[test] fn type_text_stops_at_a_refused_paragraph_break() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); @@ -150,5 +150,32 @@ fn type_text_stops_at_a_refused_paragraph_break() { let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); - assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(calls[0]["command"], "text.insert"); + assert_eq!(calls[0]["params"]["text"], "one\rtwo"); +} + +/// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document +/// undo snapshot) per line; too many lines are refused before anything is typed. +#[test] +fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + let lines: Vec = (0..2000).map(|i| format!("Line {i}")).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\r\n"), "paragraphs": true})); + let text = tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("Line 0") && text.contains("Line 1999"), "{}", text.len()); + let paras = |s: &mut Server| tool(s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(); + assert_eq!(paras(&mut s), 2000); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(paras(&mut s), 1, "one Undo takes all the lines back out"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains("Line")); + + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); + let many = "x\n".repeat(crate::tools::MAX_TYPED_PARAGRAPHS); + let r = call(&mut s, 12, "tools/call", json!({"name": "type_text", "arguments": {"text": many, "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(calls.lock().unwrap().is_empty(), "refused before any command"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b8..3a46629b2f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value { tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most 10000 lines).", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -154,6 +154,9 @@ pub fn tool_definitions() -> Value { ]) } +/// Most paragraphs `type_text` with `paragraphs: true` types in one call. +pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; + fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": id, "params": params})) } @@ -229,22 +232,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - let mut last = Ok(Value::Null); - for (i, line) in t.split('\n').enumerate() { - if i > 0 { - last = exec(b, "text.newParagraph", json!({})); - if last.is_err() { - break; - } - } - if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); - } - if last.is_err() { - break; - } + // One `text.insert`: `\r` is a paragraph break there (Enter's split), so the + // lines go in as one edit and one undo step, all or nothing, instead of a command + // (and a whole-document undo snapshot) per line. + let t = t.replace("\r\n", "\n").replace('\r', "\n"); + let lines = t.split(['\n', '\u{2029}']).count(); + if lines > MAX_TYPED_PARAGRAPHS { + return ToolResult::error(format!( + "`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call" + )); } - wrap(last) + wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } From 8ce3eb58c73b6da7b35b957e3cb17da7bb281c13 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:20:45 +0100 Subject: [PATCH 23/87] #3735: automation writes check the directory entry they replace, not only the link's target Findings 41 and 69. check_write resolved a path through links and compared the target with the write root, but safe_save puts its temporary file beside the path given and renames it over that entry; rename_file removes it. A link outside the root pointing in passed the check while the write landed outside. Write access now also requires the entry itself (resolved parent + literal name) inside the root; the existing target check still refuses an inside link that points out. Tests: file_access::tests::links_are_followed_before_comparing (extended), mcp automation_roots::a_link_outside_the_write_root_into_it_is_not_written. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/file_access.rs | 43 ++++++++++++++++++++++++++-- crates/mcp/tests/automation_roots.rs | 28 ++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3d1e808805..8e6fdaf171 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -10,6 +10,9 @@ //! is a link that leads nowhere). //! - The result must lie inside the root, compared by whole components (`/work2` isn't inside //! `/work`), without regard to case on Windows. +//! - Writing replaces or removes a directory entry, so for write access the entry itself (its +//! folder's links followed, its own name as given) must lie inside the root as well: a link +//! outside the root that leads into it is not a way to write outside (#3735). //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -95,7 +98,14 @@ impl AutomationRoots { Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), }; }; - if inside(&resolve(path)?, folder) { + // Writing replaces or removes the directory entry `path` names (a safe save renames its + // temporary file over it, a rename removes it): that entry must be inside the root too, + // not only what a link there leads to (#3735). + let entry_inside = || match access { + Access::Read => Ok(true), + Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), + }; + if inside(&resolve(path)?, folder) && entry_inside()? { return Ok(()); } let root = shown.display(); @@ -201,9 +211,27 @@ fn resolve(path: &str) -> Result { return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); } let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + resolve_absolute(path, &absolute) +} + +/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own +/// name as given, so a link there is the entry itself, not what it leads to. That entry is what +/// replacing or removing the file changes. +fn resolve_entry(path: &str) -> Result { + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { + return Err(format!("automation path rejected: not a file name: {path}")); + }; + let mut entry = resolve_absolute(path, folder)?; + entry.push(name); + Ok(entry) +} + +/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). +fn resolve_absolute(path: &str, absolute: &Path) -> Result { // The names below the nearest existing folder, last first. let mut missing: Vec = vec![]; - let mut at = absolute.as_path(); + let mut at = absolute; loop { match std::fs::canonicalize(at) { Ok(mut found) => { @@ -382,6 +410,17 @@ mod tests { // A link to a folder inside the root stays inside. assert!(made(&base.join("in/sub"), &base.join("in/alias"))); assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); + // A link outside the write root that leads into it: writing would replace the link, + // outside (#3735). One inside that leads to a file inside is fine. + std::fs::write(base.join("out/doc.docx"), "x").unwrap(); + assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); + let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); + assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); + assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); + // A folder link outside that leads into the root: the entry is inside, as the system opens it. + assert!(made(&base.join("out"), &base.join("outside/to-out"))); + assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); // A root given through a link is the folder it leads to. assert!(made(&base.join("in"), &base.join("in-link"))); let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 415ab99d81..7b618ac499 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -208,3 +208,31 @@ fn links_out_of_a_root_are_refused() { refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root"); assert!(!base.join("outside/doc.docx").exists()); } + +/// The names in `dir`, sorted. +fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[cfg(unix)] +#[test] +fn a_link_outside_the_write_root_into_it_is_not_written() { + // #3735: the link's target is inside the write root, but a save replaces the link itself, + // and its temporary file would sit beside it, outside. + let (mut server, base) = confined("link-in", true, true); + std::fs::write(base.join("out/doc.docx"), "inside").unwrap(); + std::os::unix::fs::symlink(base.join("out/doc.docx"), base.join("outside/alias.docx")).unwrap(); + let before = names(&base.join("outside")); + ok(&mut server, "type_text", json!({"text": "Hello"})); + refused(&mut server, "save_document", json!({"path": s(&base.join("outside/alias.docx"))}), "outside the write root"); + let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("outside/alias.docx"))})); + assert!(error && text.contains("outside the write root"), "{text}"); + assert_eq!(names(&base.join("outside")), before, "no temporary file, nothing replaced"); + assert!(std::fs::symlink_metadata(base.join("outside/alias.docx")).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read_to_string(base.join("out/doc.docx")).unwrap(), "inside"); + // Saving to the real file inside still works. + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))})); + assert_ne!(std::fs::read(base.join("out/doc.docx")).unwrap(), b"inside"); +} From 02fffea7c99c4bc528f18bd7fbf852bed02eff2a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:24:14 +0100 Subject: [PATCH 24/87] #3736: HTML pictures load only from inside the automation read root Finding 54. Opening an HTML file checks the file through its links, but its pictures load relative to the folder the given path names. Through a link outside the read root to a page inside it, pictures beside the link (outside the root) were embedded. Each picture's canonical path is now checked with check_read before it is read; unconfined opens are unchanged. Test: tests::html_pictures_load_only_from_inside_the_read_root. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/io.rs | 5 ++++- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 27e7020535..1e959c49ae 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -506,7 +506,7 @@ impl LocalImages { left.map(|_| ()) } - /// The canonical path `src` names, if it's inside the folder. + /// The canonical path `src` names, if it's inside the folder and may be read. fn resolve(&self, src: &str) -> Option { let src = src.split(['#', '?']).next().unwrap_or(""); if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { @@ -520,6 +520,9 @@ impl LocalImages { let dir = self.dir.as_ref()?; // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. let path = std::fs::canonicalize(dir.join(rel)).ok()?; + // The folder is the one the HTML path names, which may be a link from outside the + // automation read root; the picture itself must be inside it (#3736). + crate::file_access::check_read(&path.to_string_lossy()).ok()?; path.starts_with(dir).then_some(path) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44747c0ab9..1a9cc45389 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4112,6 +4112,29 @@ fn html_pictures_through_a_symlink_out_of_the_folder_are_not_loaded() { assert!(doc.plain_text(StoryRef::Body).contains("Link")); } +#[cfg(unix)] +#[test] +fn html_pictures_load_only_from_inside_the_read_root() { + // #3736: the HTML file is checked through its link, but its pictures load from beside the + // link. Opened through a link outside the read root, they must not come from there. + let root = scratch_dir("html-img-root"); + for d in ["in", "outside"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + std::fs::write(root.join("in/page.html"), r#"

Pic

"#).unwrap(); + std::fs::write(root.join("in/pic.png"), tiny_png()).unwrap(); + std::fs::write(root.join("outside/pic.png"), tiny_png()).unwrap(); + std::os::unix::fs::symlink(root.join("in/page.html"), root.join("outside/page.html")).unwrap(); + let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap(); + let through_link = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("outside/page.html"))); + let direct = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("in/page.html"))); + let _ = std::fs::remove_dir_all(&root); + let through_link = through_link.unwrap(); + assert!(through_link.media.is_empty(), "no picture from outside the read root"); + assert!(through_link.plain_text(StoryRef::Body).contains("Pic")); + assert_eq!(direct.unwrap().media.len(), 1, "pictures inside the root still load"); +} + /// `file.open` on a path reads only a regular file: a named pipe blocked until a writer came, and /// `/dev/zero` read until memory ran out. #[cfg(unix)] From 6142567b6a180cd15dd805bfd17f3981d4281a18 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:29:05 +0100 Subject: [PATCH 25/87] #3737: dictionary file and folder settings held to the automation roots Finding 34 and #3737 (WC-35), same root cause: proofing paths bypassed file_access. persist_user_dictionary now checks the dictionary file for read and write (entry included, so its sibling temporary file too) and keeps the words in memory when refused. folder_setting checks dictionariesFolder and userTemplatesFolder with check_read before probing the folder, so a confined session can't install or save one outside the read root. Finding 34 is defensive today: only the desktop app sets user_dict_path, and it runs unconfined. Test: tests::confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/options.rs | 2 ++ crates/engine/src/session.rs | 10 +++++++--- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 74777db995..0c676b750a 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -263,6 +263,8 @@ fn folder_setting(key: &str, f: &str, local_only: bool) -> Result if !path.is_absolute() { return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path"))); } + // Its files are read from now on, and on every start: only from inside the read root (#3737). + crate::file_access::check_read(&path.to_string_lossy()).map_err(CmdError::Failed)?; if !path.is_dir() { return Err(CmdError::Failed(format!("no folder at {}", path.display()))); } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fb9999c74e..fa24909887 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -663,10 +663,14 @@ impl Session { /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, /// with `removed` (words just taken out) left out of the merge with the file. A failed save - /// keeps the words in memory and never fails the command (#40). + /// keeps the words in memory and never fails the command (#40), as does a file outside the + /// automation roots in force: it is read, then replaced beside itself (#3737). pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { - if let Some(path) = self.user_dict_path.as_deref() - && let Err(e) = wordcraft_proof::save_user_dictionary(path, removed) + let Some(path) = self.user_dict_path.as_deref() else { return }; + let name = path.to_string_lossy(); + if let Err(e) = crate::file_access::check_read(&name) + .and_then(|()| crate::file_access::check_write(&name)) + .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) { log::warn!("custom dictionary: save failed: {e}"); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1a9cc45389..4f1aabf0b8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -224,6 +224,33 @@ fn custom_dictionary_words_persist_through_their_file() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { + // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are + // held to the automation roots like any other path. + let base = scratch_dir("dict-roots"); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + let mut s = s(); + let outside = base.join("outside/custom-dictionary.txt"); + s.user_dict_path = Some(outside.clone()); + let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); + assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); + assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); + assert!(!outside.exists(), "nothing written outside the write root"); + for key in ["dictionariesFolder", "userTemplatesFolder"] { + assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); + } + assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); + // A folder inside the read root is still taken. + let inside = base.join("in"); + assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); + let _ = std::fs::remove_dir_all(&base); + assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); +} + #[test] fn add_to_dictionary_takes_only_words_the_file_keeps() { // #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load. From 66e92e36985e34d09c8279997a3556cff66c2a63 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:32:43 +0100 Subject: [PATCH 26/87] #3692: formatting restriction refuses Copy Styles Finding 50. check_formatting blocked Update Styles and Attach Template with update, but not styles.copyFrom, which replaces style definitions (locked flag included) from another file. Under "limit formatting to a selection of styles" an editor could import changed formatting and unlock a locked style without the password. styles.copyFrom is now refused like Update Styles. copy_styles' callers are all blocked under the restriction, so its lock handling is left as is. Test: cmd::protect::tests::formatting_restriction_refuses_attach_template_update (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/protect.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index a1824a863a..5beb67eadb 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -406,6 +406,8 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro | "table.modifyStyle" | "table.deleteStyle" | "tools.updateStyles" + // Copies style definitions in from another file, locked flags included (#3692). + | "styles.copyFrom" ) // Attach Template updates the styles from it when asked (`linkStyles` is only a setting). || (id == "tools.attachTemplate" && p::bool(params, "update") == Some(true)); @@ -745,7 +747,8 @@ mod tests { } /// Formatting limited to styles: attaching a template refuses to update the styles from it - /// (it would bypass the Update Styles block); attaching without updating still works. + /// (it would bypass the Update Styles block), nor can Copy Styles; attaching without + /// updating still works. #[test] fn formatting_restriction_refuses_attach_template_update() { let mut t = Document::from_text("Template."); @@ -759,6 +762,12 @@ mod tests { let e = s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "update": true, "data": data})).unwrap_err().to_string(); assert!(e.contains("formatting is limited"), "{e}"); assert_eq!(s.doc, before); + // Copying styles in replaces their definitions, locked flag included (#3692). + s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true); + let before = s.doc.clone(); + let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string(); + assert!(e.contains("formatting is limited"), "{e}"); + assert_eq!(s.doc, before); s.run("tools.attachTemplate", &json!({"path": "Report.dotx"})).unwrap(); assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx")); } From c7dfd92ff1722a6c329830ac71760b5599d2134b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:34:26 +0100 Subject: [PATCH 27/87] #3698: template paths refuse the NT namespace and are checked again after joining the document's folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 78 and WC-02. is_network_path looked only at the first two bytes, so `\??\UNC\host\share\T.dotx` (any separator mix) passed as local, and local_path checked the reference before joining it to the document's folder, so a relative reference from a document named `\\host\share\x` (file.open with data) became a remote path after the check. The classifier now also refuses `\??\…` unless a drive letter follows, and local_path checks the completed path. Pure string logic, the same on every platform; the same helper guards the folder settings. Test: cmd::templates::tests::template_references_become_local_paths (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/templates.rs | 39 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index 60d4044a05..c59f3e1e41 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -231,18 +231,27 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option { return None; } let p = PathBuf::from(&path); - if p.is_relative() - && !is_windows_absolute(&path) - && let Some(dir) = base.and_then(Path::parent) - { - return Some(dir.join(p)); - } - Some(p) + let p = match base.and_then(Path::parent) { + Some(dir) if p.is_relative() && !is_windows_absolute(&path) => dir.join(p), + _ => p, + }; + // Checked again once joined: the document's own folder may be on another machine (#3698). + (!is_network_path(&p.to_string_lossy())).then_some(p) } +/// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`, +/// `//host`, `\\?\UNC\…`, `\\.\…`, and the NT namespace `\??\…` unless a drive letter +/// follows (`\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. pub(crate) fn is_network_path(p: &str) -> bool { + let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/')); let b = p.as_bytes(); - matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) + if sep(b.first()) && sep(b.get(1)) { + return true; + } + // `\??\` then anything but `X:` (and the end or a separator). + let nt = sep(b.first()) && b.get(1) == Some(&b'?') && b.get(2) == Some(&b'?') && sep(b.get(3)); + let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6))); + nt && !drive } fn is_windows_absolute(p: &str) -> bool { @@ -504,9 +513,23 @@ mod tests { "file://evil.example/share/T.dotx", "file:////evil.example/share/T.dotx", "FILE://Evil.Example/T.dotx", + // The NT namespace (#3698), with any separators. + "\\??\\UNC\\evil.example\\share\\T.dotx", + "/??/UNC/evil.example/share/T.dotx", + "\\??/UNC\\evil.example/share/T.dotx", + "\\??\\GLOBALROOT\\Device\\Mup\\evil.example\\share\\T.dotx", + "\\\\.\\UNC\\evil.example\\share\\T.dotx", ] { assert_eq!(local_path(remote, Some(Path::new("/docs/Letter.docx"))), None, "{remote}"); + assert_eq!(local_path(remote, None), None, "{remote} without a folder"); } + // A relative reference from a document whose folder is on another machine (#3698). + assert_eq!(local_path("T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None); + assert_eq!(local_path("file:T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None); + // A drive in the NT namespace is local. + assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx")); + assert!(!is_network_path("\\??\\C:")); + assert!(is_network_path("\\??\\C:evil")); assert_eq!(percent_decode("100%"), "100%"); assert_eq!(percent_decode("%zz%4"), "%zz%4"); } From 4f5e596c715ee2d827eed4e6078f5108fee60ebf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:35:02 +0100 Subject: [PATCH 28/87] =?UTF-8?q?#3735:=20docs=20=E2=80=94=20automation=20?= =?UTF-8?q?roots,=20type=5Ftext=20limit,=20Copy=20Styles=20under=20protect?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress log row for group J (findings 9, 18, 34, 41, 50, 54, 69, 78); docs/mcp.md says what the roots now cover; the Restrict Editing row notes Copy Styles. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- docs/mcp.md | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 076855fcf5..cde6c8e809 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,7 +93,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465). A `HYPERLINK` field reads as a link, its result ordinary text (tabs, nested `PAGEREF`), and is written back as `w:hyperlink`; its tooltip (`\o`) and target frame (`\t`) are dropped (#2174) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC when the TOC has the heading-and-entries form; around any other TOC it is kept as read (#2427). Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity; formatting-only protection also refuses Copy Styles (`styles.copyFrom`, #3692) | | Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -252,6 +252,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Formatting-only protection refuses Copy Styles, as it does Update Styles (#3692) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `basereading`, in the base text's run formatting, and HTML import reads ``/``/`` back into one ruby object: `` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d1..99bf754d54 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,11 +63,15 @@ config works across the suite) keep the agent to the folders you give it: `file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and `picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm, ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`, - `styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read - from beside that file, so they are inside the read root too (a link that leads out is not followed). + `styles.organizer` and `styles.copyFrom` read, and the folders `file.options` takes + (`dictionariesFolder`, `userTemplatesFolder`). The pictures an opened HTML file names are read + from beside that file and must be inside the read root too, even when the file was opened through + a link (#3736). - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, - `file.saveTemplate`, and `mailings.finish` with a `path`. + `file.saveTemplate`, and `mailings.finish` with a `path`. A save replaces the name it is given, + so that name must be inside the write root as well as what it leads to: a link outside the root + pointing in is refused (#3735). - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk). Without either flag nothing changes. @@ -108,7 +112,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | From 8a18026a13acee7514e151ed496d38d218a89682 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:57:23 +0100 Subject: [PATCH 29/87] #3692: rustfmt type_text Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/tools.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3a46629b2f..c666fbe3f9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,9 +238,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let t = t.replace("\r\n", "\n").replace('\r', "\n"); let lines = t.split(['\n', '\u{2029}']).count(); if lines > MAX_TYPED_PARAGRAPHS { - return ToolResult::error(format!( - "`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call" - )); + return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); } wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) } else { From 3a8ff6b6bc071a6e0dc85ae7bc0884d612efa8af Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:21:35 +0100 Subject: [PATCH 30/87] #3692: typed paragraphs break like Enter, still one command and one undo step Ultrareview finding 2. MCP `type_text` with `paragraphs: true` sent one raw text.insert with `\r` breaks, which skipped Enter: a break could land in a one-line, check-box or drop-down content control (text.newParagraph refuses there), and Enter's AutoFormat (--- borders, +---+ tables) no longer ran. text.insert takes `paragraphs: true`: each line is typed raw and each break runs text.newParagraph's own implementation (`enter`), inside the one command, so there is one checkpoint. Where Enter would refuse, the command fails and Session::run puts the document and history back (all or nothing). Enter's AutoFormat normally records the plain Enter as its own undo step after the fact (push_undo, a whole-document clone); inside typed paragraphs that step is skipped, so AutoFormatted lines add no snapshot each. MCP sends `paragraphs: true` and keeps MAX_TYPED_PARAGRAPHS. Tests: mcp type_text_paragraphs_are_refused_in_a_one_line_control, type_text_paragraphs_autoformat_like_enter_in_one_undo_step. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/text.rs | 60 ++++++++++++++++++++++++++++------- crates/mcp/src/tests.rs | 52 +++++++++++++++++++++++++++--- crates/mcp/src/tools.rs | 9 +++--- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d83d16425c..614086256b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -10,7 +10,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ - CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#), + CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"), CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"), CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"), @@ -99,6 +99,9 @@ fn typing_options(s: &mut Session, text: &str) { fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); + if p::bool(v, "paragraphs").unwrap_or(false) { + return insert_paragraphs(s, text); + } typing_options(s, text); let t = if raw { None } else { autoformat(s, text) }; type_text(s, t.as_deref().unwrap_or(text))?; @@ -576,30 +579,65 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { if super::controls::refuses_paragraph(s) { return sel_result(s); } + enter(s, true)?; + sel_result(s) +} + +/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the +/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so +/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where +/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and, +/// like any failed command, nothing of it stays. +fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult { + typing_options(s, text); + let text = text.replace("\r\n", "\n"); + for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() { + if i > 0 { + if super::controls::refuses_paragraph(s) { + return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into())); + } + enter(s, false)?; + } + if !line.is_empty() { + type_text(s, line)?; + } + } + sel_result(s) +} + +/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a +/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step` +/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it +/// (typed paragraphs, one undo step for all) no extra snapshot is taken. +fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> { super::controls::prepare_edit(s)?; if let Some(text) = sample_text(s) { let end = s.sel.focus.clone(); s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end }; - type_text(s, &text)?; - return sel_result(s); + return type_text(s, &text); } let mut at = delete_selection(s)?; let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table)); if let Some(auto) = auto { // Word: Undo right after reverts only the AutoFormat, so the typed characters come back // and the Enter stays. Record that plain Enter as its own undo step. - let (doc, sel) = (s.doc.clone(), s.sel.clone()); - let new = split_para(s, &at)?; - s.sel = Selection::caret(new); - let plain_doc = std::mem::replace(&mut s.doc, doc); - let plain_sel = std::mem::replace(&mut s.sel, sel); + let plain = if undo_step { + let (doc, sel) = (s.doc.clone(), s.sel.clone()); + let new = split_para(s, &at)?; + s.sel = Selection::caret(new); + Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel))) + } else { + None + }; match auto { EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?, EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?, } s.goal_x = None; - s.push_undo("AutoFormat", plain_doc, plain_sel); - return sel_result(s); + if let Some((plain_doc, plain_sel)) = plain { + s.push_undo("AutoFormat", plain_doc, plain_sel); + } + return Ok(()); } // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). if s.sel.is_collapsed() && at.off > 0 { @@ -610,7 +648,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { let new = split_para(s, &at)?; s.sel = Selection::caret(new); s.goal_x = None; - sel_result(s) + Ok(()) } /// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e000371344..de99ddd57c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,7 +120,7 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend that keeps every command it is sent and refuses text with a paragraph break. +/// A backend that keeps every command it is sent and refuses typed paragraphs. struct RefusesParagraph { calls: std::sync::Arc>>, } @@ -128,8 +128,8 @@ struct RefusesParagraph { impl crate::backend::Backend for RefusesParagraph { fn call(&mut self, _method: &str, params: Value) -> Result { self.calls.lock().unwrap().push(params.clone()); - match params["params"]["text"].as_str() { - Some(t) if t.contains('\r') => Err("paragraph break refused".into()), + match params["params"]["paragraphs"].as_bool() { + Some(true) => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -153,7 +153,8 @@ fn type_text_stops_at_a_refused_paragraph_break() { let calls = calls.lock().unwrap(); assert_eq!(calls.len(), 1, "{calls:?}"); assert_eq!(calls[0]["command"], "text.insert"); - assert_eq!(calls[0]["params"]["text"], "one\rtwo"); + assert_eq!(calls[0]["params"]["text"], "one\ntwo"); + assert_eq!(calls[0]["params"]["paragraphs"], true); } /// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document @@ -179,3 +180,46 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { assert_eq!(r["result"]["isError"], true, "{r}"); assert!(calls.lock().unwrap().is_empty(), "refused before any command"); } + +/// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's +/// implementation), so a one-line content control refuses it, and all of the text with it. +#[test] +fn type_text_paragraphs_are_refused_in_a_one_line_control() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Name: "})); + tool(&mut s, "execute", json!({"command": "developer.plainText", "params": {"tag": "name"}})); + tool(&mut s, "execute", json!({"command": "caret.right"})); + let before = tool(&mut s, "get_text", json!({}))["text"].clone(); + let r = call(&mut s, 13, "tools/call", json!({"name": "type_text", "arguments": {"text": "Ada\nLovelace", "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"], before, "nothing typed"); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1); +} + +/// Ultrareview of #3692: Enter's AutoFormat runs on each typed paragraph break ("---" becomes a +/// border, "+---+---+" a table), and the lines are still one undo step however many of them +/// AutoFormat changes. +#[test] +fn type_text_paragraphs_autoformat_like_enter_in_one_undo_step() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Above\n---\nBelow\n+---+---+\nCell", "paragraphs": true})); + let blocks = tool(&mut s, "inspect_document", json!({}))["blocks"].clone(); + let blocks = blocks.as_array().unwrap(); + assert_eq!(blocks[0]["text"], "Above", "{blocks:?}"); + assert!(blocks[0]["props"]["borders"]["bottom"].is_object(), "--- is a border: {blocks:?}"); + assert_eq!(blocks[1]["text"], "Below", "{blocks:?}"); + assert_eq!(blocks[2]["type"], "table", "+---+---+ is a table: {blocks:?}"); + assert!(blocks[2].to_string().contains("Cell"), "typing goes on in its first cell: {blocks:?}"); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), ""); + + // Many AutoFormatted lines: still one undo step (no snapshot per line). + let lines: Vec<&str> = (0..500).flat_map(|_| ["Text", "==="]).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\n"), "paragraphs": true})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 501); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c666fbe3f9..d706d49e9a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -232,15 +232,16 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - // One `text.insert`: `\r` is a paragraph break there (Enter's split), so the - // lines go in as one edit and one undo step, all or nothing, instead of a command - // (and a whole-document undo snapshot) per line. + // One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat, + // and its refusal in a one-line control), but the lines go in as one edit and one + // undo step, all or nothing, instead of a command (and a whole-document undo + // snapshot) per line. let t = t.replace("\r\n", "\n").replace('\r', "\n"); let lines = t.split(['\n', '\u{2029}']).count(); if lines > MAX_TYPED_PARAGRAPHS { return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); } - wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) + wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } From 6b23f3b368d5b6e0b4e16e905217597ab6200a46 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:21:35 +0100 Subject: [PATCH 31/87] #3698: template bytes need no local path; verbatim and device paths with a drive are local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultrareview finding 4. (a) load_other resolved the template name against the document's folder even when its bytes came in `data`, so a document opened from a share or a `\\?\` path could not attach, update or organise styles from a relative name. With bytes nothing is read: the name only gives the format. Without bytes local_path still refuses a relative reference from a document on a share (no NTLM leak). (b) is_network_path called `\\?\C:\…` and `\\.\C:\…` network; a drive letter after the verbatim, device or NT prefix is local. `\\?\UNC\…`, `\\.\UNC\…`, other devices and `\\host\…` stay refused. Tests: cmd::templates::tests::template_bytes_need_no_local_path (new), template_references_become_local_paths (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/templates.rs | 69 ++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index c59f3e1e41..110427879c 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -185,17 +185,22 @@ fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result MAX_PATH { return Err(CmdError::Params("`path` is too long".into())); } - let local = local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?; - let ext = local.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + // With the bytes given nothing is read, so the name only gives the format: no path is + // resolved, and a document on a share (or a `\\?\` path) can still use a relative name. + let file = match data { + Some(_) => PathBuf::from(name), + None => local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?, + }; + let ext = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); if matches!(ext.as_str(), "" | "txt" | "text") { return Err(CmdError::Params(format!("`{name}` is not a document or template with styles"))); } match data { Some(d) => { let bytes = super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?; - crate::io::open_bytes(&local.to_string_lossy(), &bytes).map_err(CmdError::Failed) + crate::io::open_bytes(name, &bytes).map_err(CmdError::Failed) } - None => crate::io::open_path(&local).map_err(CmdError::Failed), + None => crate::io::open_path(&file).map_err(CmdError::Failed), } } @@ -240,18 +245,25 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option { } /// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`, -/// `//host`, `\\?\UNC\…`, `\\.\…`, and the NT namespace `\??\…` unless a drive letter -/// follows (`\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. +/// `//host`, `\\?\UNC\…`, `\\.\UNC\…`, other devices, and the NT namespace `\??\…`. The +/// verbatim, device and NT prefixes are local when a drive letter follows (`\\?\C:\…`, +/// `\\.\C:\…`, `\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. pub(crate) fn is_network_path(p: &str) -> bool { let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/')); let b = p.as_bytes(); - if sep(b.first()) && sep(b.get(1)) { - return true; - } - // `\??\` then anything but `X:` (and the end or a separator). - let nt = sep(b.first()) && b.get(1) == Some(&b'?') && b.get(2) == Some(&b'?') && sep(b.get(3)); + // `\\?\`, `\\.\` or `\??\`, then `X:` and the end or a separator. + let prefix = sep(b.first()) + && sep(b.get(3)) + && match (b.get(1), b.get(2)) { + (Some(b'?'), Some(b'?')) => true, + (c, Some(b'?' | b'.')) => sep(c), + _ => false, + }; let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6))); - nt && !drive + if prefix { + return !drive; + } + sep(b.first()) && sep(b.get(1)) } fn is_windows_absolute(p: &str) -> bool { @@ -498,6 +510,29 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + /// Ultrareview of #3698: with the template's bytes given (`data`) nothing is read from a path, + /// so a document opened from a share or a `\\?\` path can still attach, update and organise + /// styles from a template named relative to it. Without the bytes, the share is never read. + #[test] + fn template_bytes_need_no_local_path() { + let data = crate::cmd::insert::base64_encode(&wordcraft_docx::write(&template()).unwrap()); + for doc_path in ["//fileserver/share/Letter.docx", "//?/C:/docs/Letter.docx"] { + let mut s = Session::new(Document::new()); + s.path = Some(PathBuf::from(doc_path)); + s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "data": data, "update": true})).unwrap(); + assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0), "{doc_path}"); + s.run("tools.updateStyles", &json!({"path": "Report.dotx", "data": data})).unwrap(); + let listed = s.run("styles.organizer", &json!({"path": "Report.dotx", "data": data})).unwrap(); + assert!(listed["file"]["styles"].as_array().unwrap().iter().any(|x| x["name"] == "Callout"), "{doc_path}"); + s.run("styles.copyFrom", &json!({"path": "Report.dotx", "data": data, "styles": ["Callout"]})).unwrap(); + assert!(s.doc.styles.get("Callout").is_some(), "{doc_path}"); + } + let mut s = Session::new(Document::new()); + s.path = Some(PathBuf::from("//fileserver/share/Letter.docx")); + let err = s.run("tools.updateStyles", &json!({"path": "Report.dotx"})).unwrap_err().to_string(); + assert!(err.contains("not a local file"), "{err}"); + } + #[test] fn template_references_become_local_paths() { assert_eq!(local_path("file:///C:/Users/A%20B/T.dotx", None), Some(PathBuf::from("C:/Users/A B/T.dotx"))); @@ -530,6 +565,16 @@ mod tests { assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx")); assert!(!is_network_path("\\??\\C:")); assert!(is_network_path("\\??\\C:evil")); + // Verbatim and device paths with a drive are local; with `UNC` or anything else they aren't. + for local in ["\\\\?\\C:\\Templates\\T.dotx", "\\\\.\\C:\\Templates\\T.dotx", "//?/C:/Templates/T.dotx", "\\\\?\\C:"] { + assert!(!is_network_path(local), "{local}"); + } + for remote in + ["\\\\?\\UNC\\host\\share\\T.dotx", "//./UNC/host/share/T.dotx", "\\\\host\\share\\T.dotx", "\\\\?\\C:evil", "\\\\.\\PhysicalDrive0"] + { + assert!(is_network_path(remote), "{remote}"); + } + assert_eq!(local_path("T.dotx", Some(Path::new("//?/C:/docs/Letter.docx"))), Some(PathBuf::from("//?/C:/docs/T.dotx"))); assert_eq!(percent_decode("100%"), "100%"); assert_eq!(percent_decode("%zz%4"), "%zz%4"); } From 6766118d05cc4d987833929f168a96500573b381 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 04:27:23 +0100 Subject: [PATCH 32/87] =?UTF-8?q?#3692:=20review=20=E2=80=94=20doc=20comme?= =?UTF-8?q?nts=20on=20the=20functions=20this=20branch=20touched=20that=20h?= =?UTF-8?q?ad=20none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/engine/src/cmd/text.rs | 4 ++++ crates/engine/src/session.rs | 1 + crates/mcp/src/backend.rs | 3 +++ crates/mcp/src/tools.rs | 3 +++ 4 files changed, 11 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 614086256b..5ce435e277 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -8,6 +8,7 @@ use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; use super::{delete_selection, non_mac, sel_result, split_para, type_text}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols. pub fn specs() -> Vec { vec![ CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), @@ -96,6 +97,8 @@ fn typing_options(s: &mut Session, text: &str) { } } +/// `text.insert`: type `text` at the selection as the keyboard does (AutoCorrect and +/// AutoFormat unless `raw`), or, with `paragraphs`, line by line with Enter between. fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); @@ -574,6 +577,7 @@ fn sample_text(s: &Session) -> Option { Some(text) } +/// `text.newParagraph`: Enter, unless the selection is in a control that takes no paragraph break. fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { // Enter in a one-line plain text control (or a check box or list) does nothing, as in Word. if super::controls::refuses_paragraph(s) { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fa24909887..7b25a21f74 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -637,6 +637,7 @@ fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) } +/// The name of `doc`'s bibliography style, APA when it names none we know. fn doc_bib_style(doc: &Document) -> String { doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 1cf17c61d5..fe26b2dbb3 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -112,6 +112,7 @@ impl Remote { Ok(r) } + /// The address the app is reached at, as given. pub fn addr(&self) -> &str { &self.addr } @@ -127,6 +128,8 @@ impl Remote { }) } + /// Drop the connection and connect again to the first of the address's targets that answers, + /// deciding afresh whether the control key may go to it. fn reconnect(&mut self) -> std::io::Result<()> { self.conn = None; self.key = None; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d706d49e9a..cc127d8a79 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -52,6 +52,7 @@ fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value { json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}}) } +/// Every MCP tool with its title, description, input schema and annotations (`tools/list`). pub fn tool_definitions() -> Value { json!([ tool( @@ -168,6 +169,8 @@ fn wrap(r: Result) -> ToolResult { } } +/// Run tool `name` with arguments `a` through backend `b` (`tools/call`); unknown tools and +/// failed calls are error results, never panics. pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let st = |k: &str| a.get(k).and_then(Value::as_str); match name { From 3d944e735af51ab42a66cdc66e6a5a23b6a68d44 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:32:58 +0100 Subject: [PATCH 33/87] =?UTF-8?q?#3692:=20review=20=E2=80=94=20the=20typed?= =?UTF-8?q?-paragraph=20line=20cap=20is=20text.insert's=20own,=20so=20cont?= =?UTF-8?q?rol-channel=20and=20CLI=20calls=20are=20held=20to=20it=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tool no longer counts lines itself; it quotes the engine's MAX_TYPED_PARAGRAPHS. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/engine/src/cmd/text.rs | 16 +++++++++++++--- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 11 ++++++----- crates/mcp/src/tools.rs | 16 ++++++---------- docs/mcp.md | 2 +- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5ce435e277..1781de945c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -11,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; /// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols. pub fn specs() -> Vec { vec![ - CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), + CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool} (paragraphs: each line a paragraph, one undo step, at most 10000 lines)"#), CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"), CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"), CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"), @@ -587,14 +587,24 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { sel_result(s) } +/// Most lines `text.insert` with `paragraphs` types in one call, for every caller (control +/// channel, CLI, MCP): each line is an Enter, with its AutoFormat and AutoCorrect, so the work +/// grows with the line count. More is refused before anything is typed (#3692). +pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; + /// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the /// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so /// one undo step instead of a command and a whole-document snapshot per line (#3692). Where /// Enter would do nothing (a one-line control, a check box, a list) the text is refused and, -/// like any failed command, nothing of it stays. +/// like any failed command, nothing of it stays. More than [`MAX_TYPED_PARAGRAPHS`] lines are +/// refused before anything is typed. fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult { - typing_options(s, text); let text = text.replace("\r\n", "\n"); + let lines = text.split(['\n', '\r', '\u{2029}']).count(); + if lines > MAX_TYPED_PARAGRAPHS { + return Err(CmdError::Params(format!("text.insert: `text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call"))); + } + typing_options(s, &text); for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() { if i > 0 { if super::controls::refuses_paragraph(s) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4f1aabf0b8..08a0c04d2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3368,6 +3368,36 @@ fn failed_command_leaves_document_unchanged() { assert_eq!(text(&s), before); } +/// Review of #3692: the line cap on typed paragraphs is the engine's, so a control-channel or +/// CLI `text.insert` is held to it as the MCP tool is. At the cap every line goes in as one undo +/// step; one line more is refused before anything is typed. +#[test] +fn typed_paragraphs_are_capped_for_every_caller() { + use crate::CmdError; + use crate::cmd::text::MAX_TYPED_PARAGRAPHS; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "keep"})); + let (before, depth) = (body_texts(&s), s.undo_depth()); + // "x\n" repeated ends in an empty line: one over the cap. A lone "\r" counts as a break too. + for over in ["x\n".repeat(MAX_TYPED_PARAGRAPHS), format!("{}x", "x\r\n".repeat(MAX_TYPED_PARAGRAPHS - 1)) + "\rx"] { + match s.run("text.insert", &json!({"text": over, "paragraphs": true})) { + Err(CmdError::Params(m)) => assert!(m.contains("10001 lines") && m.contains("at most 10000"), "{m}"), + r => panic!("not refused: {r:?}"), + } + assert_eq!(body_texts(&s), before, "nothing typed"); + assert_eq!(s.undo_depth(), depth, "no undo step"); + } + let at_cap = vec!["y"; MAX_TYPED_PARAGRAPHS].join("\r\n"); + run(&mut s, "text.insert", json!({"text": at_cap, "paragraphs": true})); + let paras = body_texts(&s); + assert_eq!(paras.len(), MAX_TYPED_PARAGRAPHS); + // Each break is Enter's own, AutoCorrect included: the first sentence gets its capital. + assert_eq!((paras.first().map(String::as_str), paras.last().map(String::as_str)), (Some("Keepy"), Some("y"))); + assert!(s.undo(), "undo"); + assert_eq!(body_texts(&s).len(), 1, "one undo step takes every line back out"); + assert!(!body_texts(&s).iter().any(|p| p.contains('y'))); +} + #[test] fn hostile_params_never_panic() { let reg = cmd::registry(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index de99ddd57c..8bc4a444e4 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -158,7 +158,7 @@ fn type_text_stops_at_a_refused_paragraph_break() { } /// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document -/// undo snapshot) per line; too many lines are refused before anything is typed. +/// undo snapshot) per line; too many lines are refused by the engine before anything is typed. #[test] fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { let mut s = Server::new(Box::new(Headless::default())); @@ -173,12 +173,13 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { assert_eq!(paras(&mut s), 1, "one Undo takes all the lines back out"); assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains("Line")); - let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); - let many = "x\n".repeat(crate::tools::MAX_TYPED_PARAGRAPHS); + // One line over the engine's cap ("x\n" repeated gives a last, empty line): refused, nothing typed. + let many = "x\n".repeat(wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS); let r = call(&mut s, 12, "tools/call", json!({"name": "type_text", "arguments": {"text": many, "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); - assert!(calls.lock().unwrap().is_empty(), "refused before any command"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("at most 10000 paragraphs"), "{r}"); + assert_eq!(paras(&mut s), 1, "nothing typed"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains('x')); } /// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index cc127d8a79..54f3873fbd 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -103,7 +103,10 @@ pub fn tool_definitions() -> Value { tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most 10000 lines).", + &format!( + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most {} lines).", + wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS + ), obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -155,9 +158,6 @@ pub fn tool_definitions() -> Value { ]) } -/// Most paragraphs `type_text` with `paragraphs: true` types in one call. -pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; - fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": id, "params": params})) } @@ -238,12 +238,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { // One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat, // and its refusal in a one-line control), but the lines go in as one edit and one // undo step, all or nothing, instead of a command (and a whole-document undo - // snapshot) per line. - let t = t.replace("\r\n", "\n").replace('\r', "\n"); - let lines = t.split(['\n', '\u{2029}']).count(); - if lines > MAX_TYPED_PARAGRAPHS { - return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); - } + // snapshot) per line. The engine splits the lines and holds them to its cap + // (`MAX_TYPED_PARAGRAPHS`), for this tool and every other caller alike. wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) diff --git a/docs/mcp.md b/docs/mcp.md index 99bf754d54..e0dde5f7d2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -112,7 +112,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines — the cap is `text.insert`'s own, so a control-channel or CLI call is held to it too) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | From 6f7da7345fc5cc70c2cea399b1791d63ebf60a7e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:12:56 +0000 Subject: [PATCH 34/87] W1: every way of opening a file goes through WordApp::open_file --- apps/wordcraft/src/apple_events.rs | 2 +- crates/ui-egui/src/backstage.rs | 4 +- crates/ui-egui/src/documents.rs | 16 ++++++++ crates/ui-egui/src/file_browser.rs | 7 ++-- crates/ui-egui/src/file_dialogs.rs | 11 +++--- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/windows.rs | 62 ++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 12 deletions(-) diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 64f534392d..0eae596a9d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -43,7 +43,7 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { match e { Event::Open(paths) => { for p in paths.iter().filter_map(|p| p.to_str()) { - match app.run("file.open", serde_json::json!({"path": p})) { + match app.open_file(p, None) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 771cd13768..8b3a5084ad 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -564,8 +564,8 @@ pub(crate) fn forget_save_as_error(ctx: &egui::Context) { /// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable) /// leaves File open so the list can say why, rather than closing it on a greyed-out window. -fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { - app.run("file.open", json!({"path": path}))?; +pub(crate) fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { + app.open_file(path, None)?; app.ui.backstage = false; app.canvas.want_focus = true; Ok(()) diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 601dd3c8f2..c843e19d2a 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -102,6 +102,22 @@ impl WordApp { seen } + /// Open the file at `path` for the window showing document `into` (`None`: the active + /// document's window). Every way of opening a file comes through here, so each follows the + /// same rule: an untouched blank document is replaced in its own window, else the file opens + /// in a window of its own (or its window comes forward if it is already open). A file a + /// dialog picked arrives while any window draws, so the document it was asked from is made + /// active again first. + pub fn open_file(&mut self, path: &str, into: Option) -> Result { + if let Some(id) = into + && id != self.session.document_id() + && self.session.is_open(id) + { + self.show_document(id)?; + } + self.run("file.open", json!({"path": path})) + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 502d6a3aa5..5c5765506e 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -286,7 +286,7 @@ impl WordApp { FileDialogRequest::Save { name: self.default_save_name(), dir: None }, AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, ), - _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), + _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }), }; self.show_file_browser(req, after, false); } @@ -529,7 +529,7 @@ mod tests { std::fs::create_dir(dir.join("drafts")).unwrap(); std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap(); let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }; - let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false); + let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.name = "drafts".into(); assert_eq!(b.accept(), Step::Stay); assert_eq!(b.dir, dir.join("drafts")); @@ -577,7 +577,8 @@ mod tests { fn an_empty_folder_says_there_are_no_items() { let dir = scratch("empty_state"); let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - let mut b = FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument, dir.clone(), false); + let mut b = + FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.navigate(dir.clone()); assert!(matches!(&b.listing, Ok(l) if l.entries.is_empty()), "the folder starts empty"); a.file_browser.open = Some(Box::new(b)); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 2ccb8530f1..7f375e8c1f 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -56,8 +56,8 @@ pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'stati /// What to do with the path picked in a dialog. pub(crate) enum AfterPick { - /// Open the picked document. - OpenDocument, + /// Open the picked document from the window showing `document` ([`WordApp::open_file`]). + OpenDocument { document: u64 }, /// Insert the picked picture, or replace the pending Change Picture target. Picture, /// Save the document there, then carry on. @@ -242,9 +242,9 @@ impl WordApp { /// Do what the dialog was for with the picked path (`None`: the user cancelled). pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option) -> Result { match after { - AfterPick::OpenDocument => { + AfterPick::OpenDocument { document } => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; - let r = self.run("file.open", json!({"path": path})); + let r = self.open_file(&path, Some(document)); self.ui.backstage = false; r } @@ -450,7 +450,8 @@ impl WordApp { f("document"); return; } - let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument); + let _ = + self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }); } pub(crate) fn pick_picture(&mut self) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb1af01b3f..971674b4ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2479,7 +2479,7 @@ impl WordApp { } else if wordcraft_fonts::is_font_file_name(&lp) { self.run("font.add", json!({"path": path})) } else { - self.run("file.open", json!({"path": path})) + self.open_file(&path, None) }; } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b83a5e207e..0031b2085b 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -1251,4 +1251,66 @@ mod tests { a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert!(a.quit_requested, "one prompt for the document"); } + + /// Two windows, the second (focused) on an untouched blank document: an app whose host + /// answers file dialogs only when the test does, plus the dialog's answer channel. + fn blank_beside_a_document() -> (egui::Context, WordApp, Shown, u64) { + let shown: Shown = std::rc::Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + let (tx, rx) = std::sync::mpsc::channel(); + log.borrow_mut().push(tx); + rx + })), + ..Default::default() + }; + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + frames(&ctx, &mut a, 2); + a.run("file.open", json!({"path": docx_on_disk("first", "First").to_string_lossy()})).unwrap(); + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert!(a.window_count() == 2 && a.session.is_untouched_blank()); + let blank = a.windows.shown(); + (ctx, a, shown, blank) + } + + type Shown = std::rc::Rc>>>; + + /// Each way of opening a file replaces an untouched blank document in its own window, even + /// when the file arrives while another window draws: File › Open, ⌘O and the File page's + /// Browse (an asynchronous dialog), the File page's and Start screen's recent list, and + /// Finder (between frames, in the focused window). + #[test] + fn every_open_replaces_an_untouched_blank_document_in_its_window() { + let second = docx_on_disk("second", "Second"); + let path = second.to_string_lossy().to_string(); + let opens: [(&str, fn(&mut WordApp, &egui::Context, &Shown, &str)); 3] = [ + ("dialog", |a, ctx, shown, path| { + a.run("ui.backstage", json!({"value": true, "page": "open"})).unwrap(); + a.run("ui.openFileDialog", json!({})).unwrap(); + frames(ctx, a, 2); + let tx = shown.borrow_mut().pop().unwrap(); + tx.send(Ok(vec![path.to_string()])).unwrap(); + frames(ctx, a, 3); + }), + ("recent", |a, ctx, _, path| { + crate::backstage::open_recent(a, path).unwrap(); + frames(ctx, a, 2); + }), + ("finder", |a, ctx, _, path| { + a.open_file(path, None).unwrap(); + frames(ctx, a, 2); + }), + ]; + for (how, open) in opens { + let (ctx, mut a, shown, blank) = blank_beside_a_document(); + open(&mut a, &ctx, &shown, &path); + assert_eq!(a.window_count(), 2, "{how}: no third window"); + assert!(a.enter_window(blank), "{how}: the blank document's window is still open"); + assert_eq!(text(&a), "Second", "{how}: it shows the opened file"); + } + std::fs::remove_dir_all(second.parent().unwrap()).ok(); + } } From 753c624a7d98732fb85cf09e3bedc87e4433d401 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:14:27 +0000 Subject: [PATCH 35/87] W1: the log says why an opened file didn't replace the active document --- crates/engine/src/cmd/file.rs | 10 +++++---- crates/engine/src/session/documents.rs | 28 ++++++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index db8ce49554..4e0b424a1a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -265,10 +265,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { { doc.styles.default_chr.lang = Some(lang.clone()); } - if s.is_untouched_blank() { - s.set_document(doc); - } else { - s.add_opened_document(doc); + match s.used_because() { + None => s.set_document(doc), + Some(why) => { + log::info!("file.open: opened beside document {}, not in its place: {why}", s.document_id()); + s.add_opened_document(doc); + } } if let Some(z) = s.doc.settings.zoom { super::view::restore_zoom(s, z); diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index c8570d426b..b07fb55477 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -529,14 +529,26 @@ impl Session { /// changed, nothing to undo or redo and no text. Opening a file replaces such a document /// rather than opening beside it. pub fn is_untouched_blank(&self) -> bool { - self.path.is_none() - && self.file_name.is_none() - && !self.dirty - && self.history.is_empty() - && self.redo.is_empty() - && self.rev == self.made_rev - && self.doc.paragraph_count() <= 1 - && self.doc.plain_text(StoryRef::Body).trim().is_empty() + self.used_because().is_none() + } + + /// Why the active document doesn't count as an untouched blank one, if it doesn't. + pub(crate) fn used_because(&self) -> Option<&'static str> { + if self.path.is_some() { + Some("it has a file") + } else if self.file_name.is_some() { + Some("it has a name") + } else if self.dirty { + Some("it has unsaved changes") + } else if !self.history.is_empty() || !self.redo.is_empty() { + Some("it has undo or redo steps") + } else if self.rev != self.made_rev { + Some("it changed since it was made") + } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { + Some("it has text") + } else { + None + } } /// Run `f` with document `id` active, then make the document that was active before active From 3db25e4aa948465f70f323aa64151ff1a7e062a3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:51:04 +0000 Subject: [PATCH 36/87] Tests from main: mailing results open beside the source document --- crates/engine/src/tests.rs | 9 ++++++--- crates/ui-egui/src/dialogs.rs | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f8db21aac..18f6861d37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8560,9 +8560,10 @@ fn a_rejected_comparison_keeps_the_current_versions() { assert_eq!(s.file_name.as_deref(), Some("original.docx")); } -/// A rejected mailing result must keep the source document's save target and versions. +/// A mailing result opens beside a protected source document, which keeps its save target, +/// versions, password and text. #[test] -fn a_rejected_mailing_result_keeps_the_current_file_identity() { +fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { use wordcraft_doc::{CharProps, InlineObject}; let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); @@ -8580,7 +8581,9 @@ fn a_rejected_mailing_result_keeps_the_current_file_identity() { s.password = Some(crate::io::Password::new("synthetic file password")); s.bib_style = "Chicago".into(); - assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err()); + s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); + assert_ne!(s.document_id(), before_id, "the envelope is a new document"); + s.activate(before_id).unwrap(); assert_eq!(s.doc, before); assert_eq!(s.versions.len(), 1); assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e028273c89..ef8dcd1ad1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4449,15 +4449,16 @@ mod tests { /// #1346: a check box the selected paragraphs differ in shows as neither on nor off; a click /// and OK turn it on in all of them, and the other fields they differ in stay as they were. - /// #3028: the records dialog refuses a range with no records itself, before Save Changes asks - /// about the unsaved document; a good range goes on to that question. + /// #3028: the records dialog refuses a range with no records itself; a good range merges into + /// a new document beside the unsaved one, asking nothing. #[test] - fn merge_records_dialog_checks_the_range_before_asking_to_save() { + fn merge_records_dialog_checks_the_range_before_merging() { use egui_kittest::kittest::Queryable; let mut app = app_with("Dear "); let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); let _ = app.run("text.insert", json!({"text": "unsaved"})); assert!(app.session.dirty); + let source = app.session.document_id(); app.dialog = Dialog::open("mergeRecords", &mut app); if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { *choice = 2; @@ -4491,8 +4492,10 @@ mod tests { for _ in 0..2 { h.step(); } - assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); - assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + assert!(h.state().dialog.is_none(), "nothing to ask about: the merge opens beside"); + assert_ne!(h.state().session.document_id(), source, "the merge is a new document"); + h.state_mut().run("view.switchWindows", json!({"id": source})).unwrap(); + assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "the unsaved document is still open"); } #[test] From 4c246f082aa7cb68d636c97c85cd9cd909dacf2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:11:30 +0000 Subject: [PATCH 37/87] Task panes: F6 stays in dialogs and runs user bindings (#1756) --- crates/ui-egui/src/content_controls.rs | 7 ++ crates/ui-egui/src/panes.rs | 99 +++++++++++++++++++++++++- crates/ui-egui/src/zotero.rs | 7 ++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/content_controls.rs b/crates/ui-egui/src/content_controls.rs index 2febf59634..186ad339d1 100644 --- a/crates/ui-egui/src/content_controls.rs +++ b/crates/ui-egui/src/content_controls.rs @@ -25,6 +25,13 @@ pub struct ControlUi { popup: Option, } +impl ControlUi { + /// Whether a control's list or calendar is open. + pub(crate) fn popup_open(&self) -> bool { + self.popup.is_some() + } +} + struct Popup { /// Content start of the control it belongs to. at: Pos, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 5c39b9d029..0de362de17 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -159,6 +159,39 @@ impl PaneContent for Content<'_> { } } +const HOST: &str = "task_panes"; + +/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays +/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an +/// F6 the user bound to a command runs that command instead. +fn route_f6(app: &WordApp, ctx: &egui::Context) { + let busy = app.dialog.is_some() + || app.file_dialog_open() + || app.colors.is_some() + || app.controls.popup_open() + || app.zotero.alert_open() + || app.ui.keytips != crate::keytips::Phase::Off + || egui::Popup::is_any_open(ctx); + if busy { + return; + } + let reg = &app.session.registry; + let bound = |m: egui::Modifiers| app.session.keymap.resolve(reg, &crate::keys::combo(m, "F6", true)).is_some(); + let mut cycle = None; + ctx.input_mut(|input| { + input.events.retain(|e| match e { + egui::Event::Key { key: egui::Key::F6, pressed: true, modifiers: m, .. } if !m.command && !m.ctrl && !m.mac_cmd && !bound(*m) => { + cycle = Some(m.shift); + false + } + _ => true, + }); + }); + if let Some(reverse) = cycle { + craft_ui::panes::focus_next(ctx, HOST, reverse); + } +} + /// Draw the open panes around the document. Call before the canvas's `CentralPanel`. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -166,9 +199,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !open.contains(&Pane::FormatShape) { crate::format_shape::forget_drag(ui.ctx()); } + route_f6(app, ui.ctx()); let styles = styles_state(app); - let out = PaneHost::new("task_panes") + let out = PaneHost::new(HOST) .policy(SidePolicy::Columns) + .f6(false) .labels(labels()) .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) .body_margin(egui::Margin::same(10)) @@ -1053,6 +1088,68 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); } + fn pane_harness(app: crate::WordApp) -> egui_kittest::Harness<'static, crate::WordApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h + } + + fn press(h: &mut egui_kittest::Harness<'static, crate::WordApp>, key: egui::Key, modifiers: egui::Modifiers) { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + + /// F6 and Shift+F6 with a dialog open leave the keyboard in the dialog: no pane title bar and + /// no document behind it takes it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_in_an_open_dialog_keeps_focus_there() { + for panes in [true, false] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": panes})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "replace"})).unwrap(); + let mut h = pane_harness(app); + let focused = h.ctx.memory(|m| m.focused()); + for shift in [false, true] { + press(&mut h, egui::Key::F6, if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }); + assert_eq!(h.ctx.memory(|m| m.focused()), focused, "panes open: {panes}, shift: {shift}"); + assert!(!h.state().canvas.focused && h.state().dialog.is_some(), "panes open: {panes}, shift: {shift}"); + } + } + } + + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only + /// Shift+F6 is bound. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_user_bound_f6_runs_its_command() { + for key in ["F6", "Shift+F6"] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&serde_json::json!({"assigned": {key: "view.focus"}})); + let mut h = pane_harness(app); + assert!(h.state().canvas.focused); + let m = if key == "F6" { egui::Modifiers::NONE } else { egui::Modifiers::SHIFT }; + press(&mut h, egui::Key::F6, m); + assert!(h.state().session.view.focus_mode, "{key} ran its command"); + press(&mut h, egui::Key::F6, m); + assert!(!h.state().session.view.focus_mode, "{key} ran its command again"); + if key == "Shift+F6" { + h.state_mut().canvas.want_focus = true; + h.run_steps(2); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused, "plain F6 moved the keyboard to the pane"); + } + } + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b2..97ee45cde9 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -46,6 +46,13 @@ pub struct ZoteroLink { methods: Vec, } +impl ZoteroLink { + /// Whether an alert from Zotero is waiting for an answer. + pub(crate) fn alert_open(&self) -> bool { + self.alert.is_some() + } +} + /// Focus requests from the bridge. struct AppHost<'a> { ctx: &'a egui::Context, From 8e0353ae41b7d68a06c85f5c1f5d746512823fa7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:12:55 +0000 Subject: [PATCH 38/87] Task panes: shortcuts run while a pane title bar has focus (#1756) --- crates/ui-egui/src/keys.rs | 5 +++-- crates/ui-egui/src/panes.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index abf9513909..68541737b5 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -705,10 +705,11 @@ fn is_global_shortcut(key: Key, m: Modifiers) -> bool { m.command || m.alt || is_function_key(key) } -/// Command shortcuts when the canvas isn't focused but no text field is either. +/// Command shortcuts when the canvas isn't focused and no text field is: nothing has the +/// keyboard, or a button or a pane's title bar does. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. - if app.canvas.focused || ctx.egui_wants_keyboard_input() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { + if app.canvas.focused || ctx.text_edit_focused() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { return; } let events = ctx.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 0de362de17..5533753772 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1125,6 +1125,28 @@ mod tests { } } + /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's + /// text field keeps Ctrl+H for itself. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_run_while_a_pane_title_bar_has_focus() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused && h.ctx.memory(|m| m.focused()).is_some(), "the title bar has the keyboard"); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("replace"), "Ctrl+H opened Replace"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().dialog.is_none()); + + h.state_mut().nav_search_focus = true; + h.run_steps(2); + assert!(h.ctx.text_edit_focused()); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert!(h.state().dialog.is_none(), "the search field keeps Ctrl+H"); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From 6fac5994f75187e45fa8db5f831bba480b5283e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:13:42 +0000 Subject: [PATCH 39/87] Task panes: bump only craft-ui; craft-print, craft-pdf and craft-i18n stay at main's revs (#1756) --- Cargo.lock | 1556 ++--------------------------------- Cargo.toml | 4 +- apps/wordcraft/Cargo.toml | 2 +- apps/wordcraft/src/print.rs | 2 +- crates/ui-egui/Cargo.toml | 4 +- 5 files changed, 59 insertions(+), 1509 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8eb446a43f..1510c03264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", ] [[package]] @@ -452,48 +452,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.8.0" @@ -536,15 +500,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.1" @@ -738,17 +693,6 @@ dependencies = [ "libc", ] -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "chardetng" version = "1.0.0" @@ -766,12 +710,7 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "iana-time-zone", - "js-sys", "num-traits", - "serde", - "wasm-bindgen", - "windows-link", ] [[package]] @@ -780,7 +719,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] @@ -793,54 +732,12 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "cmpv2" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" -dependencies = [ - "crmf", - "der", - "spki", - "x509-cert", -] - -[[package]] -name = "cms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" -dependencies = [ - "const-oid 0.9.6", - "der", - "spki", - "x509-cert", -] - -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.21", -] - [[package]] name = "codespan-reporting" version = "0.13.1" @@ -896,12 +793,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -918,16 +809,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -941,7 +822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -954,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -970,15 +851,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.1" @@ -991,30 +863,18 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4" dependencies = [ - "fixed_decimal", - "icu_decimal", - "icu_locale_core", - "icu_locale_fallback", - "icu_normalizer", - "icu_plurals", - "icu_properties", - "icu_provider", - "icu_provider_adapters", - "icu_provider_blob", + "intl_pluralrules", "log", - "objc2-foundation 0.3.2", "sys-locale", - "unicode-bidi", - "windows-registry", - "writeable", + "unic-langid", ] [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,14 +883,9 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" dependencies = [ - "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", "tempfile", - "winprint", - "winsafe", ] [[package]] @@ -1045,14 +900,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "craft-winprint-gdi" -version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "crc32fast" version = "1.5.2" @@ -1062,18 +909,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crmf" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" -dependencies = [ - "cms", - "der", - "spki", - "x509-cert", -] - [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1114,16 +949,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" @@ -1148,30 +973,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "der_derive", - "flagset", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1196,25 +997,15 @@ dependencies = [ "rayon", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", + "block-buffer", + "const-oid", + "crypto-common", "ctutils", ] @@ -1277,12 +1068,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecolor" version = "0.36.2" @@ -1596,45 +1381,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fixed_decimal" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.10" @@ -1655,28 +1413,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "fmt-derive" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" -dependencies = [ - "fmt-derive-proc", -] - -[[package]] -name = "fmt-derive-proc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" -dependencies = [ - "proc-macro-crate", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1760,22 +1496,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -1812,12 +1532,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - [[package]] name = "futures-task" version = "0.3.34" @@ -1831,25 +1545,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -1869,19 +1570,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -1903,11 +1591,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -2065,25 +1750,6 @@ dependencies = [ "euclid", ] -[[package]] -name = "h2" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -2232,12 +1898,6 @@ dependencies = [ "zune-jpeg", ] -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" version = "0.5.3" @@ -2256,48 +1916,9 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest", ] -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "hybrid-array" version = "0.4.15" @@ -2307,92 +1928,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" -dependencies = [ - "base64 0.23.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.3.0" @@ -2407,23 +1942,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_decimal" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_locale_core", - "icu_pattern", - "icu_plurals", - "icu_provider", - "serde", - "writeable", - "zerovec", -] - [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2432,32 +1950,11 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", "writeable", "zerovec", ] -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2469,9 +1966,6 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] @@ -2481,32 +1975,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" -[[package]] -name = "icu_pattern" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" -dependencies = [ - "displaydoc", - "either", - "serde", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_plurals" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_provider", - "serde", - "zerovec", -] - [[package]] name = "icu_properties" version = "2.3.0" @@ -2536,9 +2004,6 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", - "postcard", - "serde", - "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2546,30 +2011,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_provider_adapters" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" -dependencies = [ - "icu_locale_fallback", - "icu_provider", -] - -[[package]] -name = "icu_provider_blob" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" -dependencies = [ - "icu_provider", - "postcard", - "serde", - "writeable", - "zerotrie", - "zerovec", -] - [[package]] name = "idna" version = "1.1.0" @@ -2646,10 +2087,13 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.12.2" +name = "intl_pluralrules" +version = "7.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] [[package]] name = "is-docker" @@ -2794,9 +2238,9 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ - "base64 0.22.1", + "base64", "bumpalo", "flate2", "float-cmp", @@ -2893,9 +2337,6 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -dependencies = [ - "serde_core", -] [[package]] name = "litrs" @@ -2918,12 +2359,6 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "lru-slab" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" - [[package]] name = "memchr" version = "2.8.3" @@ -2948,12 +2383,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2974,17 +2403,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -3443,12 +2861,6 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "orbclient" version = "0.3.55" @@ -3538,25 +2950,6 @@ dependencies = [ "ryu", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "peniko" version = "0.6.1" @@ -3742,24 +3135,12 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "serde_core", - "writeable", "zerovec", ] @@ -3787,30 +3168,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -3836,7 +3193,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", - "rand 0.9.5", + "rand", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3872,63 +3229,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.21", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.3", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.21", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.47" @@ -3957,18 +3257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -3978,15 +3267,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.5" @@ -3996,28 +3279,13 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4169,49 +3437,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" -[[package]] -name = "reqwest" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" -dependencies = [ - "base64 0.23.1", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "rfd" version = "0.17.2" @@ -4239,26 +3464,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -4306,81 +3511,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -4405,12 +3535,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "ryu-js" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" - [[package]] name = "same-file" version = "1.0.6" @@ -4420,15 +3544,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -4441,29 +3556,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "self_cell" version = "1.3.0" @@ -4519,17 +3611,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_json_canonicalizer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" -dependencies = [ - "ryu-js", - "serde", - "serde_json", -] - [[package]] name = "serde_repr" version = "0.1.21" @@ -4550,29 +3631,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -4580,19 +3638,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] @@ -4602,8 +3649,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4622,175 +3669,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "sigstore-bundle" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" -dependencies = [ - "base64 0.22.1", - "hex", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-rekor", - "sigstore-tsa", - "sigstore-types", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-crypto" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "const-oid 0.9.6", - "der", - "digest 0.10.7", - "pem", - "rand_core 0.10.1", - "sha2 0.10.9", - "signature", - "sigstore-types", - "spki", - "thiserror 2.0.21", - "tracing", - "x509-cert", -] - -[[package]] -name = "sigstore-merkle" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" -dependencies = [ - "base64 0.22.1", - "hex", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-rekor" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" -dependencies = [ - "base64 0.22.1", - "hex", - "reqwest", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-types", - "thiserror 2.0.21", - "url", -] - -[[package]] -name = "sigstore-trust-root" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "rustls-pki-types", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", - "x509-cert", -] - -[[package]] -name = "sigstore-tsa" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "chrono", - "cmpv2", - "cms", - "const-oid 0.9.6", - "der", - "hex", - "rand 0.10.3", - "reqwest", - "rustls-pki-types", - "rustls-webpki", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", - "tracing", - "x509-cert", - "x509-tsp", -] - -[[package]] -name = "sigstore-types" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "pem", - "serde", - "serde_json", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-verify" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" -dependencies = [ - "base64 0.22.1", - "chrono", - "cms", - "const-oid 0.9.6", - "hex", - "pem", - "rustls-pki-types", - "rustls-webpki", - "serde", - "serde_json", - "serde_json_canonicalizer", - "sigstore-bundle", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-rekor", - "sigstore-trust-root", - "sigstore-tsa", - "sigstore-types", - "thiserror 2.0.21", - "tls_codec", - "tracing", - "x509-cert", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -4945,16 +3823,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4964,16 +3832,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4992,27 +3850,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "subsetter" version = "0.2.6" @@ -5025,22 +3862,6 @@ dependencies = [ "write-fonts", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -5063,15 +3884,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.14.0" @@ -5095,38 +3907,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -5240,63 +4020,13 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tokio" version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "bytes", - "libc", - "mio", "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", ] [[package]] @@ -5350,51 +4080,6 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.13.2", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -5438,12 +4123,6 @@ dependencies = [ "petgraph", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "type-map" version = "0.5.1" @@ -5476,6 +4155,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5527,18 +4224,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -5551,12 +4236,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5659,21 +4338,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5910,15 +4574,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weezl" version = "0.1.12" @@ -6200,17 +4855,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -6418,7 +5062,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", @@ -6463,27 +5107,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winprint" -version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" -dependencies = [ - "flate2", - "fmt-derive", - "reqwest", - "scopeguard", - "sigstore-trust-root", - "sigstore-types", - "sigstore-verify", - "strum", - "tar", - "thiserror 2.0.21", - "windows", - "windows-core", - "windows-implement", - "xml", -] - [[package]] name = "winresource" version = "0.1.31" @@ -6494,12 +5117,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "winsafe" -version = "0.0.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -6579,7 +5196,7 @@ version = "0.10.0" name = "wordcraft-doc" version = "0.10.0" dependencies = [ - "base64 0.22.1", + "base64", "proptest", "serde", "serde_json", @@ -6599,7 +5216,7 @@ dependencies = [ "miniz_oxide 0.8.9", "proptest", "serde_json", - "sha1 0.11.0", + "sha1", "thiserror 2.0.21", "wordcraft-doc", "wordcraft-geom", @@ -6610,7 +5227,7 @@ name = "wordcraft-docx" version = "0.10.0" dependencies = [ "aes", - "base64 0.22.1", + "base64", "cfb", "chrono", "getrandom 0.3.4", @@ -6619,8 +5236,8 @@ dependencies = [ "log", "proptest", "quick-xml", - "sha1 0.11.0", - "sha2 0.11.0", + "sha1", + "sha2", "thiserror 2.0.21", "unicode-bidi", "wordcraft-doc", @@ -6856,20 +5473,11 @@ dependencies = [ "read-fonts 0.39.2", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -dependencies = [ - "either", -] [[package]] name = "wuff" @@ -6914,41 +5522,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-cert" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" -dependencies = [ - "const-oid 0.9.6", - "der", - "sha1 0.10.7", - "signature", - "spki", - "tls_codec", -] - -[[package]] -name = "x509-tsp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" -dependencies = [ - "cmpv2", - "cms", - "der", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.5", -] - [[package]] name = "xcursor" version = "0.3.11" @@ -6974,12 +5547,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" - [[package]] name = "xml-rs" version = "0.8.29" @@ -7174,20 +5741,6 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" @@ -7196,11 +5749,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", - "litemap", - "serde_core", "yoke", "zerofrom", - "zerovec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 713dcaafaa..8861fcd088 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f51439e27a..dbad7e93f3 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index b92e68b3e0..22d0c64dc4 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -7,7 +7,7 @@ pub fn discover() -> Discover { let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { let printers = craft_print::printers() .into_iter() - .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) + .map(|printer| Printer { name: printer.name, label: String::new(), default: printer.default }) .collect(); let _ = sender.send(printers); }); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d5e8885720..ede5665a62 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,8 +12,8 @@ workspace = true craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in -# `craft-i18n.rev`). -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247). +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From f06a1d3ba528e364dbf510fc9ad2cb00afc757c1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:17:53 +0000 Subject: [PATCH 40/87] Task panes: rows and fields fit a 200 pt pane (#1756) --- crates/ui-egui/src/dialogs_review.rs | 26 +++++----- crates/ui-egui/src/panes.rs | 71 ++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 69861a3415..460c46f5ad 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -609,7 +609,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { if st.formatting && st.show_styles { let allowed = st.allowed.get_or_insert_with(Vec::new); ui.label(egui::RichText::new(tl!("Checked styles are allowed:")).small()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.small_button(tl!("All")).clicked() { *allowed = styles.iter().map(|(id, _)| id.clone()).collect(); } @@ -634,7 +634,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); ui.add_enabled_ui(st.editing, |ui| { let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or(""); - egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(220.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { for (i, (_, label)) in EDIT_MODES.iter().enumerate() { ui.selectable_value(&mut st.mode, i, tl!(label)); } @@ -665,7 +665,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { } else { ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small()); passwords(ui, st, true); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let mut cancel = mac && ui.button(tl!("Cancel")).clicked(); @@ -703,17 +703,15 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { error(ui, &st.message); } +/// Each label above its field, so both fit the narrowest pane. fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) { - egui::Grid::new("restrict_pw").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { - ui.label(tl!("Password:")); - ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - if confirm { - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - } - }); + let width = ui.available_width().min(220.0); + ui.label(tl!("Password:")); + ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)); + if confirm { + ui.label(tl!("Confirm password:")); + ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)); + } } fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str) { @@ -753,7 +751,7 @@ fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str } else { ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small()); passwords(ui, st, false); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let cancel_first = mac && ui.button(tl!("Cancel")).clicked(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 5533753772..979bcb1fc3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -272,7 +272,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { } } ui.add_space(4.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] { if ui.selectable_label(app.ui.nav_tab == k, tl!(l)).clicked() { app.ui.nav_tab = k.into(); @@ -435,7 +435,7 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Op /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { let _ = app.run("edit.pasteAllClipboard", json!({})); app.canvas.want_focus = true; @@ -1017,7 +1017,9 @@ fn camel_words(s: &str) -> String { #[cfg(test)] mod tests { - use super::comment_stamp; + use super::{Content, Pane, comment_stamp}; + use craft_ui::panes::PaneContent; + use egui::vec2; #[test] fn comment_stamp_keeps_date_and_time() { @@ -1172,6 +1174,69 @@ mod tests { } } + /// Every pane's body fits a pane at the 200 pt minimum width (the host clips what doesn't): + /// button rows wrap and fields narrow. Restrict Editing shows its style list and password + /// fields. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn pane_bodies_fit_the_narrowest_pane() { + // 200 pt less the 10 pt body margins and the frame's 1 pt strokes. + const BODY: f32 = 178.0; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + app.run("select.all", serde_json::json!({})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.restrict = crate::dialogs_review::RestrictPane { + open: true, + formatting: true, + show_styles: true, + editing: true, + asking: true, + ..Default::default() + }; + let panes = [ + Pane::Navigation, + Pane::Clipboard, + Pane::Styles, + Pane::StyleInspector, + Pane::Accessibility, + Pane::AltText, + Pane::FormatShape, + Pane::RestrictEditing, + Pane::Comments, + ]; + let widths = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let seen = widths.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 1200.0)).build_ui_state( + move |ui, app: &mut crate::WordApp| { + // Fonts take effect the frame after they are installed. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + let mut row = Vec::new(); + for pane in panes { + let width = ui + .allocate_ui_with_layout(vec2(BODY, 1200.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_max_width(BODY); + Content { app: &mut *app }.body(ui, &pane); + ui.min_rect().width() + }) + .inner; + row.push((pane, width)); + } + *seen.borrow_mut() = row; + }, + app, + ); + h.run_steps(3); + let widths = widths.borrow(); + assert_eq!(widths.len(), panes.len()); + let wide: Vec<_> = widths.iter().filter(|(_, width)| *width > BODY + 0.5).collect(); + assert!(wide.is_empty(), "wider than the pane: {wide:?}"); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] From 401d811fa0789b66391473e65749d296a443898e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:20:20 +0000 Subject: [PATCH 41/87] Task panes: every pane keeps its dock or float place between runs (#1756) --- crates/ui-egui/src/lib.rs | 9 ++- crates/ui-egui/src/panes.rs | 157 +++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50ed89a7c9..0555e9f78a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -286,6 +286,9 @@ pub struct UiState { /// The docked Styles pane's width this run; like the other panes' widths, not kept between runs. #[serde(skip)] pub styles_pane_width: Option, + /// Where each of the other task panes sits, by [`panes::Pane::key`] (#1756). + #[serde(deserialize_with = "panes::lenient_places")] + pub pane_places: std::collections::BTreeMap, /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet, @@ -406,6 +409,7 @@ impl Default for UiState { styles_pane_dock: wordcraft_engine::StylesPaneDock::Right, styles_pane_float: None, styles_pane_width: None, + pane_places: Default::default(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), @@ -506,10 +510,7 @@ fn lenient_styles_pane_dock<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result, D::Error> { - let v = Value::deserialize(d)?; - let Some([x, y, w, h]) = v.as_array().map(Vec::as_slice) else { return Ok(None) }; - let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); - Ok(n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h])) + Ok(panes::float_rect(&Value::deserialize(d)?)) } /// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 979bcb1fc3..5557560a0f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -65,6 +65,35 @@ impl Pane { PaneState::docked(edge, width) } + /// The pane's key in `ui.json`'s `panePlaces`. + pub fn key(self) -> &'static str { + match self { + Self::SmartArtText => "smartArtText", + Self::Navigation => "navigation", + Self::Clipboard => "clipboard", + Self::Styles => "styles", + Self::StyleInspector => "styleInspector", + Self::Accessibility => "accessibility", + Self::AltText => "altText", + Self::FormatShape => "formatShape", + Self::RestrictEditing => "restrictEditing", + Self::Comments => "comments", + } + } + + const ALL: [Self; 10] = [ + Self::SmartArtText, + Self::Navigation, + Self::Clipboard, + Self::Styles, + Self::StyleInspector, + Self::Accessibility, + Self::AltText, + Self::FormatShape, + Self::RestrictEditing, + Self::Comments, + ]; + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. fn close_command(self) -> Option<&'static str> { match self { @@ -103,8 +132,77 @@ pub fn open(app: &WordApp) -> Vec { .collect() } +/// Where a task pane other than Styles sits, kept in `ui.json` (#1756). Styles keeps its place in +/// `view.stylesPaneDock` and `stylesPaneFloat`. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SavedPane { + /// `None`: the pane's default edge. + #[serde(skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + #[serde(skip_serializing_if = "Option::is_none")] + pub float: Option<[f32; 4]>, + /// The docked width this run; like the Styles pane's, not kept between runs. + #[serde(skip)] + pub width: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SavedDock { + Left, + Right, + Float, +} + +/// `panePlaces` as saved: unknown panes, places and rects are dropped, not the whole file. +pub(crate) fn lenient_places<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let Some(map) = v.as_object() else { return Ok(BTreeMap::new()) }; + let dock = |v: &Value| match v.as_str() { + Some("left") => Some(SavedDock::Left), + Some("right") => Some(SavedDock::Right), + Some("float") => Some(SavedDock::Float), + _ => None, + }; + Ok(Pane::ALL + .iter() + .filter(|pane| **pane != Pane::Styles) + .filter_map(|pane| { + let saved = map.get(pane.key())?; + let place = SavedPane { dock: saved.get("dock").and_then(dock), float: saved.get("float").and_then(float_rect), width: None }; + Some((pane.key().to_string(), place)) + }) + .collect()) +} + +/// Four finite numbers as `[x, y, width, height]`; `craft_ui::panes` brings a saved rect back +/// inside the window. +pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> { + let [x, y, w, h] = v.as_array().map(Vec::as_slice)? else { return None }; + let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); + n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h]) +} + +/// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the +/// others' from `panePlaces`. +fn pane_state(app: &WordApp, pane: Pane) -> PaneState { + if pane == Pane::Styles { + return styles_state(app); + } + let default = pane.default_state(); + let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default }; + let place = match saved.dock { + Some(SavedDock::Left) => Place::Docked(Edge::Start), + Some(SavedDock::Right) => Place::Docked(Edge::End), + Some(SavedDock::Float) => Place::Floating, + None => default.place, + }; + PaneState { place, size: saved.width.or(default.size), float_rect: saved.float } +} + /// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). -/// Other panes keep theirs in the host for the session. fn styles_state(app: &WordApp) -> PaneState { let place = match app.session.view.styles_pane_dock { StylesPaneDock::Right => Place::Docked(Edge::End), @@ -200,14 +298,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::format_shape::forget_drag(ui.ctx()); } route_f6(app, ui.ctx()); - let styles = styles_state(app); + let states: Vec<(Pane, PaneState)> = open.iter().map(|pane| (*pane, pane_state(app, *pane))).collect(); let out = PaneHost::new(HOST) .policy(SidePolicy::Columns) .f6(false) .labels(labels()) .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) .body_margin(egui::Margin::same(10)) - .show(ui, &open, |pane| (*pane == Pane::Styles).then_some(styles), &mut Content { app }); + .show(ui, &open, |pane| states.iter().find(|(p, _)| p == pane).map(|(_, state)| *state), &mut Content { app }); for request in out.requests { apply(app, request); } @@ -231,7 +329,18 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width), PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), - PaneRequest::Place(..) | PaneRequest::Resize(..) | PaneRequest::FloatRect(..) | PaneRequest::Focus(_) => {} + PaneRequest::Place(pane, place) => { + let dock = match place { + Place::Docked(Edge::Start) => SavedDock::Left, + Place::Docked(Edge::End) => SavedDock::Right, + Place::Floating => SavedDock::Float, + Place::Docked(Edge::Bottom) => return, + }; + app.ui.pane_places.entry(pane.key().into()).or_default().dock = Some(dock); + } + PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), + PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), + PaneRequest::Focus(_) => {} } } @@ -1237,6 +1346,46 @@ mod tests { assert!(wide.is_empty(), "wider than the pane: {wide:?}"); } + /// A pane other than Styles floated from its menu comes back floating, at the same rect, after + /// a restart; junk in `panePlaces` reads as the defaults (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_floated_pane_floats_again_after_a_restart() { + use craft_ui::panes::Place; + use egui_kittest::kittest::Queryable; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let bar = h.get_by_label_contains("Move Pane: ").rect(); + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom(); + h.get_all_by_label("Pane Options").find(|n| on_row(n.rect())).expect("the menu button beside the title bar").click(); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, Place::Floating); + let rect = h.state().ui.pane_places.get("navigation").and_then(|p| p.float).expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + let state = super::pane_state(&next, Pane::Navigation); + assert_eq!((state.place, state.float_rect), (Place::Floating, Some(rect))); + assert_eq!(super::pane_state(&next, Pane::Clipboard), Pane::Clipboard.default_state(), "other panes keep their defaults"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs( + serde_json::from_str( + r#"{"panePlaces": {"navigation": {"dock": "top", "float": [1, "x", 3, 4]}, "clipboard": 7, "nope": {}}, "navPane": true}"#, + ) + .unwrap(), + ); + assert_eq!(super::pane_state(&junk, Pane::Navigation), Pane::Navigation.default_state(), "junk reads as the default"); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + let mut odd = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + odd.apply_prefs(serde_json::from_str(r#"{"panePlaces": [1, 2], "navPane": true}"#).unwrap()); + assert!(odd.ui.pane_places.is_empty() && odd.session.view.nav_pane); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] From 01e32ff8635c49c398764f9827754b5ffaafa89d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:41:02 +0000 Subject: [PATCH 42/87] Pin craft-libs at 6e7f285 for craft-ui docking, craft-i18n, craft-print, craft-pdf and krilla --- Cargo.lock | 10 +++++----- Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ead7e7959..9c9022b455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 7dc0744b0f..1398af5fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index e255bafc5a..00054b687c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 416e0c58d6..6f63941a97 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in -# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing +# `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 7f236c3ec582d895bcf25f378cd6443b156fc00c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:41:36 +0000 Subject: [PATCH 43/87] Print pane: show each printer's spooler description as its label --- apps/wordcraft/src/print.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index 58eb23286e..b8947a61e7 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -7,7 +7,7 @@ pub fn discover() -> Discover { let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { let printers = craft_print::printers() .into_iter() - .map(|printer| Printer { name: printer.name, label: String::new(), default: printer.default }) + .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) .collect(); let _ = sender.send(printers); }); From 4f071a5b881d326f039287eff5e157e79f6f234c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:23:51 +0000 Subject: [PATCH 44/87] Shortcuts: a focused dialog button keeps Ctrl+Z and other shortcuts from the document --- crates/ui-egui/src/keys.rs | 9 ++++++-- crates/ui-egui/src/panes.rs | 41 ++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 68541737b5..431391b863 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -706,10 +706,15 @@ fn is_global_shortcut(key: Key, m: Modifiers) -> bool { } /// Command shortcuts when the canvas isn't focused and no text field is: nothing has the -/// keyboard, or a button or a pane's title bar does. +/// keyboard, or a button or a pane's title bar does. A focused widget in an open dialog or popup +/// keeps the keys, so Ctrl+Z on a dialog button can't undo the document behind it. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. - if app.canvas.focused || ctx.text_edit_focused() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { + if app.canvas.focused + || ctx.text_edit_focused() + || (ctx.egui_wants_keyboard_input() && crate::panes::overlay_open(app, ctx)) + || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) + { return; } let events = ctx.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f8500daade..62963bf443 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -259,18 +259,23 @@ impl PaneContent for Content<'_> { const HOST: &str = "task_panes"; -/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays -/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an -/// F6 the user bound to a command runs that command instead. -fn route_f6(app: &WordApp, ctx: &egui::Context) { - let busy = app.dialog.is_some() +/// Whether a dialog, file browser, colors dialog, content-control popup, Zotero alert, keytips +/// or egui popup is open: the keyboard belongs to it, not to the panes or the document. +pub(crate) fn overlay_open(app: &WordApp, ctx: &egui::Context) -> bool { + app.dialog.is_some() || app.file_dialog_open() || app.colors.is_some() || app.controls.popup_open() || app.zotero.alert_open() || app.ui.keytips != crate::keytips::Phase::Off - || egui::Popup::is_any_open(ctx); - if busy { + || egui::Popup::is_any_open(ctx) +} + +/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays +/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an +/// F6 the user bound to a command runs that command instead. +fn route_f6(app: &WordApp, ctx: &egui::Context) { + if overlay_open(app, ctx) { return; } let reg = &app.session.registry; @@ -1247,6 +1252,28 @@ mod tests { assert!(h.state().dialog.is_none(), "the search field keeps Ctrl+H"); } + /// A dialog button with the keyboard keeps Ctrl+Z away from the document behind the dialog. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_stay_out_of_the_document_while_a_dialog_button_has_focus() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "wordCount"})).unwrap(); + let mut h = pane_harness(app); + // Two buttons are labelled Close (the dialog's title bar and its own row); either will do. + if let Some(close) = h.query_all_by_label("Close").next() { + close.focus(); + } + h.run_steps(2); + assert!(!h.state().canvas.focused, "the document gave up the keyboard"); + assert!(!h.ctx.text_edit_focused() && h.ctx.memory(|m| m.focused()).is_some(), "Close has the keyboard"); + press(&mut h, egui::Key::Z, egui::Modifiers::COMMAND); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "kept", "Ctrl+Z left the document alone"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("wordCount")); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From f501f7cb5f6f375e4d23d0f0a625b1effe702b62 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:33:00 +0000 Subject: [PATCH 45/87] Task panes: Dock Left, Dock Right and Float run ui.paneDock for every pane but Styles --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panes.rs | 68 ++++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f44e8c0e0..587ffed6e7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1660,6 +1660,7 @@ impl WordApp { self.canvas.want_focus = true; json!({"pending": "contextMenu"}) } + "ui.paneDock" => return Some(panes::dock_command(self, p)), "ui.collapseRibbon" => { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; json!({"collapsed": self.ui.ribbon_collapsed}) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 62963bf443..12120265bb 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -336,12 +336,12 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), PaneRequest::Place(pane, place) => { let dock = match place { - Place::Docked(Edge::Start) => SavedDock::Left, - Place::Docked(Edge::End) => SavedDock::Right, - Place::Floating => SavedDock::Float, + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", Place::Docked(Edge::Bottom) => return, }; - app.ui.pane_places.entry(pane.key().into()).or_default().dock = Some(dock); + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "dock": dock})); } PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), @@ -349,6 +349,37 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } } +/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float"}` (#1756): docks or floats a +/// task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` it reads the +/// pane's place. `pane` is the pane's `panePlaces` key; Styles goes through `view.stylesPaneDock`. +pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result { + let key = p.get("pane").and_then(Value::as_str).ok_or("`pane` must be a task pane's key, such as \"navigation\"")?; + let pane = Pane::ALL.into_iter().find(|pane| pane.key() == key).ok_or_else(|| format!("unknown task pane `{key}`"))?; + let dock = match p.get("dock") { + None => None, + Some(v) => Some(match v.as_str() { + Some("left") => SavedDock::Left, + Some("right") => SavedDock::Right, + Some("float") => SavedDock::Float, + _ => return Err("`dock` must be left, right or float".into()), + }), + }; + if pane == Pane::Styles { + let params = p.get("dock").map_or_else(|| json!({}), |d| json!({"dock": d})); + let result = app.run("view.stylesPaneDock", params)?; + return Ok(json!({"pane": key, "dock": result.get("dock")})); + } + if let Some(dock) = dock { + app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock); + } + let name = match pane_state(app, pane).place { + Place::Docked(Edge::Start) => "left", + Place::Floating => "float", + Place::Docked(_) => "right", + }; + Ok(json!({"pane": key, "dock": name})) +} + fn close(app: &mut WordApp, pane: Pane) { match pane.close_command() { Some(id) => { @@ -1402,6 +1433,35 @@ mod tests { assert!(odd.ui.pane_places.is_empty() && odd.session.view.nav_pane); } + /// `ui.paneDock` docks or floats any pane and reads its place, as the pane's menu does + /// (#1756); Styles goes through `view.stylesPaneDock`; junk is refused and changes nothing. + #[test] + fn pane_dock_command_docks_floats_and_reads_each_pane() { + use craft_ui::panes::{Edge, Place}; + use serde_json::json; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap(), json!({"pane": "navigation", "dock": "left"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "comments"})).unwrap(), json!({"pane": "comments", "dock": "right"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "float"})).unwrap()["dock"], "float"); + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating); + app.run("ui.paneDock", json!({"pane": "comments", "dock": "left"})).unwrap(); + assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::Start)); + assert_eq!(app.run("ui.paneDock", json!({"pane": "styles", "dock": "float"})).unwrap(), json!({"pane": "styles", "dock": "float"})); + assert_eq!(app.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert!(!app.ui.pane_places.contains_key("styles"), "Styles keeps its place in the view state"); + for junk in [ + json!({}), + json!({"pane": "nope", "dock": "left"}), + json!({"pane": 3}), + json!({"pane": "navigation", "dock": "top"}), + json!({"pane": "navigation", "dock": 1}), + json!({"pane": "navigation", "dock": null}), + ] { + assert!(app.run("ui.paneDock", junk.clone()).is_err(), "{junk}"); + } + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing"); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c62c3d91..f0adffa3b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,7 +52,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | 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.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)…) | +| `engine.execute` | `command`, `params` | 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.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"}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | 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 | From 9129d713ca832a3efa46e95de377dbc8dfa86212 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:14:31 +0000 Subject: [PATCH 46/87] Escape during a task pane drag cancels the drag and stays in full screen --- crates/ui-egui/src/keys.rs | 11 ++++++++--- crates/ui-egui/src/panes.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 431391b863..71542f8ab4 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -465,7 +465,7 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } continue; } - if leave_full_screen(app, key, modifiers, true) { + if leave_full_screen(app, ctx, key, modifiers, true) { continue; } app.canvas.swallow_text = dispatch(app, key, modifiers) && modifiers.alt; @@ -479,12 +479,17 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { /// took it: a pen, a text box, a header, a dialog, a menu or a popup, a drag on the page, and in /// the document (`canvas`) a selection, which Escape collapses first (`select.collapse`), as in a /// normal window. -fn leave_full_screen(app: &mut WordApp, key: Key, m: Modifiers, canvas: bool) -> bool { +fn leave_full_screen(app: &mut WordApp, ctx: &egui::Context, key: Key, m: Modifiers, canvas: bool) -> bool { let screen = crate::chrome::Screen::of(&app.session.view).exit_command(); let Some(id) = screen.or(app.session.view.full_screen.then_some("view.fullScreen")) else { return false }; if key != Key::Escape || !m.is_none() || !app.escape_free { return false; } + // Escape with a button held (dragging a task pane's title bar, say) cancels the drag only. + // egui has already dropped the drag itself, so the held button is what is left of it. + if ctx.input(|i| i.pointer.any_down()) { + return false; + } let c = &app.canvas; if c.obj_drag.is_some() || c.table_resize.is_some() || c.table_drag.is_some() || c.link_from.is_some() { return false; @@ -720,7 +725,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - if leave_full_screen(app, key, modifiers, false) { + if leave_full_screen(app, ctx, key, modifiers, false) { continue; } if is_global_shortcut(key, modifiers) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 12120265bb..5274f083db 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1305,6 +1305,34 @@ mod tests { assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("wordCount")); } + /// Escape during a pane's title-bar drag cancels the drag and leaves full screen on. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn escape_during_a_pane_drag_keeps_full_screen() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.fullScreen", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let title = h.get_by_label_contains("Move Pane: ").rect().center(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(title), button(title, true)]); + h.run_steps(2); + let away = title + egui::vec2(300.0, 200.0); + h.input_mut().events.push(egui::Event::PointerMoved(away)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "the title bar is dragged"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().session.view.full_screen); + h.input_mut().events.push(button(away, false)); + h.run_steps(2); + assert!(h.state().session.view.full_screen); + let docked = craft_ui::panes::Place::Docked(craft_ui::panes::Edge::Start); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, docked, "the drag was cancelled"); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From 0bdd2a3e040199cc3b5b0288a45af89a0845e137 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:37:48 +0000 Subject: [PATCH 47/87] Pin craft-libs at 869928d (craft-print verifies Windows print capabilities, craft-libs#303) --- Cargo.lock | 122 ++++++++------------------------------ Cargo.toml | 4 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +- 4 files changed, 29 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8be5da985c..5e14ec336c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,7 +240,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4952,7 +4952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6235,7 +6235,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6244,16 +6244,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6271,31 +6262,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6313,96 +6287,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" diff --git a/Cargo.toml b/Cargo.toml index 1398af5fa2..55b52d9788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 00054b687c..3d9356a8d3 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c17e3f0e5d..c880541a91 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 823ce76e398b3d0e1a4c6b46832e05f58edbdeb0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:55:42 +0000 Subject: [PATCH 48/87] Pin craft-libs at 8a503cf (Adobe-style drop targets, craft-libs#310) --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e14ec336c..e3af381854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 55b52d9788..638f2eea5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 3d9356a8d3..37a674f407 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c880541a91..0b2e81a45a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From f199efde84785944c61bbd9dbaf56a4ce7a7c9a1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:07:10 +0000 Subject: [PATCH 49/87] Pin craft-libs at 4dc543c (task pane drop previews show where the pane lands, craft-libs#312) --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3af381854..cfc346e383 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 638f2eea5f..4f24147d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 37a674f407..6a61d8e8a6 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0b2e81a45a..614ab7f154 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 49600e0344995954b4dee72c08f41ff0fed4d438 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 21:07:35 +0100 Subject: [PATCH 50/87] egui037: cover formatting copy and paste shortcuts --- crates/ui-egui/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f546dead9e..1b63624f87 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6821,6 +6821,70 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } + /// PR #4241: Ctrl/Cmd+Shift+C copies the selection's formatting and Ctrl/Cmd+Shift+V pastes it + /// onto another selection, as in Word — not the text. egui 0.37 delivers those chords as keys + /// (only Cmd/Ctrl without extra modifiers become copy/paste events), so the registry's + /// `Mod+Shift+C`/`Mod+Shift+V` commands must run. + #[test] + fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() { + // A native event carries ⌘ (mac_cmd and command) on macOS, Ctrl (ctrl and command) + // elsewhere; COMMAND+SHIFT is the logical chord both platforms resolve. + let native = if cfg!(target_os = "macos") { + egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + } else { + egui::Modifiers::CTRL | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + }; + let copied = |h: &egui_kittest::Harness| -> Vec { + h.output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect() + }; + let bold = |h: &mut egui_kittest::Harness| -> Option { + h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool) + }; + for modifiers in [egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, native] { + let mut a = app(); + a.run("text.insert", json!({"text": "Bold plain"})).unwrap(); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + a.run("format.bold", json!({})).unwrap(); + a.run("select.text", json!({"text": "plain"})).unwrap(); + a.run("edit.copy", json!({})).unwrap(); + let clipboard_entries = a.session.clip_history.len(); + assert_eq!(a.session.clipboard_text, "plain"); + assert!(clipboard_entries > 0); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + let mut h = harness(a); + h.run_steps(6); + assert!(h.state().canvas.focused, "the canvas's key handler has the keyboard"); + assert_eq!(bold(&mut h), Some(true), "the source selection is bold"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.event(key(egui::Key::C, modifiers)); + h.step(); + assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved"); + assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h)); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: copying formatting left the document text alone"); + + h.state_mut().run("select.text", json!({"text": "plain"})).unwrap(); + assert_eq!(bold(&mut h), Some(false), "the target selection is plain before the paste"); + h.event(key(egui::Key::V, modifiers)); + h.step(); + assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold"); + assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up"); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text"); + assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h)); + } + } + /// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing /// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and /// Linux send the key, which reached no command.) From 4963e30eed6de7299b18eb7b6ca7ba302e2edca1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:32:25 +0100 Subject: [PATCH 51/87] #3692: a row's gridAfter is bounded like the DOCX reader's Audit finding 35: Table::cols() added a deserialized row's grid_after (u32) unchecked, and layout_table sized its column vectors from it, so a small native JSON document could ask for a 16 GB grid (and overflow the sum on 32-bit wasm). Row::grid_after() now clamps to MAX_COLS as first_grid() does and the importer does; cols() sums with saturating adds; layout reads the bounded value. Test: tests_budget::a_huge_grid_after_is_bounded_like_the_docx_readers. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/table.rs | 9 ++++++++- crates/layout/src/lib.rs | 2 ++ crates/layout/src/table.rs | 4 ++-- crates/layout/src/tests_budget.rs | 24 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 crates/layout/src/tests_budget.rs diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d3c0280676..76911dd4f8 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -48,6 +48,12 @@ impl Row { pub fn first_grid(&self) -> usize { (self.props.grid_before as usize).min(MAX_COLS) } + + /// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX + /// reader bounds them (a native document can say anything). + pub fn grid_after(&self) -> usize { + (self.props.grid_after as usize).min(MAX_COLS) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] @@ -97,7 +103,8 @@ impl Table { } pub fn cols(&self) -> usize { - let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::() + r.props.grid_after as usize; + let row_cols = + |r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after()); self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0)) } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f639078ec1..4481829906 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4967,6 +4967,8 @@ mod tests_autospace; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_budget; +#[cfg(test)] mod tests_complex; #[cfg(test)] mod tests_grid; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d2e24e8910..169eda6b9f 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -537,7 +537,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); // A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own // first and last cell. - let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize)); + let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.grid_after())); let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), @@ -1170,7 +1170,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep /// columns, width in points). Column widths make room for them like for an empty cell. fn row_gaps(row: &Row, end: usize) -> impl Iterator { let p = &row.props; - [(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)] + [(0, row.first_grid(), p.w_before), (end, row.grid_after(), p.w_after)] .into_iter() .filter(|&(_, n, _)| n > 0) .filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0)))) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs new file mode 100644 index 0000000000..c98efc3d6f --- /dev/null +++ b/crates/layout/src/tests_budget.rs @@ -0,0 +1,24 @@ +//! Hostile documents whose layout work or memory would otherwise grow far past their size: each +//! is bounded by a cap, and real documents stay well under it. + +use super::*; +use wordcraft_doc::Block; +use wordcraft_doc::table::{MAX_COLS, Table}; + +fn lay(doc: &Document) -> DocLayout { + let mut c = LayoutCache::new(); + layout(doc, &mut c, &LayoutOptions::default()) +} + +#[test] +fn a_huge_grid_after_is_bounded_like_the_docx_readers() { + // A native document can say a row leaves four billion columns empty after its cells. + let mut t = Table::new(1, 2, 468.0); + t.rows[0].props.grid_after = u32::MAX; + t.grid.clear(); + assert!(t.cols() <= 2 + MAX_COLS, "{} columns", t.cols()); + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. }))); +} From 8efba59b35feafcc5115a4faeb79a4bd3f432aa4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:33:18 +0100 Subject: [PATCH 52/87] #3692: a linked text-box chain lays its story out at most 16 widths Audit finding 7: flow() laid the head box's whole story out again at every distinct box line length in the chain and kept each layout while splitting, so a chain of many one-line boxes of different widths multiplied the story's layout work and memory by the chain length. Past MAX_FLOW_WIDTHS (16) distinct lengths the text stops: the box that would need another layout and the rest of the chain show nothing, as overflow past the last box is hidden. Work is now at most 16x the story. Test: tests_budget::a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/tests_budget.rs | 44 ++++++++++++++++++++++++++++++- crates/layout/src/textbox.rs | 11 ++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index c98efc3d6f..09834fbc31 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -2,14 +2,56 @@ //! is bounded by a cap, and real documents stay well under it. use super::*; -use wordcraft_doc::Block; use wordcraft_doc::table::{MAX_COLS, Table}; +use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block}; fn lay(doc: &Document) -> DocLayout { let mut c = LayoutCache::new(); layout(doc, &mut c, &LayoutOptions::default()) } +/// A `w` × `h` text box showing `text`, inline at the end of body paragraph `para`; its story id. +fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 { + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, Default::default()))]); + if let Some(p) = d.parts.get_mut(&id) { + p.body = TextBody::default(); + } + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w, + h, + fill: None, + stroke: None, + stroke_width: 1.0, + float: Default::default(), + story: Some(id), + effects: Default::default(), + freeform: None, + extra: Default::default(), + }; + let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len()); + d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap(); + id +} + +#[test] +fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { + // Forty linked boxes, each a line deep and each a different width, over a long story: every + // width would lay the whole story out again. Past the cap, the chain's later boxes show nothing. + let n = 40; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let l = lay(&d); + let shown = + l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).count(); + assert!(shown > 1, "the first boxes still show the story: {shown}"); + assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); +} + #[test] fn a_huge_grid_after_is_bounded_like_the_docx_readers() { // A native document can say a row leaves four billion columns empty after its cells. diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index c3b87f7c77..8042cb5061 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -208,6 +208,12 @@ struct Cut { byte: usize, } +/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole +/// story out again and is kept while the chain is split, so a chain of boxes that each differ in +/// width would multiply its story's layout work and memory; boxes needing another one show no +/// text. Far above a real chain's few box sizes. +pub(crate) const MAX_FLOW_WIDTHS: usize = 16; + /// Split the text of `chain`'s first box over its boxes. fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let Some(&head) = chain.first() else { return Vec::new() }; @@ -236,6 +242,11 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let key = (len.to_bits(), body.vert); let items = match layouts.iter().find(|(k, _)| *k == key) { Some((_, items)) => items.clone(), + // Past the cap the text stops here: this box and the rest show none. + None if layouts.len() >= MAX_FLOW_WIDTHS => { + out.push(Vec::new()); + continue; + } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); From 96579e6b4e48739900d9da6c8b0320a55c1cc02c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:33:55 +0100 Subject: [PATCH 53/87] #3692: repeated table header rows are capped by items, not rows Audit finding 11: place_table cloned every item of the header rows onto each page a table spills onto, but its cap (200 000) counted rows. An exact-height header row keeps all its overflowing paragraphs without growing, so one header row of thousands of items over hundreds of pages placed millions of copies. The cap now counts placed items (MAX_HEADER_ITEMS = 500 000, a row costing at least one) and the header set repeats whole or not at all; past it later pages carry no header rows and pagination still finishes. Test: tests_budget::repeated_header_items_are_capped_across_the_table. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/lib.rs | 30 ++++++++++++++-------------- crates/layout/src/tests_budget.rs | 33 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4481829906..d324770026 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4404,6 +4404,14 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p table::split_row(laid, room).is_some() } +/// Header rows are re-placed on every page a table spills onto, so a table is O(header items × +/// pages) placed items — and an exact-height header row keeps all its overflowing text without +/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row +/// counts at least one) have been repeated across a table, later pages carry no header rows; +/// pagination still finishes. Far above any real table (three ordinary header rows over some +/// 5 000 pages). +const MAX_HEADER_ITEMS: usize = 500_000; + fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { pb.prev = None; let width = pb.col_w(); @@ -4424,13 +4432,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl // A table in the text flow starts below the floating objects in its way. below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width); let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect(); - // Header rows are re-placed on every page a table spills onto, so a table with many header - // rows that each spill onto their own page is O(header rows × pages) placed items — a few KB - // of document can otherwise fill memory. Once this many header rows have been repeated across - // the whole table, later pages carry none; pagination still finishes. Far above any real - // table (e.g. 3 header rows over 30 000 pages). - const MAX_HEADER_REPEATS: usize = 200_000; + // Items repeated so far, and what one more repeat of the header rows costs (see + // `MAX_HEADER_ITEMS`). let mut header_repeats = 0usize; + let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add); // Resolve kept chains once; nested paragraphs cannot keep their enclosing row. let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect(); for i in (0..tl.rows.len().saturating_sub(1)).rev() { @@ -4530,15 +4535,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl } pb.advance(block, body_top); // Repeat header rows, up to this table's cap. - if !header_rows.contains(&ri) && header_repeats < MAX_HEADER_REPEATS { - for hr in &header_rows { - if header_repeats >= MAX_HEADER_REPEATS { - break; - } - if let Some(h) = tl.rows.get(*hr) { - place(pb, h); - header_repeats += 1; - } + if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS { + header_repeats += header_cost; + for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) { + place(pb, h); } } fresh = true; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 09834fbc31..7a1c4d569a 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -2,6 +2,7 @@ //! is bounded by a cap, and real documents stay well under it. use super::*; +use wordcraft_doc::props::HeightRule; use wordcraft_doc::table::{MAX_COLS, Table}; use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block}; @@ -52,6 +53,38 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); } +#[test] +fn repeated_header_items_are_capped_across_the_table() { + // An exact-height header row keeps thousands of overflowing paragraphs without growing, and + // repeats on each of hundreds of pages: every page would copy all of them. + let mut t = Table::new(201, 1, 468.0); + t.rows[0].props.header = true; + t.rows[0].props.height = Some(20.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect(); + for row in t.rows.iter_mut().skip(1) { + row.props.height = Some(600.0); + row.props.height_rule = HeightRule::Exact; + } + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 200, "{} pages", l.pages.len()); + let items: usize = l.pages.iter().map(|p| p.items.len()).sum(); + // The first page's header and each page's body row come on top of the repeats. + assert!(items <= MAX_HEADER_ITEMS + 3000 + 50 * l.pages.len(), "{items} items placed"); + // An ordinary table's header still repeats on every page. + let mut t = Table::new(80, 2, 468.0); + t.rows[0].props.header = true; + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 2); + for p in &l.pages { + assert!(p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. })), "the header repeats"); + } +} + #[test] fn a_huge_grid_after_is_bounded_like_the_docx_readers() { // A native document can say a row leaves four billion columns empty after its cells. From b578be045932eead5a55760d692b42b9711eadcc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:34:48 +0100 Subject: [PATCH 54/87] Audit finding 38: the outline cache keys on media and crop, but working out any crop's outline decoded the whole shared picture again, so many small picture references with distinct crops multiplied full-image decodes without repeating the encoded image. Layout now keeps the last picture's transparency (one alpha byte per pixel) and reuses it for every crop of it, and charges each decode's pixels against MAX_DECODED_PIXELS (2^28) per layout; past it pictures wrap as their rectangles. Tests: tests_budget::a_picture_cropped_many_ways_is_decoded_once, tests_budget::decoding_pictures_stops_at_the_layouts_budget. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/contour.rs | 78 +++++++++++++++++++++++++++---- crates/layout/src/lib.rs | 7 ++- crates/layout/src/tests_budget.rs | 44 +++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index d58d0e0c8d..ce02027fbf 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -164,21 +164,81 @@ pub fn contour_area(r: Rect, float: &Float, outline: &[(f32, f32)]) -> Option<(R /// Largest picture (each side, pixels) whose transparency is looked at. const MAX_PICTURE_SIDE: u32 = 8192; -/// The outline of a picture's opaque pixels within `crop` (left, top, right, bottom fractions), -/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or -/// that don't decode): their rectangle is their outline. -pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { +/// Most picture pixels decoded for their transparency in one layout. Every picture a document +/// wraps tightly is decoded in full whatever its crop; past this, pictures wrap as their +/// rectangles. Room for four pictures of the largest size looked at, or hundreds of photos. +pub(crate) const MAX_DECODED_PIXELS: u64 = 1 << 28; + +/// A picture's transparency: one alpha byte per pixel, row by row. +struct Alpha { + w: u32, + h: u32, + a: Vec, +} + +impl Alpha { + fn opaque(&self, x: u32, y: u32) -> bool { + x < self.w + && (y as usize) + .checked_mul(self.w as usize) + .and_then(|r| r.checked_add(x as usize)) + .and_then(|i| self.a.get(i)) + .is_some_and(|a| *a >= 128) + } +} + +/// Pictures' transparency decoded during one layout. A picture shown with many crops (each an +/// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. +#[derive(Default)] +pub(crate) struct Alphas { + /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). + last: Option<((usize, usize), Option>)>, + /// Pixels decoded so far. + pub(crate) decoded: u64, +} + +impl Alphas { + /// The outline of picture `bytes`'s opaque pixels within `crop` (left, top, right, bottom + /// fractions), in unit coordinates of the cropped picture. `None` for pictures without + /// transparency (or that don't decode, or past the budget): their rectangle is their outline. + pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Option> { + let key = (bytes.as_ptr() as usize, bytes.len()); + let alpha = match &self.last { + Some((k, alpha)) if *k == key => alpha.clone(), + _ => { + let alpha = decode_alpha(bytes, &mut self.decoded).map(Arc::new); + self.last = Some((key, alpha.clone())); + alpha + } + }; + alpha_in_crop(&*alpha?, crop) + } +} + +/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures +/// without transparency, that don't decode, or that would take `decoded` past +/// [`MAX_DECODED_PIXELS`]. +fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Option { let pic = wordcraft_geom::picture::probe(bytes).ok()?; // Formats that can't be transparent aren't decoded. if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE { return None; } + let pixels = u64::from(pic.width) * u64::from(pic.height); + let total = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS)?; + *decoded = total; let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?; if !img.color().has_alpha() { return None; } let img = img.into_rgba8(); - let (iw, ih) = img.dimensions(); + let (w, h) = img.dimensions(); + Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }) +} + +/// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`]. +fn alpha_in_crop(img: &Alpha, crop: [f32; 4]) -> Option> { + let (iw, ih) = (img.w, img.h); // The part the renderers draw (#1899): a sliver keeps its partly covered pixels. let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop); let (fw, fh) = (f64::from(iw), f64::from(ih)); @@ -197,9 +257,7 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> let opaque = |gx: usize, gy: usize| { let (px0, py0) = (x0 + gx as u32 * cell, y0 + gy as u32 * cell); let (px1, py1) = ((px0 + cell).min(x1), (py0 + cell).min(y1)); - (py0..py1) - .step_by(step as usize) - .any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128))) + (py0..py1).step_by(step as usize).any(|y| (px0..px1).step_by(step as usize).any(|x| img.opaque(x, y))) }; let pts = alpha_outline(gw as usize, gh as usize, opaque)?; // Grid points onto the picture as the renderers draw the window, whose edges fall within @@ -214,6 +272,10 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> mod tests { use super::*; + fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { + Alphas::default().outline(bytes, crop) + } + fn diamond(through: bool) -> Contour { Contour::new(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], 0.0, 0.0, 0.0, through).unwrap().1 } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index d324770026..35720aae24 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -741,6 +741,8 @@ struct Ctx<'a> { floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>, /// The document's sections with their headers and footers linked ([`HfLinks`]). hf: Arc>, + /// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]). + alphas: contour::Alphas, } /// A header or footer (`story`) laid out for page `number` of a section (`sect`, `mirror`): its @@ -795,7 +797,7 @@ impl Ctx<'_> { /// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]); /// pictures' are worked out once per picture and crop. fn wrap_outline(&mut self, o: &InlineObject) -> Option>> { - let (doc, cache) = (self.doc, &mut *self.cache); + let (doc, cache, alphas) = (self.doc, &mut *self.cache, &mut self.alphas); contour::wrap_outline(o, |media, crop| { let bytes = doc.media.get(media)?; let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits)); @@ -805,7 +807,7 @@ impl Ctx<'_> { if cache.outlines.len() >= 256 { cache.outlines.clear(); } - let outline = contour::picture_outline(bytes, crop).map(Arc::new); + let outline = alphas.outline(bytes, crop).map(Arc::new); cache.outlines.insert(key, outline.clone()); outline }) @@ -2338,6 +2340,7 @@ fn layout_pass( pin: None, floats_above: HashMap::new(), hf: Arc::new(HfLinks::new(doc.sections())), + alphas: Default::default(), }; let hf = Arc::clone(&ctx.hf); let sections = &hf.sections; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 7a1c4d569a..3604b471e2 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -97,3 +97,47 @@ fn a_huge_grid_after_is_bounded_like_the_docx_readers() { let l = lay(&d); assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. }))); } + +/// A `size` × `size` PNG, transparent but for an opaque disc in the middle. +fn disc_png(size: u32) -> Vec { + let c = size as f32 / 2.0; + let img = image::RgbaImage::from_fn(size, size, |x, y| { + let (dx, dy) = (x as f32 + 0.5 - c, y as f32 + 0.5 - c); + image::Rgba([0, 0, 0, if dx * dx + dy * dy < c * c * 0.5 { 255 } else { 0 }]) + }); + let mut buf = Vec::new(); + image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); + buf +} + +#[test] +fn a_picture_cropped_many_ways_is_decoded_once() { + let png = disc_png(64); + let mut alphas = contour::Alphas::default(); + for i in 0..300 { + let f = i as f32 / 1000.0; + assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_some(), "crop {i} follows the disc"); + } + assert_eq!(alphas.decoded, 64 * 64, "one decode for every crop"); + // A crop's outline is the one the picture alone gives. + let mut fresh = contour::Alphas::default(); + assert_eq!(alphas.outline(&png, [0.1, 0.0, 0.2, 0.0]), fresh.outline(&png, [0.1, 0.0, 0.2, 0.0])); +} + +#[test] +fn decoding_pictures_stops_at_the_layouts_budget() { + let (a, b) = (disc_png(64), disc_png(32)); + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64 + 1; + // Too many pixels left to decode: the picture wraps as its rectangle. + assert!(alphas.outline(&a, [0.0; 4]).is_none()); + // A smaller one still fits. + assert!(alphas.outline(&b, [0.0; 4]).is_some()); + assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); + // Pictures alternating past the budget aren't decoded again. + for _ in 0..10 { + assert!(alphas.outline(&a, [0.0; 4]).is_none()); + let _ = alphas.outline(&b, [0.0; 4]); + } + assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); +} From ed78539488ca7377b86af3275457b206c6dfd6dc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:35:01 +0100 Subject: [PATCH 55/87] Progress log and layout-parity notes for audit findings 7, 11, 35, 38. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/layout-parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d35ce0a1cd..1f9fc717d5 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -59,7 +59,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles, #3692); not compared page-for-page with Word | 1–2 | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 different box widths, later boxes needing another show nothing (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | @@ -273,6 +273,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | +| 2026-10-22 | trivial | Layout work bounds for hostile documents: linked-box chain widths, repeated header items, `gridAfter`, picture decodes (#3692) | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | From 32e4900bc5ea341d3aace37657b0a2081fc8812e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:18:47 +0100 Subject: [PATCH 56/87] #3692: a linked chain past 16 widths reuses the nearest; opaque pictures and budget refusals don't cost a later picture its outline Ultrareview finding 6: boxes past a chain's 16th distinct width (compared by exact float equality) showed no text, which resized boxes hit easily. Widths are now told apart to a twip, and past the cap a box takes the nearest layout already made (same direction, widest that fits, else the narrowest). Work stays bounded at 16 story layouts. Ultrareview finding 7 (also raised by CodeRabbit): opaque non-JPEG pictures were decoded and charged to the per-layout pixel budget though they give no outline, and a budget refusal was cached in LayoutCache as a permanent "no outline". The header (read via into_decoder, PNG tRNS included) now says whether a picture can be transparent before anything is charged; Alphas::outline reports OverBudget distinctly from Ok(None), and Ctx::wrap_outline (via picture_outline) doesn't keep a refusal in LayoutCache::outlines. Within one layout a refused picture is remembered so its header isn't re-read. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/contour.rs | 52 ++++++++++++------ crates/layout/src/lib.rs | 35 ++++++++---- crates/layout/src/tests_budget.rs | 90 +++++++++++++++++++++++++++---- crates/layout/src/textbox.rs | 21 +++++--- docs/layout-parity.md | 4 +- 5 files changed, 156 insertions(+), 46 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index ce02027fbf..48432f4f77 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -5,6 +5,7 @@ //! A [`Contour`] is built once per placed object; each line then asks it which stretches of //! the line's band it takes ([`Contour::occupied`]), a walk over its edges. +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -187,12 +188,19 @@ impl Alpha { } } +/// A picture whose transparency would take its layout past [`MAX_DECODED_PIXELS`]: it wraps +/// as its rectangle in this layout only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OverBudget; + /// Pictures' transparency decoded during one layout. A picture shown with many crops (each an /// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. #[derive(Default)] pub(crate) struct Alphas { /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). last: Option<((usize, usize), Option>)>, + /// Pictures refused for the budget (bytes' address and length), not looked at again. + refused: HashSet<(usize, usize)>, /// Pixels decoded so far. pub(crate) decoded: u64, } @@ -200,40 +208,50 @@ pub(crate) struct Alphas { impl Alphas { /// The outline of picture `bytes`'s opaque pixels within `crop` (left, top, right, bottom /// fractions), in unit coordinates of the cropped picture. `None` for pictures without - /// transparency (or that don't decode, or past the budget): their rectangle is their outline. - pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Option> { + /// transparency (or that don't decode), and [`OverBudget`] past the budget: their rectangle + /// is their outline. + pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result>, OverBudget> { let key = (bytes.as_ptr() as usize, bytes.len()); let alpha = match &self.last { Some((k, alpha)) if *k == key => alpha.clone(), + _ if self.refused.contains(&key) => return Err(OverBudget), _ => { - let alpha = decode_alpha(bytes, &mut self.decoded).map(Arc::new); + let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| { + self.refused.insert(key); + })?; + let alpha = alpha.map(Arc::new); self.last = Some((key, alpha.clone())); alpha } }; - alpha_in_crop(&*alpha?, crop) + Ok(alpha.and_then(|a| alpha_in_crop(&a, crop))) } } /// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures -/// without transparency, that don't decode, or that would take `decoded` past -/// [`MAX_DECODED_PIXELS`]. -fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Option { - let pic = wordcraft_geom::picture::probe(bytes).ok()?; +/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded` +/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing. +fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result, OverBudget> { + use image::ImageDecoder; + let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) }; // Formats that can't be transparent aren't decoded. if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE { - return None; + return Ok(None); + } + // Only the header is read here (a PNG's `tRNS` counts as transparency). + let has_alpha = image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format) + .into_decoder() + .ok() + .is_some_and(|d| d.color_type().has_alpha()); + if !has_alpha { + return Ok(None); } let pixels = u64::from(pic.width) * u64::from(pic.height); - let total = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS)?; - *decoded = total; - let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?; - if !img.color().has_alpha() { - return None; - } + *decoded = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS).ok_or(OverBudget)?; + let Ok(img) = pic.decode(wordcraft_geom::MAX_DECODED_BYTES) else { return Ok(None) }; let img = img.into_rgba8(); let (w, h) = img.dimensions(); - Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }) + Ok(Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() })) } /// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`]. @@ -273,7 +291,7 @@ mod tests { use super::*; fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { - Alphas::default().outline(bytes, crop) + Alphas::default().outline(bytes, crop).ok().flatten() } fn diamond(through: bool) -> Contour { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 35720aae24..2b59874c65 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -535,7 +535,7 @@ pub struct LayoutCache { measures: HashMap, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the /// media's bytes (address and length) and crop. - outlines: HashMap<(String, usize, usize, [u32; 4]), Option>>>, + outlines: Outlines, env: u64, used: HashSet, /// The STYLEREF style index of the last pass and what it was built from: the body blocks @@ -793,6 +793,29 @@ fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl (out, behind, front) } +/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]). +type Outlines = HashMap<(String, usize, usize, [u32; 4]), Option>>>; + +/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this +/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it. +fn picture_outline( + outlines: &mut Outlines, + alphas: &mut contour::Alphas, + key: (String, usize, usize, [u32; 4]), + bytes: &[u8], + crop: [f32; 4], +) -> Option>> { + if let Some(o) = outlines.get(&key) { + return o.clone(); + } + let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new); + if outlines.len() >= 256 { + outlines.clear(); + } + outlines.insert(key, outline.clone()); + outline +} + impl Ctx<'_> { /// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]); /// pictures' are worked out once per picture and crop. @@ -801,15 +824,7 @@ impl Ctx<'_> { contour::wrap_outline(o, |media, crop| { let bytes = doc.media.get(media)?; let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits)); - if let Some(o) = cache.outlines.get(&key) { - return o.clone(); - } - if cache.outlines.len() >= 256 { - cache.outlines.clear(); - } - let outline = alphas.outline(bytes, crop).map(Arc::new); - cache.outlines.insert(key, outline.clone()); - outline + picture_outline(&mut cache.outlines, alphas, key, bytes, crop) }) } diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 3604b471e2..e3ead629dc 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -38,7 +38,8 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 { #[test] fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { // Forty linked boxes, each a line deep and each a different width, over a long story: every - // width would lay the whole story out again. Past the cap, the chain's later boxes show nothing. + // width would lay the whole story out again. Past the cap, later boxes reuse the nearest + // width already laid out, so every box still shows its share of the story. let n = 40; let mut d = Document::from_text(&vec!["p"; n].join("\n")); let story = "Words that run on and on through every box of the chain. ".repeat(200); @@ -47,10 +48,42 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); } let l = lay(&d); - let shown = - l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).count(); - assert!(shown > 1, "the first boxes still show the story: {shown}"); - assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); + let lines: Vec<&Placed> = + l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).collect(); + assert_eq!(lines.len(), n, "every box shows the story"); + // Each width the story was laid out at is its own paragraph layout. + let mut widths: Vec = + lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect(); + widths.sort_unstable(); + widths.dedup(); + assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); +} + +#[test] +fn boxes_a_hair_apart_in_width_share_a_layout() { + // Resized boxes differ by fractions of a point; those within a twentieth of a point of each + // other share one layout of the story. + let n = 30; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(100); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 150.0 + 0.0005 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let l = lay(&d); + let mut widths: Vec = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == ids[0] => Some(Arc::as_ptr(para) as usize), + _ => None, + }) + .collect(); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), 1, "one layout for every box"); } #[test] @@ -116,7 +149,7 @@ fn a_picture_cropped_many_ways_is_decoded_once() { let mut alphas = contour::Alphas::default(); for i in 0..300 { let f = i as f32 / 1000.0; - assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_some(), "crop {i} follows the disc"); + assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} follows the disc"); } assert_eq!(alphas.decoded, 64 * 64, "one decode for every crop"); // A crop's outline is the one the picture alone gives. @@ -129,15 +162,52 @@ fn decoding_pictures_stops_at_the_layouts_budget() { let (a, b) = (disc_png(64), disc_png(32)); let mut alphas = contour::Alphas::default(); alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64 + 1; - // Too many pixels left to decode: the picture wraps as its rectangle. - assert!(alphas.outline(&a, [0.0; 4]).is_none()); + // Too many pixels left to decode: the picture wraps as its rectangle, and says why. + assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget)); // A smaller one still fits. - assert!(alphas.outline(&b, [0.0; 4]).is_some()); + assert!(alphas.outline(&b, [0.0; 4]).is_ok_and(|o| o.is_some())); assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); // Pictures alternating past the budget aren't decoded again. for _ in 0..10 { - assert!(alphas.outline(&a, [0.0; 4]).is_none()); + assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget)); let _ = alphas.outline(&b, [0.0; 4]); } assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); } + +/// A `size` × `size` PNG with no transparency. +fn opaque_png(size: u32) -> Vec { + let img = image::RgbImage::from_pixel(size, size, image::Rgb([0, 0, 0])); + let mut buf = Vec::new(); + image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); + buf +} + +#[test] +fn opaque_pictures_take_none_of_the_decoding_budget() { + // Pictures whose header says they have no transparency have no outline to find: decoding + // them would only spend the budget a transparent picture after them needs. + let (opaque, disc) = (opaque_png(64), disc_png(64)); + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64; + for _ in 0..3 { + assert_eq!(alphas.outline(&opaque, [0.0; 4]), Ok(None)); + } + assert!(alphas.outline(&disc, [0.0; 4]).is_ok_and(|o| o.is_some()), "the transparent picture still has its outline"); + assert_eq!(alphas.decoded, contour::MAX_DECODED_PIXELS); +} + +#[test] +fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { + // One layout ran out of budget before this picture; the next has a budget of its own. + let disc = disc_png(64); + let mut outlines = HashMap::new(); + let key = ("p".to_string(), 1, 2, [0; 4]); + let mut spent = contour::Alphas::default(); + spent.decoded = contour::MAX_DECODED_PIXELS; + assert!(picture_outline(&mut outlines, &mut spent, key.clone(), &disc, [0.0; 4]).is_none()); + assert!(outlines.is_empty(), "the refusal isn't kept"); + let mut fresh = contour::Alphas::default(); + assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some()); + assert!(outlines.get(&key).is_some_and(|o| o.is_some()), "the outline is kept"); +} diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 8042cb5061..975b393904 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -210,10 +210,16 @@ struct Cut { /// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole /// story out again and is kept while the chain is split, so a chain of boxes that each differ in -/// width would multiply its story's layout work and memory; boxes needing another one show no -/// text. Far above a real chain's few box sizes. +/// width would multiply its story's layout work and memory; boxes needing another one take the +/// nearest already laid out. Far above a real chain's few box sizes. pub(crate) const MAX_FLOW_WIDTHS: usize = 16; +/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart +/// share one layout. +fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + ((len * 20.0).round() as i32, vert) +} + /// Split the text of `chain`'s first box over its boxes. fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let Some(&head) = chain.first() else { return Vec::new() }; @@ -227,7 +233,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { // time), sharing a layout with the boxes before it of the same length. let snap = (ctx.counters.clone(), ctx.eq_count); let mut after = None; - let mut layouts: Vec<((u32, TextVert), Vec)> = Vec::new(); + let mut layouts: Vec<((i32, TextVert), Vec)> = Vec::new(); let mut out = Vec::with_capacity(chain.len()); // Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown. let mut start: Option> = Some(None); @@ -239,13 +245,14 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default(); let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); - let key = (len.to_bits(), body.vert); + let key = flow_key(len, body.vert); let items = match layouts.iter().find(|(k, _)| *k == key) { Some((_, items)) => items.clone(), - // Past the cap the text stops here: this box and the rest show none. + // Past the cap the box takes the nearest layout: the same direction first, then the + // widest line length that fits it, else the narrowest. None if layouts.len() >= MAX_FLOW_WIDTHS => { - out.push(Vec::new()); - continue; + let near = layouts.iter().min_by_key(|((k, v), _)| (*v != key.1, *k > key.0, k.abs_diff(key.0))); + near.map(|(_, items)| items.clone()).unwrap_or_default() } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1f9fc717d5..2d06368c92 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles, #3692); not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 different box widths, later boxes needing another show nothing (#3692) | — | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | From 0f9b926497ce9226170ec22d52ac2721133296e1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:23 +0100 Subject: [PATCH 57/87] =?UTF-8?q?#3692:=20review=20=E2=80=94=20a=20picture?= =?UTF-8?q?'s=20transparency=20is=20kept=20for=20the=20whole=20layout,=20n?= =?UTF-8?q?ot=20just=20the=20last=20one=20decoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crops alternating between pictures decoded each again and again, spending the budget on pictures that fit in it. Every picture charged to the budget keeps its alpha (bounded by the same 2^28-pixel budget). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/contour.rs | 19 ++++++++++--------- crates/layout/src/tests_budget.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 48432f4f77..e0a12778df 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -5,7 +5,7 @@ //! A [`Contour`] is built once per placed object; each line then asks it which stretches of //! the line's band it takes ([`Contour::occupied`]), a walk over its edges. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -194,11 +194,12 @@ impl Alpha { pub(crate) struct OverBudget; /// Pictures' transparency decoded during one layout. A picture shown with many crops (each an -/// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. +/// outline of its own) is decoded once, however its crops interleave with other pictures', and +/// decoding stops at [`MAX_DECODED_PIXELS`] (which also bounds what is kept). #[derive(Default)] pub(crate) struct Alphas { - /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). - last: Option<((usize, usize), Option>)>, + /// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none). + seen: HashMap<(usize, usize), Option>>, /// Pictures refused for the budget (bytes' address and length), not looked at again. refused: HashSet<(usize, usize)>, /// Pixels decoded so far. @@ -212,15 +213,15 @@ impl Alphas { /// is their outline. pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result>, OverBudget> { let key = (bytes.as_ptr() as usize, bytes.len()); - let alpha = match &self.last { - Some((k, alpha)) if *k == key => alpha.clone(), - _ if self.refused.contains(&key) => return Err(OverBudget), - _ => { + let alpha = match self.seen.get(&key) { + Some(alpha) => alpha.clone(), + None if self.refused.contains(&key) => return Err(OverBudget), + None => { let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| { self.refused.insert(key); })?; let alpha = alpha.map(Arc::new); - self.last = Some((key, alpha.clone())); + self.seen.insert(key, alpha.clone()); alpha } }; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index e3ead629dc..9e95ca076f 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -157,6 +157,20 @@ fn a_picture_cropped_many_ways_is_decoded_once() { assert_eq!(alphas.outline(&png, [0.1, 0.0, 0.2, 0.0]), fresh.outline(&png, [0.1, 0.0, 0.2, 0.0])); } +#[test] +fn pictures_cropped_in_turn_are_each_decoded_once() { + // Crops alternating between two pictures don't decode either again: enough of them would + // otherwise spend the budget on pictures that fit in it. + let (a, b) = (disc_png(64), disc_png(32)); + let mut alphas = contour::Alphas::default(); + for i in 0..300 { + let f = i as f32 / 1000.0; + assert!(alphas.outline(&a, [f, 0.0, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of a"); + assert!(alphas.outline(&b, [0.0, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of b"); + } + assert_eq!(alphas.decoded, 64 * 64 + 32 * 32, "one decode per picture"); +} + #[test] fn decoding_pictures_stops_at_the_layouts_budget() { let (a, b) = (disc_png(64), disc_png(32)); From 1ae38ec2be76b47e6adf7d6486a940a9448587de Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:23 +0100 Subject: [PATCH 58/87] =?UTF-8?q?#3692:=20review=20=E2=80=94=20stacked=20l?= =?UTF-8?q?inked=20boxes=20share=20one=20layout;=20past=20the=20cap=20a=20?= =?UTF-8?q?box=20takes=20one=20in=20its=20own=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked text is laid out at STACK_WIDTH whatever the frame, so its flow key now is too. The nearest-layout fallback only picks layouts of the box's TextVert; the first box in a direction is laid out (at most a few more layouts). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/tests_budget.rs | 53 +++++++++++++++++++++++++++++++ crates/layout/src/textbox.rs | 16 +++++----- docs/layout-parity.md | 2 +- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 9e95ca076f..72e1d343bf 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -59,6 +59,59 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); } +/// The paragraph layouts (by address) showing story `head` across the document. +fn story_layouts(l: &DocLayout, head: u32) -> Vec { + l.pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == head => Some(Arc::as_ptr(para) as usize), + _ => None, + }) + .collect() +} + +#[test] +fn stacked_boxes_of_any_width_share_a_layout() { + // Stacked text is laid out one letter to a line whatever the box's width. + let n = 20; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Stacked letters run on through every box. ".repeat(20); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + for id in &ids { + d.parts.get_mut(id).unwrap().body.vert = wordcraft_doc::props::TextVert::Stacked; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), 1, "one layout for every stacked box"); +} + +#[test] +fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() { + // The cap is full of horizontal widths when a vertical box comes: it lays the story out + // its own way rather than showing a horizontal layout. + let n = textbox::MAX_FLOW_WIDTHS + 1; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + if let Some(last) = ids.last() { + d.parts.get_mut(last).unwrap().body.vert = wordcraft_doc::props::TextVert::Vert; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), n, "the vertical box has a layout of its own"); +} + #[test] fn boxes_a_hair_apart_in_width_share_a_layout() { // Resized boxes differ by fractions of a point; those within a twentieth of a point of each diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 975b393904..4ebe96c9cc 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -215,8 +215,9 @@ struct Cut { pub(crate) const MAX_FLOW_WIDTHS: usize = 16; /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart -/// share one layout. +/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size. fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + let len = if vert == TextVert::Stacked { STACK_WIDTH } else { len }; ((len * 20.0).round() as i32, vert) } @@ -246,14 +247,13 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); let key = flow_key(len, body.vert); - let items = match layouts.iter().find(|(k, _)| *k == key) { + // Past the cap the box takes the nearest layout in its direction: the widest line length + // that fits it, else the narrowest. The first box in a direction is laid out (one more + // layout for each of the few directions at most). + let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0))); + let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None }); + let items = match kept { Some((_, items)) => items.clone(), - // Past the cap the box takes the nearest layout: the same direction first, then the - // widest line length that fits it, else the narrowest. - None if layouts.len() >= MAX_FLOW_WIDTHS => { - let near = layouts.iter().min_by_key(|((k, v), _)| (*v != key.1, *k > key.0, k.abs_diff(key.0))); - near.map(|(_, items)| items.clone()).unwrap_or_default() - } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2d06368c92..23252ea7fe 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -68,7 +68,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one (#3692) | — | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | From 3cffe22c004bfe650585293e5867b973834398d9 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:22:16 +0100 Subject: [PATCH 59/87] =?UTF-8?q?#3692:=20review=20=E2=80=94=20picture=20o?= =?UTF-8?q?utline=20cache=20keys=20stay=20unique:=20the=20per-pass=20one?= =?UTF-8?q?=20documented,=20the=20lasting=20one=20holds=20its=20bytes'=20a?= =?UTF-8?q?llocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alphas keys decoded pictures by their bytes' address and length, which is sound only while the document is borrowed for the layout pass; that is now said where the key is. LayoutCache's outlines outlive the documents they were made for and use the same key, so each entry now keeps a Weak reference to the bytes: their allocation can't be freed and the address reused by another picture of the same length while the entry is kept. MAX_FLOW_WIDTHS spells out which layout a box past the 16-width cap reuses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/contour.rs | 6 ++++++ crates/layout/src/lib.rs | 18 ++++++++++++------ crates/layout/src/tests_budget.rs | 20 ++++++++++++++++++-- crates/layout/src/textbox.rs | 6 ++++-- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index e0a12778df..d5f18dc8ad 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -199,6 +199,12 @@ pub(crate) struct OverBudget; #[derive(Default)] pub(crate) struct Alphas { /// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none). + /// + /// The address tells pictures apart only while their bytes can't be freed and the address + /// reused for other bytes of the same length: an `Alphas` lives for one layout pass (in its + /// `Ctx`), and the document whose media hold the bytes is borrowed for the whole pass, so no + /// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to + /// the bytes, as [`crate::LayoutCache`]'s outlines do. seen: HashMap<(usize, usize), Option>>, /// Pictures refused for the budget (bytes' address and length), not looked at again. refused: HashSet<(usize, usize)>, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2b59874c65..6771be76b6 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -534,7 +534,9 @@ pub struct LayoutCache { /// Narrowest and widest content of paragraphs measured for table autofit ([`Ctx::para_measured`]). measures: HashMap, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the - /// media's bytes (address and length) and crop. + /// media's bytes (address and length) and crop. Kept from one layout to the next, so each + /// entry holds a weak reference to its bytes: their allocation isn't freed while the entry is + /// kept, so no other picture's bytes can come to have its address ([`Outlines`]). outlines: Outlines, env: u64, used: HashSet, @@ -793,8 +795,12 @@ fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl (out, behind, front) } -/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]). -type Outlines = HashMap<(String, usize, usize, [u32; 4]), Option>>>; +/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]), each +/// with a weak reference to those bytes. The cache outlives the documents it is used with, and +/// an address names the same bytes only while their allocation lives: the weak reference keeps +/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address +/// and length and be taken for it. +type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak>, Option>>)>; /// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this /// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it. @@ -802,17 +808,17 @@ fn picture_outline( outlines: &mut Outlines, alphas: &mut contour::Alphas, key: (String, usize, usize, [u32; 4]), - bytes: &[u8], + bytes: &Arc>, crop: [f32; 4], ) -> Option>> { - if let Some(o) = outlines.get(&key) { + if let Some((_, o)) = outlines.get(&key) { return o.clone(); } let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new); if outlines.len() >= 256 { outlines.clear(); } - outlines.insert(key, outline.clone()); + outlines.insert(key, (Arc::downgrade(bytes), outline.clone())); outline } diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 72e1d343bf..483ec44976 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -267,7 +267,7 @@ fn opaque_pictures_take_none_of_the_decoding_budget() { #[test] fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { // One layout ran out of budget before this picture; the next has a budget of its own. - let disc = disc_png(64); + let disc = Arc::new(disc_png(64)); let mut outlines = HashMap::new(); let key = ("p".to_string(), 1, 2, [0; 4]); let mut spent = contour::Alphas::default(); @@ -276,5 +276,21 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { assert!(outlines.is_empty(), "the refusal isn't kept"); let mut fresh = contour::Alphas::default(); assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some()); - assert!(outlines.get(&key).is_some_and(|o| o.is_some()), "the outline is kept"); + assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept"); +} + +/// The outline cache outlives the documents it is used with and tells pictures apart by their +/// bytes' address: each entry keeps that allocation (a weak reference, not the bytes' owner), so +/// it can't be freed and the address given to another picture's bytes while the entry is kept. +#[test] +fn a_kept_outline_holds_on_to_its_pictures_allocation() { + let disc = Arc::new(disc_png(64)); + let mut outlines = HashMap::new(); + let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]); + assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some()); + let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") }; + assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes"); + assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes"); + drop(disc); + assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept"); } diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 4ebe96c9cc..22ace35d11 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -210,8 +210,10 @@ struct Cut { /// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole /// story out again and is kept while the chain is split, so a chain of boxes that each differ in -/// width would multiply its story's layout work and memory; boxes needing another one take the -/// nearest already laid out. Far above a real chain's few box sizes. +/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't +/// among them takes the nearest narrower one laid out in its text direction, so its lines still +/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer +/// than the box. Far above a real chain's few box sizes. pub(crate) const MAX_FLOW_WIDTHS: usize = 16; /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart From 3ee1978910da4d854bf15e71719d170e724a61cf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 00:01:24 +0000 Subject: [PATCH 60/87] =?UTF-8?q?#3692:=20rebase=20onto=20main=20=E2=80=94?= =?UTF-8?q?=20rustfmt=20after=20the=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/layout/src/contour.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index d5f18dc8ad..e185aed1fe 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -246,10 +246,8 @@ fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result, OverBu return Ok(None); } // Only the header is read here (a PNG's `tRNS` counts as transparency). - let has_alpha = image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format) - .into_decoder() - .ok() - .is_some_and(|d| d.color_type().has_alpha()); + let has_alpha = + image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha()); if !has_alpha { return Ok(None); } From ee7e98eafbe5ab2a48589bdf7cfad84cffc512a3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:22:09 -0400 Subject: [PATCH 61/87] hotfix: same_content must mention media_index (E0027 after #3872) --- crates/doc/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ada156750e..6090ecacd0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1007,6 +1007,7 @@ impl Document { media, passthrough, glossary, + media_index: _, } = self; let Document { body: other_body, @@ -1025,6 +1026,7 @@ impl Document { media: other_media, passthrough: other_passthrough, glossary: other_glossary, + media_index: _, } = other; same_blocks(body, other_body) && same_parts(parts, other_parts) From 4bb3679e4139aea66d3c25de52d06143fbbb52c0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:28:59 -0400 Subject: [PATCH 62/87] Sonar: use PoisonError::into_inner, explicit test imports, [[ in import-cert - proof: pass PoisonError::into_inner instead of an identity closure (S1612). - layout tests: replace glob super::* imports with explicit names (S2208). - packaging/macos/import-cert.sh: use [[ for the status test (S7688). --- crates/layout/src/tests_linear.rs | 5 ++++- crates/layout/src/tests_nbsp.rs | 2 +- crates/layout/src/tests_table_contact.rs | 2 +- crates/proof/src/es/mod.rs | 10 +++++----- packaging/macos/import-cert.sh | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index e49409693c..a90036b4e0 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -6,7 +6,10 @@ //! whose walk is the document model's, is timed. use super::tests::{SCALE, cost_ratio}; -use super::*; +use super::{ + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, ParaLayout, Paragraph, Path, Placed, + SectionProps, SectionStart, StoryRef, display, layout, para, work, +}; use wordcraft_doc::para::Run; use wordcraft_doc::{Pos, Table}; diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..f9ee301b2d 100644 --- a/crates/layout/src/tests_nbsp.rs +++ b/crates/layout/src/tests_nbsp.rs @@ -1,7 +1,7 @@ //! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0 //! glyph measures (Lucida Bright draws it twice as wide). -use super::*; +use super::{Document, LayoutCache, LayoutOptions, Placed, layout}; /// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp". /// Built from the bundled font so the test needs no installed font. diff --git a/crates/layout/src/tests_table_contact.rs b/crates/layout/src/tests_table_contact.rs index 8f3800c961..65a86f0f5f 100644 --- a/crates/layout/src/tests_table_contact.rs +++ b/crates/layout/src/tests_table_contact.rs @@ -1,7 +1,7 @@ //! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary. //! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue. -use super::*; +use super::{Arc, Block, Document, LayoutCache, LayoutOptions, Placed, StoryRef, layout}; use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat}; use wordcraft_doc::{CharProps, Paragraph, Pos, Table}; diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs index 94c6511abd..351a69b04c 100644 --- a/crates/proof/src/es/mod.rs +++ b/crates/proof/src/es/mod.rs @@ -16,7 +16,7 @@ mod suggest; pub(crate) mod test_dict; pub mod voseo; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, PoisonError, RwLock}; use crate::ProofLang; use crate::affix::AffixDict; @@ -62,11 +62,11 @@ pub fn dictionary_name(lang: ProofLang) -> &'static str { /// see [`has_dictionary`]). pub fn dictionary(lang: ProofLang) -> Arc { let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) }; - if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(d) = slot.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return d.clone(); } let loaded = Arc::new(load_installed(lang)); - let mut slot = slot.write().unwrap_or_else(|e| e.into_inner()); + let mut slot = slot.write().unwrap_or_else(PoisonError::into_inner); // Another thread (or an install) may have filled it meanwhile: that one wins. slot.get_or_insert(loaded).clone() } @@ -90,7 +90,7 @@ fn load_installed(lang: ProofLang) -> AffixDict { pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> { let d = AffixDict::from_hunspell(aff, dic, &[])?; if let Some(slot) = DICTS.get(variety(lang).0) { - *slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + *slot.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(d)); } Ok(()) } @@ -99,7 +99,7 @@ pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), S /// (after [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). pub fn reload_dictionary() { for slot in &DICTS { - *slot.write().unwrap_or_else(|e| e.into_inner()) = None; + *slot.write().unwrap_or_else(PoisonError::into_inner) = None; } } diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index a4e79ea8b4..341d012f62 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -30,7 +30,7 @@ KEYCHAIN="$WORK/wordcraft-signing.keychain-db" cleanup() { local status=$? rm -f "$CERT" - if [ "$status" -ne 0 ]; then + if [[ "$status" -ne 0 ]]; then security delete-keychain "$KEYCHAIN" 2>/dev/null || true rm -rf "$WORK" fi From 4fd9075e99ca700073d73bf8ee2a476bba372c84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:30:49 -0400 Subject: [PATCH 63/87] Sonar: split flood fill, CSV separator, noise collapse and numbered backspace Extract helpers with unchanged behaviour to bring these functions under the cognitive-complexity limit (S3776): - engine cmd/objects.rs clear_background: border_pixels, run_span, push_open_runs. - engine cmd/tools.rs collapse_noise_matches: flatten_region (Kind is now module-level). - engine cmd/mailings.rs parse_csv: csv_separator, push_csv_row. - engine cmd/text.rs backspace_inner: remove_number. --- crates/engine/src/cmd/mailings.rs | 28 +++++++----- crates/engine/src/cmd/objects.rs | 71 +++++++++++++++++++------------ crates/engine/src/cmd/text.rs | 36 +++++++++------- crates/engine/src/cmd/tools.rs | 27 +++++++----- 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 488b9bc4a1..8007456366 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { } /// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs). -pub fn parse_csv(text: &str) -> Vec> { +/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most. +fn csv_separator(text: &str) -> char { let first = text.lines().next().unwrap_or(""); let mut quoted = false; let mut unquoted = String::new(); @@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec> { unquoted.push(c); } } - let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(','); + [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',') +} + +/// Ends the current row: kept when it has any non-empty field, otherwise dropped. +fn push_csv_row(rows: &mut Vec>, row: &mut Vec) { + if row.iter().any(|x| !x.is_empty()) { + rows.push(std::mem::take(row)); + } else { + row.clear(); + } +} + +pub fn parse_csv(text: &str) -> Vec> { + let sep = csv_separator(text); let mut rows = Vec::new(); let mut row = Vec::new(); let mut cell = String::new(); @@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec> { chars.next(); } row.push(std::mem::take(&mut cell)); - if row.iter().any(|x| !x.is_empty()) { - rows.push(std::mem::take(&mut row)); - } else { - row.clear(); - } + push_csv_row(&mut rows, &mut row); } else { cell.push(c); } @@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec> { } } row.push(cell); - if row.iter().any(|x| !x.is_empty()) { - rows.push(row); - } + push_csv_row(&mut rows, &mut row); rows } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index b99c302522..7528319318 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { !seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0)) }; let mut seen = vec![false; (w as usize) * (h as usize)]; - let mut stack: Vec<(u32, u32)> = Vec::new(); - for x in 0..w { - stack.push((x, 0)); - stack.push((x, h - 1)); - } - for y in 0..h { - stack.push((0, y)); - stack.push((w - 1, y)); - } + let mut stack = border_pixels(w, h); let mut most = stack.len(); while let Some((x, y)) = stack.pop() { if !open(rgba, &seen, x, y) { continue; } - let (mut x0, mut x1) = (x, x); - while x0 > 0 && open(rgba, &seen, x0 - 1, y) { - x0 -= 1; - } - while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) { - x1 += 1; - } + let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi)); for xi in x0..=x1 { if let Some(s) = seen.get_mut(at(xi, y)) { *s = true; @@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { } } for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() { - let mut xi = x0; - while xi <= x1 { - if open(rgba, &seen, xi, ny) { - stack.push((xi, ny)); - while xi <= x1 && open(rgba, &seen, xi, ny) { - xi += 1; - } - } else { - xi += 1; - } - } + push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi)); } most = most.max(stack.len()); } most } +/// Every pixel on the image's edge, the flood-fill seeds. +fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> { + let mut stack = Vec::new(); + for x in 0..w { + stack.push((x, 0)); + stack.push((x, h - 1)); + } + for y in 0..h { + stack.push((0, y)); + stack.push((w - 1, y)); + } + stack +} + +/// The horizontal run of open pixels around `(x, y)`, both ends included. +fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) { + let (mut x0, mut x1) = (x, x); + while x0 > 0 && open(x0 - 1, y) { + x0 -= 1; + } + while x1 + 1 < w && open(x1 + 1, y) { + x1 += 1; + } + (x0, x1) +} + +/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`. +fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) { + let mut xi = x0; + while xi <= x1 { + if open(xi, ny) { + stack.push((xi, ny)); + while xi <= x1 && open(xi, ny) { + xi += 1; + } + } else { + xi += 1; + } + } +} + fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result { let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?; let mut out = canvas(w, h, color)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ee7ebaafa5..3ec8006744 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -916,6 +916,24 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult { /// numbering while keeping the text position, takes a List Paragraph out of the list, outdents, /// joins the previous paragraph or moves into a preceding table; the command wrapper restores a /// content-control placeholder. +/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's +/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own. +fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult { + let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent); + let p = s.doc.para_mut(f.story, &f.path)?; + if p.props.style.as_deref() == Some("ListParagraph") { + if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { + p.props.indent_left = text_at; + } + if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { + p.props.indent_first = Some(0.0); + } + } + p.props.numbering = Some(NumRef { num: 0, level: 0 }); + p.touch(); + sel_result(s) +} + fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; @@ -949,22 +967,8 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { return sel_result(s); } // At the start of a paragraph. - if para.props.numbering.is_some_and(|n| n.num != 0) { - // First Backspace removes the number but keeps the item's indent (Word): the text stays - // where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it). - let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent); - let p = s.doc.para_mut(f.story, &f.path)?; - if p.props.style.as_deref() == Some("ListParagraph") { - if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { - p.props.indent_left = text_at; - } - if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { - p.props.indent_first = Some(0.0); - } - } - p.props.numbering = Some(NumRef { num: 0, level: 0 }); - p.touch(); - return sel_result(s); + if let Some(n) = para.props.numbering.filter(|n| n.num != 0) { + return remove_number(s, &f, n); } if para.props.style.as_deref() == Some("ListParagraph") { // The second one takes it out of the list's indent instead of joining paragraphs. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 695501aea5..ea9386b9ce 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1554,12 +1554,13 @@ const NOISE_MATCH_LEN: usize = 1; /// stripped before running its table, so it's left untouched regardless of length; only a short /// match produced by the table's own backtracking, which can only ever be interior, is a /// candidate. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Keep, + Fold, +} + fn collapse_noise_matches(ops: &[Op]) -> Vec { - #[derive(Clone, Copy, PartialEq)] - enum Kind { - Keep, - Fold, - } let mut runs: Vec<(Op, usize, Kind)> = Vec::new(); let mut k = 0; while k < ops.len() { @@ -1586,12 +1587,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { } let region = &runs[start..i]; if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) { - // Flatten the region: every original character first (in its own order), then every - // revised character (in its own order) — one clean delete, one clean insert. - let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); - let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); - out.extend(std::iter::repeat_n(Op::Del, dels)); - out.extend(std::iter::repeat_n(Op::Ins, inss)); + flatten_region(&mut out, region); } else { for (op, len, _) in region { out.extend(std::iter::repeat_n(*op, *len)); @@ -1601,6 +1597,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { out } +/// Flattens a region with a folded run: every original character first (in its own order), then +/// every revised character (in its own order) — one clean delete, one clean insert. +fn flatten_region(out: &mut Vec, region: &[(Op, usize, Kind)]) { + let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); + let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); + out.extend(std::iter::repeat_n(Op::Del, dels)); + out.extend(std::iter::repeat_n(Op::Ins, inss)); +} + /// Words with the spaces after them ("The ", "cat "), or single characters. fn tokens(s: &str, characters: bool) -> Vec { if characters { From 66601038df94ce538741fb31a025c6ce0fc6bdec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:52:01 -0400 Subject: [PATCH 64/87] tests(layout): build the nbsp test document without struct-update syntax (#4380) --- crates/layout/src/tests_nbsp.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..d2908fba70 100644 --- a/crates/layout/src/tests_nbsp.rs +++ b/crates/layout/src/tests_nbsp.rs @@ -37,7 +37,8 @@ fn wide_nbsp_family() -> &'static str { /// The width of the first line of `text` set in the wide-nbsp font. fn line_width(text: &str) -> f32 { let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() }; - let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() }; + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))]; let mut c = LayoutCache::new(); let l = layout(&d, &mut c, &LayoutOptions::default()); for p in &l.pages { From 4eda77a2de238e642cf4c33ca2b1521b7fbe23e2 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:08:04 +0000 Subject: [PATCH 65/87] =?UTF-8?q?#3876:=20review=20=E2=80=94=20Zotero=20ow?= =?UTF-8?q?ner=20check=20streams=20the=20socket=20table,=20and=20an=20unre?= =?UTF-8?q?adable=20table=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux same-user check read /proc/net/tcp{,6} through a 64 MiB take(). Hitting the cap was silent, so a local user binding ~450k loopback listeners pushed the connection rows past it; both uids came back unknown and the "trust loopback" arm let their fake Zotero listener on 23116 through. A truncated last line could also glue to the next header. Now each table is streamed line by line (BufRead, nothing kept, no total cap, lines capped at 4 KiB) and stops once both rows are found. Any read error refuses. "Unknown, trust loopback" remains only for a listener absent from a table read in full (WSL2, proxies). Tests: rows after 70 MiB of filler, and a read error mid-table. rich.rs: document that the MAX_RUNS cap still allows one first run per paragraph (bounded by MAX_PARAS). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 157 ++++++++++++++++++++++++++++++++------ crates/zotero/src/rich.rs | 2 +- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 07df98e79f..68e2185364 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -35,23 +35,25 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { } /// Refuse a listener at `peer` that the kernel's socket tables say belongs to another user. +/// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::Read; - /// Longest socket table read (a busy server's table is a few MB). - const MAX_TABLE: u64 = 64 * 1024 * 1024; - let mut table = String::new(); + use std::io::BufReader; + let mut rows = Rows::default(); for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - if let Ok(f) = std::fs::File::open(path) { - let mut part = String::new(); - if f.take(MAX_TABLE).read_to_string(&mut part).is_ok() { - table.push_str(&part); - } + // A missing file (no IPv6) is an absent table; any other failure is a refusal. + match std::fs::File::open(path) { + Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; } } // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); - same_owner(&table, local, peer, me) + decide(&rows, peer, me) } /// Windows, macOS, the BSDs: no same-user check (no socket table to read without OS calls this @@ -61,16 +63,39 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } -/// From a `/proc/net/tcp`-format socket table: refuse when the socket at `peer` connected to -/// `local` (the listener's side) belongs to another user than ours. Our uid is our own socket's -/// row, else `me` (this process's). When the listener isn't listed (or the table couldn't be -/// read, or our uid is unknown) its owner is unknown and the loopback rule decides. -#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] -pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { +/// The uids found for the two rows of one connection. +#[derive(Default)] +struct Rows { + ours: Option, + theirs: Option, +} + +impl Rows { + fn done(&self) -> bool { + self.ours.is_some() && self.theirs.is_some() + } +} + +fn unreadable(e: &std::io::Error) -> ZoteroError { + ZoteroError::Refused(format!("couldn't read the system's socket table ({e}), so the program on the Zotero port can't be checked")) +} + +/// Stream a `/proc/net/tcp`-format table line by line (nothing is kept, so the table's size +/// doesn't matter and nothing is truncated), recording the uids of the connection's two rows. +/// Stops once both are found. Any read error is a refusal: a table we couldn't finish reading +/// must never count as "the listener isn't listed". +fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketAddr, rows: &mut Rows) -> Result<(), ZoteroError> { let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); let (local, peer) = (key(local), key(peer)); - let (mut ours, mut theirs) = (None, None); - for line in table.lines() { + let mut line = Vec::new(); + loop { + line.clear(); + // Bounded line: a row is ~150 bytes; read at most 4 KiB of one (the rest is skipped). + let n = read_line_capped(&mut table, &mut line).map_err(|e| unreadable(&e))?; + if n == 0 { + return Ok(()); + } + let line = String::from_utf8_lossy(&line); let f: Vec<&str> = line.split_whitespace().take(8).collect(); let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { continue; @@ -78,26 +103,67 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: O let Ok(uid) = uid.parse::() else { continue }; let (l, r) = (key(l), key(r)); if l == local && r == peer { - ours = Some(uid); + rows.ours = Some(uid); } else if l == peer && r == local { - theirs = Some(uid); + rows.theirs = Some(uid); + } + if rows.done() { + return Ok(()); } } - match (ours.or(me), theirs) { +} + +/// Read one line into `out`, keeping at most 4 KiB of it; returns the bytes consumed (0 at the end). +fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io::Result { + const CAP: usize = 4096; + let mut total = 0; + loop { + let buf = r.fill_buf()?; + if buf.is_empty() { + return Ok(total); + } + let (take, found) = match buf.iter().position(|b| *b == b'\n') { + Some(i) => (i + 1, true), + None => (buf.len(), false), + }; + let keep = take.min(CAP.saturating_sub(out.len())); + out.extend_from_slice(buf.get(..keep).unwrap_or_default()); + r.consume(take); + total += take; + if found { + return Ok(total); + } + } +} + +/// Refuse when the connection's rows show another user. Our uid is our own socket's row, else +/// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, +/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), (Some(_), Some(b)) => { - Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) + Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.port()))) } _ => { log::debug!( "Zotero: couldn't tell which user runs the program on port {} (not in the socket table); trusting the loopback address alone", - peer.1 + peer.port() ); Ok(()) } } } +/// From an in-memory `/proc/net/tcp`-format table (tests and callers with a string): see +/// [`scan_table`] and [`decide`]. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + let mut rows = Rows::default(); + scan_table(table.as_bytes(), local, peer, &mut rows)?; + decide(&rows, peer, me) +} + /// `0100007F:5A5C` (IPv4) or 32 hex digits and a port (IPv6): each 32-bit word as the kernel /// prints it, in this machine's byte order. #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] @@ -205,6 +271,51 @@ mod tests { assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); } + /// A reader that yields `data`, then fails. + struct Failing(std::io::Cursor>); + impl std::io::Read for Failing { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.read(buf)? { + 0 => Err(std::io::Error::other("injected")), + n => Ok(n), + } + } + } + + /// The real attack: a hostile user floods the table with listener rows so the connection's + /// rows sit past any fixed cap. Streaming finds them wherever they are. + #[test] + fn rows_far_into_a_huge_table_are_still_found() { + let (local, peer) = addrs(); + let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); + let mut table = String::from(HEADER); + // About 70 MiB of rows, past the old 64 MiB cap. + for _ in 0..(70 * 1024 * 1024 / filler.len()) { + table.push_str(&filler); + } + table.push_str(&row(local, peer, 1000)); + table.push_str(&row(peer, local, 1001)); + assert!(table.len() > 64 * 1024 * 1024); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". + #[test] + fn a_table_that_fails_to_read_is_refused() { + let (local, peer) = addrs(); + let data = format!("{HEADER}{}", row(local, peer, 1000)).into_bytes(); + let mut rows = Rows::default(); + let r = scan_table(std::io::BufReader::new(Failing(std::io::Cursor::new(data))), local, peer, &mut rows); + assert!(matches!(r, Err(ZoteroError::Refused(ref e)) if e.contains("socket table")), "{r:?}"); + } + + #[test] + fn an_overlong_line_is_skipped_without_gluing_to_the_next() { + let (local, peer) = addrs(); + let table = format!("{HEADER}{}\n{}{}", "x".repeat(100_000), row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(_)))); + } + #[test] fn only_this_computer_is_contacted() { for ok in ["127.0.0.1:23116", "127.9.9.9:1", "[::1]:23116", "[::ffff:127.0.0.1]:23116"] { diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 7ff3377f1c..705a7e15ec 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -42,7 +42,7 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { /// The body paragraphs of `d` (Zotero's RTF or HTML, read), at most [`MAX_PARAS`], formatted on /// top of `base` with the run styling they carry (past [`MAX_RUNS`] runs, text keeps the -/// formatting before it). Objects are dropped. +/// formatting before it; each paragraph still needs its first run, so at most [`MAX_PARAS`] more). Objects are dropped. fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); let mut runs = 0usize; From b094b299589b9588e70f13c31914b18aaffc8e68 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 01:20:22 +0000 Subject: [PATCH 66/87] =?UTF-8?q?#3869:=20review=20=E2=80=94=20WOFF2=20poi?= =?UTF-8?q?nt=20pre-check=20reads=20glyf=20nContours=20as=20u16=20like=20t?= =?UTF-8?q?he=20decoder=20(only=200xFFFF=20is=20composite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb/user.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index c67fb6a213..8e0da9e524 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -273,9 +273,11 @@ fn glyf_points_fit(t: &[u8]) -> Option { let points = t.get(contours_end..contours_end.checked_add(u32_at(12)?)?)?; let mut at = 0; for g in 0..glyphs { - let n = i16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); + // The decoder reads the count as unsigned and only 0xFFFF is a composite glyph (no points + // here): 0x8000..=0xFFFE are that many contours, so they are counted, not read as negative. + let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); let mut total = 0usize; - for _ in 0..n.max(0) { + for _ in 0..if n == 0xFFFF { 0 } else { n } { // 255UInt16 let code = *points.get(at)?; at += 1; @@ -745,15 +747,20 @@ pub(crate) mod tests { /// A WOFF2 file holding a transformed `glyf` (and `loca`) whose one glyph has two contours of /// `points` points each (and no coordinates: the decoder finds it damaged in the end). fn woff2_glyph_with(points: u16) -> Vec { - let total = 2 * points as usize; + let [hi, lo] = points.to_be_bytes(); + woff2_glyph_with_contours(2, &[253, hi, lo].repeat(2), 2 * points as usize) + } + + /// As [`woff2_glyph_with`], for a glyph whose contour count is `n` and whose point-count stream + /// is `counts`, with `total` flags. + fn woff2_glyph_with_contours(n: u16, counts: &[u8], total: usize) -> Vec { let mut glyf = vec![0, 0, 0, 0, 0, 1, 0, 0]; // reserved, optionFlags, numGlyphs 1, indexFormat // nContour, nPoints, flag, glyph, composite, bbox and instruction stream sizes. - for size in [2, 6, total, 0, 0, 4, 0] { + for size in [2, counts.len(), total, 0, 0, 4, 0] { glyf.extend_from_slice(&(size as u32).to_be_bytes()); } - glyf.extend_from_slice(&2i16.to_be_bytes()); - let [hi, lo] = points.to_be_bytes(); - glyf.extend_from_slice(&[253, hi, lo, 253, hi, lo]); // 255UInt16 in its word form + glyf.extend_from_slice(&n.to_be_bytes()); + glyf.extend_from_slice(counts); // 255UInt16 point counts glyf.resize(glyf.len() + total, 1); // on-curve flags glyf.extend_from_slice(&[0; 4]); // no explicit bounding boxes let mut dir = vec![10]; // glyf, transform version 0 @@ -801,6 +808,17 @@ pub(crate) mod tests { assert!(matches!(decode_font_file(&woff2_glyph_with(3)), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); } + #[test] + fn a_woff2_glyph_with_a_contour_count_past_i16_max_is_counted() { + // #3703: the decoder reads the contour count as unsigned (only 0xFFFF is a composite), so + // 0x8000..=0xFFFE are that many contours; read as negative they counted no points and the + // decoder then allocated about 1 GB for a 1.3 MB font. + for n in [0x8000u16, 0xFFFE] { + let file = woff2_glyph_with_contours(n, &vec![3; usize::from(n)], 3 * usize::from(n)); + assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())), "{n:#x}"); + } + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db(); From ddda3c46701d38db74ad870bc478bc9b0a1a576b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:09:31 +0000 Subject: [PATCH 67/87] =?UTF-8?q?#3869:=20review=20=E2=80=94=20the=20draw?= =?UTF-8?q?=20that=20turns=20hinting=20off=20is=20redrawn=20unhinted,=20so?= =?UTF-8?q?=20the=20outline=20cache=20never=20keeps=20a=20hinted=20glyph?= =?UTF-8?q?=20from=20a=20tripped=20breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb.rs | 48 +++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2ccb4cc744..8ab5451e8b 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -95,6 +95,8 @@ pub struct FontFace { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] hint_slow: std::sync::atomic::AtomicU32, hinting_off: std::sync::atomic::AtomicBool, + /// A hinted draw slower than this is a strike ([`HINT_STRIKE`]; tests shorten it). + hint_strike: std::time::Duration, } /// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget @@ -233,7 +235,7 @@ impl FontFace { (!pen.over).then_some(pen.path) } fn draw_outline(&self, gid: u32, max_elements: usize) -> FlipPen { - let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false }; + let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false }; if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { @@ -242,11 +244,19 @@ impl FontFace { #[cfg(not(target_arch = "wasm32"))] let started = std::time::Instant::now(); let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); - let drawn = g.draw(settings, &mut pen); + pen.hinted = hinter.is_some(); + let mut drawn = g.draw(settings, &mut pen); #[cfg(not(target_arch = "wasm32"))] - if hinter.is_some() && self.count_hinted(started.elapsed() > HINT_STRIKE) { + if hinter.is_some() && self.count_hinted(started.elapsed() > self.hint_strike) { log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed()); } + // A draw that was hinted when hinting went off (by itself, or by another thread's draw + // meanwhile) is the kind that was slow or misassembled: draw it again unhinted, so + // the outline cache never keeps it (#3692). + if pen.hinted && self.hinting_off.load(Ordering::Relaxed) { + pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false }; + drawn = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen); + } if let Err(e) = drawn { log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); pen.path = BezPath::new(); @@ -1015,6 +1025,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy hint_draws: Default::default(), hint_slow: Default::default(), hinting_off: Default::default(), + hint_strike: HINT_STRIKE, }) } @@ -1996,6 +2007,8 @@ struct FlipPen { path: BezPath, room: usize, over: bool, + /// Drawn with the face's TrueType interpreter. + hinted: bool, } impl FlipPen { @@ -2375,6 +2388,35 @@ mod tests { assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty(), "drawn unhinted"); } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() { + // #3692: the glyph whose slow hinted draw trips the breaker may be misassembled; it must not be + // what the outline cache keeps. + let data = hint_reliant_font(660); + let mut face = super::make_face( + super::FontBytes::Owned(std::sync::Arc::new(data)), + 0, + "MingLiU JBMono".into(), + "Regular".into(), + "MingLiU JBMono".into(), + Vec::new(), + ) + .unwrap(); + assert!(face.hinter().is_some()); + face.hint_strike = std::time::Duration::ZERO; // every hinted draw is slow + let gid = face.glyph_for('a'); + assert!(face.draw_outline(gid, usize::MAX).hinted, "hinted while the breaker is closed"); + assert!(face.draw_outline(gid, usize::MAX).hinted); + let tripping = face.draw_outline(gid, usize::MAX); + assert!(face.hinting_off.load(std::sync::atomic::Ordering::Relaxed), "the third slow draw trips the breaker"); + assert!(!tripping.hinted, "the tripping draw is returned unhinted"); + assert!(!tripping.path.elements().is_empty()); + // And what the cache keeps is that outline. + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements()); + } + #[test] fn named_instances_and_collection_members_are_capped() { // #3716: a file's faces are capped before they are built, however the file multiplies them. From b654049545aa41cae0bd50f4b2c4221c05ea961e Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:30:32 +0000 Subject: [PATCH 68/87] =?UTF-8?q?#3874:=20review=20=E2=80=94=20text=20box?= =?UTF-8?q?=20paste=20charges=20only=20what=20it=20copies;=20stories=20sha?= =?UTF-8?q?ring=20Arc=20blocks=20(PDF=20imports)=20keep=20their=20text=20(?= =?UTF-8?q?#4381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/edit.rs | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 1c5b877666..64a75b4446 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -188,8 +188,9 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec) { const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). const MAX_PASTED_BOXES: usize = 4096; -/// Most text box content (estimated bytes, see [`block_cost`]) one paste may adopt: thousands of -/// boxes can show the same large story, and each needs a story of its own. +/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of +/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes +/// are shared between those stories, not copied, so they are not charged per box. const MAX_PASTED_BOX_BYTES: usize = 32 << 20; /// What a paste may still spend on text box stories. @@ -901,9 +902,16 @@ impl Document { return None; } let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; - // Charge the whole story before copying any of it. Its cost is worked out once per paste - // (that walks its payloads), however many boxes show it. - let cost = *budget.costs.entry(old).or_insert_with(|| src.blocks.iter().fold(0usize, |a, b| a.saturating_add(block_cost(b).0))); + // Charge what the copy really allocates before making any of it: the block slots, and the + // blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many + // boxes show them, so they cost nothing more. Worked out once per paste. + let cost = *budget.costs.entry(old).or_insert_with(|| { + let slots = src.blocks.len().saturating_mul(std::mem::size_of::>()); + src.blocks.iter().fold(slots, |a, b| match block_cost(b) { + (c, true) => a.saturating_add(c), + (_, false) => a, + }) + }); let Some(left) = budget.bytes.checked_sub(cost) else { budget.skipped = budget.skipped.saturating_add(1); return None; @@ -1385,7 +1393,10 @@ mod tests { fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() { // Review of #3874: once one story didn't fit, every later box in the paste was left // empty, however small its story. Only the box that doesn't fit is. - let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default())); + // (Story 1 holds a text box, so each box showing it needs a real copy.) + let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default()); + bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let big = para_block(bp); let small = para_block(Paragraph::with_text("small", CharProps::default())); let mut p = Paragraph::new(); for story in [2, 1, 2, 1] { @@ -1405,6 +1416,25 @@ mod tests { assert_eq!(budget.costs.len(), 2, "each story costed once"); } + #[test] + fn many_boxes_sharing_one_story_paste_with_their_text() { + // Review of #3874: a document converted from PDF has thousands of boxes showing story + // data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB + // cap) but the block is shared, not copied, so every box keeps its text. + let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default())); + let f = fan_out(vec![big.clone()], 64); + let mut d = doc(&["x"]); + let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap()); + assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}"); + let ids: Vec = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect(); + assert_eq!(ids.len(), 64, "every box keeps a story"); + for id in ids { + let b = d.parts[&id].blocks.first().unwrap(); + assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box"); + assert_eq!(d.parts[&id].blocks.len(), 1); + } + } + #[test] fn pasted_box_copies_are_capped_in_bytes() { // Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs From e216e7afc1bd103080f151b3cd9ff9f12b96c412 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:36:16 +0000 Subject: [PATCH 69/87] =?UTF-8?q?#3876:=20review=20=E2=80=94=20docs=20say?= =?UTF-8?q?=20an=20unreadable=20table=20refuses;=20EINTR=20is=20retried;?= =?UTF-8?q?=20the=20huge-table=20test=20drives=20the=20real=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zotero.md and peer.rs module docs: a read error, or an open error other than NotFound, refuses (sandboxes denying /proc/net/tcp: AppArmor, Snap, Flatpak, are refused). - read_line_capped retries ErrorKind::Interrupted like std's read_line. - New scan_tables (what owner_check calls) takes the table opener; the 70 MiB test drives it, so reinstating take(64 MiB) there fails the test. Added tests for EINTR and a denied open. - Row parsing uses split_whitespace().nth() instead of collecting a Vec. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 125 +++++++++++++++++++++++++++++--------- docs/zotero.md | 6 +- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 68e2185364..3175487a05 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -4,9 +4,10 @@ //! - **Every system:** WordCraft only connects to this computer (a loopback address). //! - **Linux only:** the program listening there must not run as another user, from the //! kernel's socket table (`/proc/net/tcp`): another account on a shared machine that took the -//! port while Zotero wasn't running is refused. A listener the table doesn't show (WSL2 -//! mirrored networking with Zotero on Windows, a local proxy, a sandbox) leaves only the -//! loopback rule. +//! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 +//! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, +//! but only when the tables were read in full: a read error, or an open error other than +//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -38,19 +39,7 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { /// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::BufReader; - let mut rows = Rows::default(); - for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - // A missing file (no IPv6) is an absent table; any other failure is a refusal. - match std::fs::File::open(path) { - Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(unreadable(&e)), - } - if rows.done() { - break; - } - } + let rows = scan_tables(["/proc/net/tcp", "/proc/net/tcp6"], |p| std::fs::File::open(p), local, peer)?; // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); decide(&rows, peer, me) @@ -63,6 +52,28 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } +/// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are +/// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +fn scan_tables( + paths: [&str; 2], + open: impl Fn(&str) -> std::io::Result, + local: SocketAddr, + peer: SocketAddr, +) -> Result { + let mut rows = Rows::default(); + for path in paths { + match open(path) { + Ok(f) => scan_table(std::io::BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; + } + } + Ok(rows) +} + /// The uids found for the two rows of one connection. #[derive(Default)] struct Rows { @@ -96,8 +107,8 @@ fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketA return Ok(()); } let line = String::from_utf8_lossy(&line); - let f: Vec<&str> = line.split_whitespace().take(8).collect(); - let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { + let mut f = line.split_whitespace(); + let (Some(l), Some(r), Some(uid)) = (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.nth(4)) else { continue; }; let Ok(uid) = uid.parse::() else { continue }; @@ -118,7 +129,11 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io const CAP: usize = 4096; let mut total = 0; loop { - let buf = r.fill_buf()?; + let buf = match r.fill_buf() { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; if buf.is_empty() { return Ok(total); } @@ -282,21 +297,75 @@ mod tests { } } + /// Repeats `unit` for `left` bytes, then ends: a huge table without holding it in memory. + struct Table { + parts: Vec<(Vec, usize)>, + part: usize, + at: usize, + } + impl std::io::Read for Table { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let Some((unit, times)) = self.parts.get(self.part) else { return Ok(0) }; + if self.at >= unit.len() * times { + self.part += 1; + self.at = 0; + return self.read(buf); + } + let n = buf.len().min(unit.len() - self.at % unit.len()); + let off = self.at % unit.len(); + buf[..n].copy_from_slice(&unit[off..off + n]); + self.at += n; + Ok(n) + } + } + /// The real attack: a hostile user floods the table with listener rows so the connection's - /// rows sit past any fixed cap. Streaming finds them wherever they are. + /// rows sit past any fixed cap. The real read path (`scan_tables`, which `owner_check` + /// calls on `/proc/net/tcp*`) finds them wherever they are. #[test] fn rows_far_into_a_huge_table_are_still_found() { let (local, peer) = addrs(); let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); - let mut table = String::from(HEADER); - // About 70 MiB of rows, past the old 64 MiB cap. - for _ in 0..(70 * 1024 * 1024 / filler.len()) { - table.push_str(&filler); + // More than 64 MiB of rows (the old cap), then the connection's two rows. + let times = 70 * 1024 * 1024 / filler.len(); + assert!(filler.len() * times > 64 * 1024 * 1024); + let tail = format!("{}{}", row(local, peer, 1000), row(peer, local, 1001)).into_bytes(); + let open = |p: &str| match p { + "t4" => { + Ok(Table { parts: vec![(HEADER.as_bytes().to_vec(), 1), (filler.clone().into_bytes(), times), (tail.clone(), 1)], part: 0, at: 0 }) + } + _ => Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + let rows = scan_tables(["t4", "t6"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(1000), Some(1001))); + assert!(matches!(decide(&rows, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A signal interrupting a read is retried, not taken for an unreadable table. + #[test] + fn an_interrupted_read_is_retried() { + struct Eintr(bool, std::io::Cursor>); + impl std::io::Read for Eintr { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !std::mem::replace(&mut self.0, true) { + return Err(std::io::ErrorKind::Interrupted.into()); + } + self.1.read(buf) + } } - table.push_str(&row(local, peer, 1000)); - table.push_str(&row(peer, local, 1001)); - assert!(table.len() > 64 * 1024 * 1024); - assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + let (local, peer) = addrs(); + let data = format!("{HEADER}{}{}", row(local, peer, 7), row(peer, local, 7)).into_bytes(); + let open = |_: &str| Ok(Eintr(false, std::io::Cursor::new(data.clone()))); + let rows = scan_tables(["a", "b"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(7), Some(7))); + } + + /// A sandbox that denies the table (not "not found") refuses. + #[test] + fn a_table_that_cannot_be_opened_is_refused() { + let (local, peer) = addrs(); + let open = |_: &str| -> std::io::Result<&[u8]> { Err(std::io::ErrorKind::PermissionDenied.into()) }; + assert!(matches!(scan_tables(["a", "b"], open, local, peer), Err(ZoteroError::Refused(_)))); } /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". diff --git a/docs/zotero.md b/docs/zotero.md index cf5813494c..9a0eb14fb3 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -74,8 +74,10 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t program listening on the port runs as the same user as WordCraft, before sending anything. If another account on a shared machine took the port while Zotero wasn't running, WordCraft refuses with an error and the document isn't touched. When the table doesn't show the - listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox) - or can't be read, its owner is unknown and only the loopback rule applies, as below. + listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner + is unknown and only the loopback rule applies, as below. That holds only when the tables were + read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes + that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a From ed1296a1d4a1c72618a69bec8c45341aebe9a868 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:37:10 +0000 Subject: [PATCH 70/87] #3871 follow-up: .doc picture decode charges zlib input and DIB copies; budget scales with the file (#4382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #3871: review — metafile input charged to the decode budget; inline BLIPs deduped; budget scales with input - A compressed metafile is charged max(inflated size, zlib input), so a record claiming to inflate to nothing still costs its input. - Inline pictures share one decode per BLIP record, cached by (stream, foDelay, record's own length) instead of the FBSE size field. - The decode budget is max(512 MB, 64 x input bytes), capped at 2 GiB, so documents of many large legitimate DIBs decode; high-ratio bombs stay capped. - Decode-budget refusals get their own log line. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3871: review — a DIB's copy into a BMP is charged to the decode budget before it is made The charge was w*h*4 after the copy, so a 1x1 header followed by megabytes of tail cost 4 bytes per picture. It is now max(pixels, DIB length), charged first; a bitmap whose copy doesn't fit is dropped rather than copied. The doc comment on the budget scale now says RLE8/RLE4 bitmaps (about 510x) rely on the 512 MB floor. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/docbin/src/lib.rs | 2 +- crates/docbin/src/media.rs | 59 +++++++---- crates/docbin/src/officeart.rs | 81 +++++++++++---- crates/docbin/src/tests/drawing.rs | 158 +++++++++++++++++++++++++---- 4 files changed, 241 insertions(+), 59 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 845171251a..7734c72220 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -259,7 +259,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result usize { + input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING)) +} /// The document's pictures, keyed by their location in the Data stream: anchors that share a /// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a @@ -32,16 +46,17 @@ pub(crate) struct Pictures { by_fc: HashMap>, /// BLIP store index → media key (`None`: unreadable, not retried). by_bse: HashMap>, - /// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias - /// one record share one decode (`None`: failed, not retried). - by_delay: HashMap<(usize, usize), Option>, + /// (stream, foDelay, record length) of a BLIP record outside the store → media key: store + /// entries and inline pictures that alias one record share one decode (`None`: failed, not + /// retried). The stream is told apart by its address. + by_delay: HashMap<(usize, usize, usize), Option>, /// The media entries, merged into the document at the end. pub(crate) media: BTreeMap>>, total: usize, /// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests). pub(crate) limit: usize, /// Decode work left (`MAX_DECODE_WORK`; smaller in tests). - pub(crate) work: usize, + pub(crate) work: officeart::Decode, /// Anchors whose picture was dropped (only the first few are logged). dropped: usize, /// Pictures kept as placeholders (formats we can't draw). @@ -57,7 +72,7 @@ impl Default for Pictures { media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, - work: MAX_DECODE_WORK, + work: officeart::Decode::new(MAX_DECODE_WORK), dropped: 0, placeholders: 0, } @@ -75,6 +90,12 @@ pub(crate) struct Inline { } impl Pictures { + /// An empty store whose decode budget scales with the `input` stream bytes (WordDocument + /// plus Data) the pictures can come from. + pub(crate) fn for_input(input: usize) -> Self { + Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() } + } + /// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time. /// `None` when the header is malformed, the picture can't be read, or a picture /// count/byte cap is reached. @@ -96,16 +117,13 @@ impl Pictures { if room == 0 { return None; } - match locate(data, fc_pic, room, &mut self.work)? { + match locate(data, fc_pic, self)? { Found::Range(pic) => { let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec(); let key = self.insert(bytes, pic.ext, false)?; Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }) } - Found::Shape { w, h, blip, props } => { - let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?; - Some(Inline { key, w, h, crop: props.crop, alt: props.alt }) - } + Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }), } } @@ -127,6 +145,7 @@ impl Pictures { /// Decode a store entry's BLIP, once per delay-stream record however many entries name it. fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option { let (rec, at) = officeart::fbse_record(fbse, delay)?; + let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used)); if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) { return hit.clone(); } @@ -174,7 +193,7 @@ struct Located { /// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded. enum Found { Range(Located), - Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props }, + Shape { w: f32, h: f32, key: Option, props: officeart::Props }, } /// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file @@ -183,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064; const MM_SHAPEFILE: u16 = 0x0066; /// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData); -/// a shape's picture is decoded only up to `room` bytes. -fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option { +/// the BLIP is decoded once per record and charged to the decode budget. +fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option { let base = fc_pic as usize; let pic = data.get(base..)?; let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; @@ -203,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option Option Option<(officeart::Blip, officeart::Props)> { +/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded +/// once per record, however many pictures (and FBSE sizes) name it. +fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option, officeart::Props)> { let mut budget = 10_000usize; let recs = officeart::children(buf, &mut budget); let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; - let blip = officeart::fbse_blip(fbse, data, room, work)?; - Some((blip, sp.props)) + let key = pics.decode_bse(fbse, data); + Some((key, sp.props)) } /// Offset of the first embedded raster image inside the payload, skipping the small diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs index 84d68b9d7f..6d35d929d9 100644 --- a/crates/docbin/src/officeart.rs +++ b/crates/docbin/src/officeart.rs @@ -112,16 +112,50 @@ pub(crate) struct Blip { /// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay` /// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for /// inline pictures). `limit` caps the bytes produced. -pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut usize) -> Option { +#[cfg(test)] +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option { let (rec, _) = fbse_record(fbse, delay)?; blip(&rec, limit, work) } +/// The document's decode budget: bytes of inflating and pixel decoding left, and how many +/// attempts it has refused (only the first few are logged). +pub(crate) struct Decode { + pub(crate) left: usize, + refused: usize, +} + +impl Decode { + pub(crate) fn new(left: usize) -> Self { + Decode { left, refused: 0 } + } + + /// Take `cost` off the budget; `false` (and a log line) when it doesn't fit. + fn charge(&mut self, cost: usize, what: &str) -> bool { + match self.left.checked_sub(cost) { + Some(left) if cost > 0 => { + self.left = left; + true + } + _ => { + if self.refused < 10 { + log::warn!( + "docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded", + self.left + ); + } + self.refused = self.refused.saturating_add(1); + false + } + } + } +} + /// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where -/// its (`foDelay`, end) location is returned too (store entries can share one record). +/// its (`foDelay`, record length) location is returned too (entries can share one record, +/// whatever sizes their FBSEs claim: the record's own header is what counts). pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> { // btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2. - let size = u32_at(fbse, 20)? as usize; let fo_delay = u32_at(fbse, 28)? as usize; let cb_name = *fbse.get(33)? as usize; let at = 36usize.checked_add(cb_name)?; @@ -129,15 +163,14 @@ pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a return Some((rec, None)); } // Not embedded: the record sits in the delay stream. - let end = fo_delay.checked_add(size.max(8))?.min(delay.len()); - let (rec, _) = record(delay.get(fo_delay..end)?)?; - Some((rec, Some((fo_delay, end)))) + let (rec, used) = record(delay.get(fo_delay..)?)?; + Some((rec, Some((fo_delay, used)))) } /// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the /// document's decode budget: inflating a metafile or decoding a DIB is charged to it before /// it runs, whether it succeeds or not, and nothing is decoded once it is spent. -pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { +pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option { // Each type has two instance values; the odd one carries a second 16-byte UID. let uids = if rec.inst & 1 == 1 { 32 } else { 16 }; match rec.typ { @@ -163,11 +196,12 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { let raw = raw.get(..cb_save.min(raw.len()))?; let cap = limit.min(MAX_METAFILE); let bytes = if compression == 0 { - let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work); - if want == 0 { + let want = if cb_size > 0 { cb_size.min(cap) } else { cap }; + // The compressed stream is read whole whatever it inflates to: a record that + // claims to inflate to nothing still costs its input (#3871). + if !work.charge(want.max(raw.len()), "metafile") { return None; } - *work -= want; miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()? } else { if raw.len() > cap { @@ -192,7 +226,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { /// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the /// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the /// BMP bytes are kept (and render as a grey placeholder). -fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { +fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option { let header = u32_at(dib, 0)? as usize; if !(12..=124).contains(&header) { return None; @@ -211,6 +245,20 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 }; let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?; let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?; + // The declared size is what decoding allocates, however small the (RLE) data is. + let (w, h) = if header == 12 { + (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) + } else { + (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) + }; + let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX); + // Copying the DIB into a BMP is work too, however few pixels it declares: it is charged + // before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP + // bytes as a placeholder, if the copy fits. + let decode = work.charge(cost.max(dib.len()), "bitmap"); + if !decode && !work.charge(dib.len(), "bitmap") { + return None; + } let mut bmp = Vec::with_capacity(file_size); bmp.extend_from_slice(b"BM"); bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes()); @@ -218,16 +266,9 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes()); bmp.extend_from_slice(dib); let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }); - // The declared size is what decoding allocates, however small the (RLE) data is. - let (w, h) = if header == 12 { - (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) - } else { - (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) - }; - let Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else { + if !decode { return placeholder(bmp); - }; - *work = left; + } let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp)); reader.set_format(image::ImageFormat::Bmp); let mut limits = image::Limits::default(); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 041267c16c..b754206320 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]); let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (dib_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&dib_rec, 1 << 20, &mut { usize::MAX }).expect("DIB decodes"); + let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes"); assert_eq!(blip.ext, "png"); let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0])); @@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat()); let (wmf_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&wmf_rec, 1 << 20, &mut { usize::MAX }).expect("WMF inflates"); + let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates"); assert_eq!((blip.ext, blip.bytes), ("wmf", wmf)); } @@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() { hdr.extend_from_slice(&[0, 0]); let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat()); let (emf, _) = officeart::record(&r).unwrap(); - assert!(officeart::blip(&emf, 1 << 20, &mut { usize::MAX }).is_none()); + assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); let mut fbse = vec![0u8; 36]; fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut { usize::MAX }).is_none()); + assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); } proptest! { @@ -322,12 +322,12 @@ fn dib_conversion_caps_header_and_encoded_bytes() { let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (record, _) = officeart::record(&record).unwrap(); let bmp_size = 14 + dib.len(); - assert!(officeart::blip(&record, bmp_size - 1, &mut { usize::MAX }).is_none(), "the BMP header also needs room"); - let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).expect("the BMP fallback fits exactly"); + assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room"); + let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly"); assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size)); let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30])); - let normal = officeart::blip(&record, 512, &mut { usize::MAX }).expect("normal conversion fits"); + let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits"); assert_eq!((normal.ext, normal.placeholder), ("png", false)); assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); @@ -434,10 +434,10 @@ fn store_entries_aliasing_one_delayed_metafile_decode_it_once() { let fbse = delayed_fbse(delay.len(), 0); let store: Vec<&[u8]> = vec![&fbse; 5_000]; let mut pics = crate::media::Pictures::default(); - pics.work = 8 << 20; + pics.work = officeart::Decode::new(8 << 20); let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect(); assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture"); - assert_eq!(pics.work, (8 << 20) - wmf.len(), "inflated once"); + assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once"); } /// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so @@ -456,11 +456,11 @@ fn failing_metafile_inflations_spend_the_decode_budget() { .collect(); let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect(); let mut pics = crate::media::Pictures::default(); - pics.work = 3 << 20; + pics.work = officeart::Decode::new(3 << 20); for pib in 1..=store.len() as u32 { assert!(pics.bse(pib, &store, &delay).is_none()); } - assert_eq!(pics.work, 0, "three attempts spent the budget; the rest weren't tried"); + assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried"); } /// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but @@ -470,12 +470,13 @@ fn dib_decoding_is_charged_its_declared_pixels() { let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()); let one = blip_of(&one_pixel_dib()); let (one, _) = officeart::record(&one).unwrap(); - let mut work = 3; - assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); - assert_eq!(work, 3, "nothing decoded, nothing charged"); - let mut work = 4; + let mut work = officeart::Decode::new(3); + assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied"); + assert_eq!(work.left, 3, "nothing decoded, nothing charged"); + let dib_len = one.body.len() - 17; + let mut work = officeart::Decode::new(dib_len); assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false))); - assert_eq!(work, 0); + assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel"); // 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes. let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::>(); @@ -489,7 +490,128 @@ fn dib_decoding_is_charged_its_declared_pixels() { big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap let big = blip_of(&big); let (big, _) = officeart::record(&big).unwrap(); - let mut work = 512 << 20; + let mut work = officeart::Decode::new(512 << 20); assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); - assert_eq!(work, 512 << 20); + assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels"); +} + +/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`. +fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec { + let mut hdr = vec![0u8; 34]; + hdr[0..4].copy_from_slice(&cb_size.to_le_bytes()); + hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes()); + hdr[32] = 0; // compressed + hdr[33] = 0xFE; + rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat()) +} + +/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to. +#[test] +fn compressed_metafile_input_is_charged_to_the_decode_budget() { + let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same + let r = emf_blip(1, &raw); + let (emf, _) = officeart::record(&r).unwrap(); + let mut work = officeart::Decode::new(10 << 20); + assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to"); + // Ten more of them exhaust the budget; none is inflated past it. + let mut work = officeart::Decode::new(3 << 20); + for _ in 0..10 { + let _ = officeart::blip(&emf, 64 << 20, &mut work); + } + assert_eq!(work.left, 0, "three attempts fit, the rest are refused"); +} + +/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the +/// FBSE claims `size` bytes. +fn inline_pic(fo_delay: u32, size: u32) -> Vec { + let mut fbse = vec![0u8; 36]; + fbse[20..24].copy_from_slice(&size.to_le_bytes()); + fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes()); + let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat(); + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); + picf[30..32].copy_from_slice(&1440i16.to_le_bytes()); + [picf, body].concat() +} + +/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes +/// their FBSEs claim and wherever their PICFs sit. +#[test] +fn inline_pictures_aliasing_one_blip_record_are_decoded_once() { + let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]); + let a = inline_pic(0, 70_000); + let b = inline_pic(0, 80_000); + let c = inline_pic(0, 90_000); + let blip_at = (a.len() + b.len() + c.len()) as u32; + let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000)); + let data = [a.clone(), b.clone(), c, blip].concat(); + let mut pics = crate::media::Pictures::default(); + let start = pics.work.left; + let _ = pics.get(&data, 0); + let _ = pics.get(&data, a.len() as u32); + let _ = pics.get(&data, (a.len() + b.len()) as u32); + assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge"); +} + +/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data. +fn dib_1bpp(side: u32, bytes: usize) -> Vec { + let mut d = vec![0u8; 40]; + d[0..4].copy_from_slice(&40u32.to_le_bytes()); + d[4..8].copy_from_slice(&side.to_le_bytes()); + d[8..12].copy_from_slice(&side.to_le_bytes()); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[14..16].copy_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette + d.extend(std::iter::repeat_n(0u8, bytes)); + d +} + +fn dib_record(dib: &[u8]) -> Vec { + rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()) +} + +/// #3871 review: the decode budget scales with the input, so a document of many legitimate +/// large scans decodes them all; a high-ratio set is still capped. +#[test] +fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() { + let floor = 1 << 20; + // 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each. + let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8)); + let (rec_real, _) = officeart::record(&real).unwrap(); + let input = 20 * real.len(); + let mut work = officeart::Decode::new(crate::media::decode_work(input, floor)); + for i in 0..20 { + let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap(); + assert!(!b.placeholder, "scan {i} decodes"); + } + // Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded. + let bomb = dib_record(&dib_1bpp(20_000, 16)); + let (rec_bomb, _) = officeart::record(&bomb).unwrap(); + let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor)); + for _ in 0..20 { + assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder); + } + assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented"); +} + +/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels +/// the header declares; with the budget spent nothing is copied at all. +#[test] +fn a_dib_with_a_large_tail_is_charged_for_its_copy() { + let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes + let (big, _) = officeart::record(&big).unwrap(); + let dib_len = big.body.len() - 17; + let mut work = officeart::Decode::new(10 << 20); + assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder); + assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel"); + // Room for the copy: decoded. Not even that: nothing is copied. + let mut work = officeart::Decode::new(dib_len + 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_some()); + let mut work = officeart::Decode::new(dib_len - 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, dib_len - 1); } From 328a58dd038a0121525a26c4cb221751c84b50a7 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:43:10 +0000 Subject: [PATCH 71/87] =?UTF-8?q?#3869:=20review=20=E2=80=94=20hint=5Fstri?= =?UTF-8?q?ke=20is=20native-only=20like=20HINT=5FSTRIKE=20(wasm=20build);?= =?UTF-8?q?=20composite=20and=200x7FFF=20contour-count=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb.rs | 2 ++ crates/fonts/src/fontdb/user.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8ab5451e8b..9e8472bc30 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -96,6 +96,7 @@ pub struct FontFace { hint_slow: std::sync::atomic::AtomicU32, hinting_off: std::sync::atomic::AtomicBool, /// A hinted draw slower than this is a strike ([`HINT_STRIKE`]; tests shorten it). + #[cfg(not(target_arch = "wasm32"))] hint_strike: std::time::Duration, } @@ -1025,6 +1026,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy hint_draws: Default::default(), hint_slow: Default::default(), hinting_off: Default::default(), + #[cfg(not(target_arch = "wasm32"))] hint_strike: HINT_STRIKE, }) } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 8e0da9e524..f7cd0fd918 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -813,12 +813,20 @@ pub(crate) mod tests { // #3703: the decoder reads the contour count as unsigned (only 0xFFFF is a composite), so // 0x8000..=0xFFFE are that many contours; read as negative they counted no points and the // decoder then allocated about 1 GB for a 1.3 MB font. - for n in [0x8000u16, 0xFFFE] { + for n in [0x7FFFu16, 0x8000, 0xFFFE] { let file = woff2_glyph_with_contours(n, &vec![3; usize::from(n)], 3 * usize::from(n)); assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())), "{n:#x}"); } } + #[test] + fn a_woff2_composite_glyph_is_not_counted_as_having_points() { + // 0xFFFF marks a composite glyph: the point check skips it (the decoder reads its own + // streams), so it is not refused for its points. + let file = woff2_glyph_with_contours(0xFFFF, &[], 0); + assert!(matches!(decode_font_file(&file), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db(); From 45c4197bc16da9069c0936b4c029b74b9aea3bf5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:44:26 +0000 Subject: [PATCH 72/87] Restrict Editing: the protection list and password fields carry their labels' names --- crates/ui-egui/src/dialogs_review.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 7cb3e9e13f..b1f38ccb9e 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -643,14 +643,16 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { }); } heading(ui, "2. Editing restrictions"); - ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); + let editing = ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); ui.add_enabled_ui(st.editing, |ui| { let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or(""); - egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { - for (i, (_, label)) in EDIT_MODES.iter().enumerate() { - ui.selectable_value(&mut st.mode, i, tl!(label)); - } - }); + let combo = + egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { + for (i, (_, label)) in EDIT_MODES.iter().enumerate() { + ui.selectable_value(&mut st.mode, i, tl!(label)); + } + }); + combo.response.labelled_by(editing.id); }); let mode = EDIT_MODES.get(st.mode).map(|m| m.0).unwrap_or("readOnly"); if st.editing && matches!(mode, "readOnly" | "comments") { @@ -718,11 +720,11 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { /// Each label above its field, so both fit the narrowest pane. fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) { let width = ui.available_width().min(220.0); - ui.label(tl!("Password:")); - ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)); + let label = ui.label(tl!("Password:")); + ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); if confirm { - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)); + let label = ui.label(tl!("Confirm password:")); + ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); } } From 8bc825825f7fc9c2da839d8b3c3f8e7b40e8bf77 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:50:03 +0000 Subject: [PATCH 73/87] =?UTF-8?q?#3876:=20review=20=E2=80=94=20scan=5Ftabl?= =?UTF-8?q?es=20allowed=20unused=20off=20Linux;=20sandbox=20and=20empty-ta?= =?UTF-8?q?ble=20comments=20match=20the=20refusal=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/zotero/src/peer.rs | 9 +++++---- docs/zotero.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 3175487a05..09abd8de43 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -7,7 +7,7 @@ //! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 //! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, //! but only when the tables were read in full: a read error, or an open error other than -//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. +//! "not found" (e.g. a sandbox profile that denies `/proc/net/tcp`), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -54,6 +54,7 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> /// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are /// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] fn scan_tables( paths: [&str; 2], open: impl Fn(&str) -> std::io::Result, @@ -153,7 +154,7 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io /// Refuse when the connection's rows show another user. Our uid is our own socket's row, else /// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, -/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +/// a proxy) or our uid is unknown, its owner is unknown and the loopback rule decides. fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), @@ -248,14 +249,14 @@ mod tests { assert_eq!(same_owner(&junk, local, peer, None), Ok(())); } - /// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: the listener + /// WSL2 mirrored networking (Zotero on Windows) or a local proxy: the listener /// isn't in our table, so the loopback rule decides, as on other systems. #[test] fn an_unlisted_listener_falls_back_to_the_loopback_rule() { let (local, peer) = addrs(); let missing = format!("{HEADER}{}", row(local, peer, 1000)); assert_eq!(same_owner(&missing, local, peer, None), Ok(())); - // The table couldn't be read. + // An empty table: the listener isn't listed. assert_eq!(same_owner("", local, peer, None), Ok(())); assert_eq!(same_owner("", local, peer, Some(1000)), Ok(())); } diff --git a/docs/zotero.md b/docs/zotero.md index 9a0eb14fb3..4e96520ad5 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -77,7 +77,7 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner is unknown and only the loopback rule applies, as below. That holds only when the tables were read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes - that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. + that deny `/proc/net/tcp` (some AppArmor or Snap profiles, for example) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a From e9dd2520965527cdd12838ee38124191d74b805f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:46:53 +0000 Subject: [PATCH 74/87] =?UTF-8?q?#3866:=20review=20=E2=80=94=20PDF=20expor?= =?UTF-8?q?t=20builds=20the=20revision=20authors=20and=20clipped=20cells?= =?UTF-8?q?=20once=20per=20page;=20part=5Fpieces=20finds=20a=20paragraph's?= =?UTF-8?q?=20first=20page=20from=20one=20index;=20grid=5Fcols=20once;=20b?= =?UTF-8?q?alloon=20colour=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/display.rs | 72 ++++++++++--- crates/layout/src/hit.rs | 51 ++++++--- crates/layout/src/table.rs | 6 +- crates/layout/src/tests_linear.rs | 171 ++++++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 48 +-------- crates/pdf/src/tests.rs | 42 +------- 6 files changed, 278 insertions(+), 112 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index ca83e65f82..827ad8f734 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -533,12 +533,42 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec { + doc: &'a Document, + page: &'a Page, + authors: Authors<'a>, + clips: Clips, +} + +impl<'a> ItemDisplay<'a> { + pub fn new(doc: &'a Document, page: &'a Page) -> Self { + let cells: Vec = + page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })).cloned().collect(); + ItemDisplay { doc, page, authors: Authors::new(doc), clips: clipped_cells(&cells) } + } + + /// The draw items of `it` alone, as [`page_display`] gives them for a page holding just it + /// (and, for a line in a clipped cell, the cell's clip). + pub fn draws(&self, it: &Placed, opts: &DisplayOptions) -> Vec { + let mut out = Vec::new(); + let items = std::slice::from_ref(it); + let (_, ba) = dimming(opts); + stories_display(self.doc, &[items], Some(&self.clips), opts, &self.authors, ba, &mut out); + body_change_bars(self.doc, self.page, items, opts, ba, &mut out); + out + } +} + /// A page's draw items as two layers, its headers and footers then its body, each at full /// strength with the opacity to draw it at. A dimmed layer is faded as one picture, as Word dims /// it: where its lines or fills overlap (a table's borders meeting) they don't darken each other. @@ -546,9 +576,9 @@ pub fn page_layers(doc: &Document, page: &Page, opts: &DisplayOptions) -> [(f32, let (ha, ba) = dimming(opts); let (mut hf, mut body) = (Vec::new(), Vec::new()); let authors = Authors::new(doc); - stories_display(doc, &[&page.header, &page.footer], opts, &authors, 1.0, &mut hf); - stories_display(doc, &[&page.items], opts, &authors, 1.0, &mut body); - body_change_bars(doc, page, opts, 1.0, &mut body); + stories_display(doc, &[&page.header[..], &page.footer[..]], None, opts, &authors, 1.0, &mut hf); + stories_display(doc, &[&page.items[..]], None, opts, &authors, 1.0, &mut body); + body_change_bars(doc, page, &page.items, opts, 1.0, &mut body); [(ha, hf), (ba, body)] } @@ -558,18 +588,34 @@ fn dimming(opts: &DisplayOptions) -> (f32, f32) { (a(opts.dim_header), a(opts.dim_body)) } -fn body_change_bars(doc: &Document, page: &Page, opts: &DisplayOptions, alpha: f32, out: &mut Vec) { +fn body_change_bars(doc: &Document, page: &Page, items: &[Placed], opts: &DisplayOptions, alpha: f32, out: &mut Vec) { if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None { - change_bars(doc, page, &opts.revisions, alpha, out); + change_bars(doc, page, items, &opts.revisions, alpha, out); } } /// Draw items for the placed items of `stories`, faded by `a`. -fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptions, authors: &Authors, a: f32, out: &mut Vec) { +/// `shared` gives the page's clipped cells when the caller worked them out already. +fn stories_display( + doc: &Document, + stories: &[&[Placed]], + shared: Option<&Clips>, + opts: &DisplayOptions, + authors: &Authors, + a: f32, + out: &mut Vec, +) { let hidden = opts.hide_object.as_ref().map(|p| Hidden::new(doc, p)); for items in stories { let warps = warped_boxes(doc, items); - let clips = clipped_cells(items); + let own; + let clips = match shared { + Some(c) => c, + None => { + own = clipped_cells(items); + &own + } + }; let mut done: Vec = Vec::new(); for it in items.iter() { // The object being dragged is drawn by its preview instead: leave it out here. @@ -577,7 +623,7 @@ fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptio continue; } if let Placed::Lines { story, path, .. } = it - && let Some(rect) = cell_clip(&clips, *story, &path.0) + && let Some(rect) = cell_clip(clips, *story, &path.0) { let mut inner = Vec::new(); item(doc, it, opts, authors, a, &mut inner); @@ -726,7 +772,7 @@ pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50); /// Bars in the margin beside body lines with tracked insertions or deletions, or (when shown) /// tracked formatting changes of their text, paragraph or paragraph mark. -fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: &mut Vec) { +fn change_bars(doc: &Document, page: &Page, items: &[Placed], m: &MarkupOptions, alpha: f32, out: &mut Vec) { // Outside: the left margin, or the outer one of a right-hand page with mirrored margins. let right = match m.changed_lines { ChangeBar::Right => true, @@ -735,7 +781,7 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: }; let bx = if right { page.body.right() + 9.0 } else { page.body.x - 9.0 }; let color = m.changed_lines_color.unwrap_or(CHANGE_BAR); - for it in &page.items { + for it in items { let Placed::Lines { story, path, para: pl, l0, l1, y, turn, .. } = it else { continue }; if turn.is_turned() { continue; diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index bb0955ad8b..2b1bb66d4c 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -147,17 +147,40 @@ fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option { struct PartPages<'a> { story: StoryRef, pages: HashMap>>, + /// The first page that shows each paragraph, made the first time one is asked for, in one + /// walk of the pages (not one walk of them for every paragraph). + first: Option>, } impl<'a> PartPages<'a> { fn new(story: StoryRef) -> Self { - PartPages { story, pages: HashMap::new() } + PartPages { story, pages: HashMap::new(), first: None } + } + + /// The first page of `lay` that shows a piece of the paragraph at `path`. + fn first_page(&mut self, lay: &'a DocLayout, path: &Path) -> Option { + let story = self.story; + let first = self.first.get_or_insert_with(|| { + let mut first: HashMap<&'a Path, usize> = HashMap::new(); + for (pi, p) in lay.pages.iter().enumerate() { + for it in items_of(p, story) { + if let Placed::Lines { story: s, path, .. } = it + && *s == story + { + first.entry(path).or_insert(pi); + } + } + } + first + }); + first.get(path).copied() } /// The pieces of the paragraph at `path` on page `pi` of `lay`, as [`DocLayout::pieces_on`] /// gives them. fn on(&mut self, lay: &'a DocLayout, pi: usize, path: &Path) -> Vec<(usize, &'a Placed)> { let Some(p) = lay.pages.get(pi) else { return Vec::new() }; + crate::work::steps(1); let story = self.story; let index = self.pages.entry(pi).or_insert_with(|| { let mut index: HashMap<&'a Path, Vec<&'a Placed>> = HashMap::new(); @@ -460,21 +483,24 @@ impl DocLayout { .map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect()) .unwrap_or_default(); } - self.part_pieces(page_hint, &mut |pi| self.pieces_on(pi, story, path)) + let mut first = || (0..self.pages.len()).find(|pi| !self.pieces_on(*pi, story, path).is_empty()); + self.part_pieces(page_hint, &mut first, &mut |pi| self.pieces_on(pi, story, path)) } /// Pieces of a paragraph of a story part (see [`Self::pieces`]), `on` giving those on a page. - fn part_pieces<'a>(&'a self, page_hint: usize, on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>) -> Vec<(usize, &'a Placed)> { + fn part_pieces<'a>( + &'a self, + page_hint: usize, + first: &mut dyn FnMut() -> Option, + on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>, + ) -> Vec<(usize, &'a Placed)> { // Headers/footers repeat: prefer the hinted page, else the first that shows the part. - let mut order: Vec = (0..self.pages.len()).collect(); - if page_hint < order.len() { - order.retain(|p| *p != page_hint); - order.insert(0, page_hint); - } - for pi in order { - let mut found = on(pi); + let hinted = if page_hint < self.pages.len() { on(page_hint) } else { Vec::new() }; + let start = if hinted.is_empty() { first() } else { Some(page_hint) }; + if let Some(pi) = start { + let mut found = if hinted.is_empty() { on(pi) } else { hinted }; if found.is_empty() { - continue; + return found; } // A note continued over pages has the rest of the paragraph on the pages before or // after (a repeated header shows all of it on every page: one page is enough). @@ -517,7 +543,8 @@ impl DocLayout { if pages.story == StoryRef::Body { return self.pieces(StoryRef::Body, path, page_hint); } - self.part_pieces(page_hint, &mut |pi| pages.on(self, pi, path)) + let first = pages.first_page(self, path); + self.part_pieces(page_hint, &mut || first, &mut |pi| pages.on(self, pi, path)) } /// Pieces of a paragraph of story part `story` on page `pi`. diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 169eda6b9f..08e50361cc 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1129,7 +1129,9 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText { /// with their own. `None` when every cell is empty. fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option> { let margins_def = default_margins(t, style); - let mut cols = vec![(0.0f32, 0.0f32); grid_cols(t).max(1)]; + // The grid's width once: counting it walks every row. + let ncols = grid_cols(t); + let mut cols = vec![(0.0f32, 0.0f32); ncols.max(1)]; let mut spanning = Vec::new(); let mut any = false; for (ri, row) in t.rows.iter().enumerate() { @@ -1151,8 +1153,6 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep } spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } - // The grid's width once: counting it walks every row, and there can be a spanning cell in each. - let ncols = grid_cols(t); for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue }; let n = cs.len().max(1) as f32; diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index e49409693c..b242b00c3c 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -201,6 +201,35 @@ fn selecting_a_long_header_takes_linear_time() { assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); } +/// An endnote of `n` paragraphs, a few lines each, continued over many pages. +fn long_endnote(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as + // the paragraphs and each paragraph costs next to nothing. + let huge = CharProps { size: Some(400.0), ..Default::default() }; + let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); + let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; + d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); + (d, id) +} + +/// #3866: selecting across an endnote over many pages probed the pages from the first for each +/// paragraph (paragraphs x pages). +#[test] +fn selecting_a_long_endnote_takes_linear_time() { + let laid = |n: usize| { + let (d, id) = long_endnote(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 20); + assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); + assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + /// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its /// (empty) paragraph and the whole table fits on the first page. fn clipped_table(n: usize) -> Document { @@ -294,3 +323,145 @@ fn balloons_for_many_authors_lay_out_in_linear_time() { let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; assert_layout_linear("comment balloons", 120, doc, &opts); } + +/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. +fn tracked_paras(n: usize) -> Document { + let mut d = Document::from_text("x"); + d.body = (0..n) + .map(|i| { + let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; + let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; + p.mark.size = Some(1.0); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + wordcraft_doc::para_block(p) + }) + .collect(); + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Draws every item of the first page on its own, as the PDF export does. +fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { + let Some(p) = l.pages.first() else { return }; + let shown = display::ItemDisplay::new(d, p); + for it in p.header.iter().chain(&p.footer).chain(&p.items) { + std::hint::black_box(shown.draws(it, opts)); + } +} + +/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision +/// authors again for every item with a tracked change (lines x revisions). +#[test] +fn drawing_items_one_by_one_ranks_the_authors_once() { + let laid = |n: usize| { + let d = tracked_paras(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 40).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: the same for the clipped cells, which each item's `page_display` collected again +/// (lines x clipped cells). +#[test] +fn drawing_items_one_by_one_collects_the_clipped_cells_once() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. +#[test] +fn balloons_are_in_their_authors_colours() { + let mut d = Document::from_text("one two three"); + for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); + } + let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); + // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. + let mut stripes: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), + _ => None, + }) + .collect(); + stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); +} + +/// A page with tracked changes by several authors in its body and header, and exact-height +/// tables, one nested in a cell of another, whose cells clip their lines. +fn mixed_page() -> (Document, DocLayout) { + let mut d = tracked_paras(6); + let mut hp = Paragraph::with_text("head", CharProps::default()); + hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let exact = |rows, cols| { + let mut t = Table::new(rows, cols, 200.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + t + }; + // A cell with text in it holds a nested table. + let mut outer = exact(2, 2); + outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); + let l = lay(&d, &LayoutOptions::default()); + (d, l) +} + +/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the +/// page's authors and clipped cells were shared: a `page_display` of its own. +fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { + let mut items = vec![it.clone()]; + if matches!(it, Placed::Lines { .. }) { + let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); + items.extend(cells.cloned()); + } + let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; + display::page_display(d, &one, opts) +} + +/// #3866: drawing the items of a page one by one with the page's shared authors and clipped +/// cells gives what a `page_display` of each alone gave. +#[test] +fn drawing_items_one_by_one_matches_a_page_display_of_each() { + let (d, l) = mixed_page(); + let page = &l.pages[0]; + let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); + assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); + assert!(!page.header.is_empty(), "a header on the page"); + for markup in [true, false] { + let opts = display::DisplayOptions { markup, ..Default::default() }; + let shown = display::ItemDisplay::new(&d, page); + let (mut clips, mut coloured) = (0, 0); + for it in page.header.iter().chain(&page.footer).chain(&page.items) { + let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); + clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); + coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); + } + assert!(clips > 0, "some lines are clipped"); + assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9781a04662..f0e65c4d49 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -47,7 +47,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; use wordcraft_layout::display::Paint; -use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display}; +use wordcraft_layout::display::{DisplayOptions, Draw, ItemDisplay, Stroke as LineStyle}; use wordcraft_layout::para::{ClKind, LineEnd}; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, Page, Placed, layout}; use wordcraft_metafile::{Picture, PlacedItem}; @@ -920,9 +920,10 @@ impl Exporter<'_> { placeholders: false, ..Default::default() }; - let clips = clipped_cells(page); + // What the items share (the authors' colours, the clipped cells) is worked out once per page. + let shown = ItemDisplay::new(self.doc, page); for it in page.header.iter().chain(page.footer.iter()) { - let draws = self.draws(page, it, &clips, &dopts); + let draws = shown.draws(it, &dopts); self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { for d in &draws { me.draw(s, d); @@ -930,7 +931,7 @@ impl Exporter<'_> { }); } for it in &page.items { - let draws = self.draws(page, it, &clips, &dopts); + let draws = shown.draws(it, &dopts); match it { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); @@ -992,20 +993,6 @@ impl Exporter<'_> { } } - /// Display items for one placed item. - fn draws(&self, page: &Page, it: &Placed, clips: &CellClips, dopts: &DisplayOptions) -> Vec { - let mut items = vec![it.clone()]; - // Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the - // cell among the items (cells themselves draw nothing). Only that line's cell goes along. - if let Placed::Lines { story, path, .. } = it - && let Some(cell) = clip_cell(clips, *story, &path.0) - { - items.push(cell.clone()); - } - let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; - page_display(self.doc, &one, dopts) - } - /// A heading's text for bookmarks and tags: what the pages show. That leaves out what its /// layout `pl` left out (hidden text, resolved as laid out, so table formatting counts too) and, /// without markup, tracked deletions. @@ -1898,31 +1885,6 @@ fn path_from(b: &kurbo::BezPath) -> Option { pb.finish() } -/// A page's cells whose content is cut off at their edge, by story and content path (table, -/// row, cell), with their place in header, footer, items order (the first wins, as in layout). -type CellClips<'a> = HashMap, (usize, &'a Placed)>>; - -fn clipped_cells(page: &Page) -> CellClips<'_> { - let mut clips: CellClips = HashMap::new(); - for (order, it) in page.header.iter().chain(&page.footer).chain(&page.items).enumerate() { - if let Placed::Cell { table, row, cell, story, clip: true, .. } = it - && let (Ok(row), Ok(cell)) = (u32::try_from(*row), u32::try_from(*cell)) - { - let mut prefix = table.0.clone(); - prefix.extend([row, cell]); - clips.entry(*story).or_default().entry(prefix).or_insert((order, it)); - } - } - clips -} - -/// The clipped cell a line at `path` of `story` is in: the first one whose path starts it. A -/// lookup per prefix of the (short) path instead of passing every clipped cell with every line. -fn clip_cell<'a>(clips: &CellClips<'a>, story: StoryRef, path: &[u32]) -> Option<&'a Placed> { - let cells = clips.get(&story)?; - (0..=path.len()).filter_map(|k| cells.get(path.get(..k)?)).min_by_key(|(order, _)| *order).map(|(_, it)| *it) -} - /// Where each glyph's text ends: the cluster (byte offset) of the first later glyph whose cluster /// is past the glyph's own, else `len`. One backward pass keeping the later clusters that can /// still be an answer, so a long run of glyphs sharing a cluster (combining marks) stays linear. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 4f81f9e1a9..f6548d9c79 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2039,7 +2039,7 @@ fn bundled_unicode_glyphs_are_visible_and_extractable() { let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let mut drawn = String::new(); for page in &lay.pages { - for draw in page_display(&d, page, &DisplayOptions::default()) { + for draw in wordcraft_layout::display::page_display(&d, page, &DisplayOptions::default()) { if let Draw::Glyphs { face, glyphs, text, .. } = draw { assert_eq!(face.data(), bundled.data(), "page glyphs must use the bundled face without system fallback"); assert!(glyphs.iter().all(|(id, _, _)| *id != 0), "page glyphs must not use .notdef"); @@ -2450,43 +2450,3 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); assert_eq!(ends.last(), Some(&(2 * n + 4))); } - -/// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an index -/// built once per page: every line used to carry a clone of every clipped cell on the page, so a -/// page of exact-height rows cost lines × cells. -#[test] -fn a_line_finds_its_clipped_cell_by_path() { - let cell = |story, table: Vec, row, cell, clip| Placed::Cell { - rect: Rect::default(), - covered: false, - table: DocPath(table), - row, - cell, - story, - clip, - }; - let mut page = Page { items: vec![cell(StoryRef::Body, vec![0], 0, 1, false), cell(StoryRef::Body, vec![2], 1, 0, true)], ..Default::default() }; - // A nested clipped cell inside the first one; the outer one comes first in the page. - page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true)); - page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true)); - let clips = clipped_cells(&page); - let found = |story, path: &[u32]| { - clip_cell(&clips, story, path).and_then(|c| if let Placed::Cell { table, .. } = c { Some(table.0.clone()) } else { None }) - }; - assert_eq!(found(StoryRef::Body, &[2, 1, 0, 0]), Some(vec![2])); - assert_eq!(found(StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(vec![2]), "the first cell in page order wins, as in layout"); - assert_eq!(found(StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped"); - assert_eq!(found(StoryRef::Body, &[2, 0, 0, 0]), None, "another row"); - assert_eq!(found(StoryRef::Part(7), &[0, 0, 0, 2]), Some(vec![0])); - assert_eq!(found(StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); - - // 100 000 clipped cells and as many lines: a lookup each, not a pass over the cells each - // (10^10 steps, minutes; the lookups take well under a second on a loaded machine). - let n = 100_000; - let big = Page { items: (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true)).collect(), ..Default::default() }; - let start = std::time::Instant::now(); - let clips = clipped_cells(&big); - let hits = (0..n as u32).filter(|r| clip_cell(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); - assert_eq!(hits, n); - assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); -} From a44e3ec8f969825b3cae93c6063f0395eea7a3d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:02:07 +0000 Subject: [PATCH 75/87] W1: pin craft-libs e8b4dde5 --- Cargo.lock | 56 ++++++++++++++++++++++++++------------- Cargo.toml | 6 ++--- NOTICE | 2 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +-- docs/shared-pdf.md | 2 +- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f64d8d769..6ac24dce67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1011,10 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" + [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1037,7 +1042,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1046,10 +1051,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "roxmltree", "tempfile", "winprint", @@ -1059,15 +1064,27 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "chrono", "dunce", @@ -1573,7 +1590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2826,7 +2843,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3544,7 +3561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4038,7 +4055,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4338,9 +4355,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] [[package]] name = "rustc-hash" @@ -4386,7 +4406,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4445,7 +4465,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5229,10 +5249,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6269,7 +6289,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ee319c50ad..21909fd4bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,8 @@ wuff = "0.2.9" brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -120,7 +120,7 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 7fd6e61209..023da4449a 100644 --- a/NOTICE +++ b/NOTICE @@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources. The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision -17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing +e8b4dde5137fe41823326b55018c31302218d3a4. The backend retains its MIT OR Apache-2.0 licensing and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md. The backend patch prevents finalization during an unwind and rejects a writer after failed cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index aee3872697..0a12fbbf7c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1a257045f9..1a31617d36 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index 99b36bb161..417ec24ae8 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`17744430be327d0257496a6a31c8d38fe20a6756`. Rust 1.95 or newer is required. The revision must be +`e8b4dde5137fe41823326b55018c31302218d3a4`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. From 8634995c20ad57210884729a7ac7552758da5322 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:03:36 +0000 Subject: [PATCH 76/87] W1: control-channel doc tests in their own module beside the control tests --- crates/ui-egui/src/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f86372648b..e33b6aa4b2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1208,4 +1208,4 @@ mod screenshot_tests { #[cfg(test)] #[path = "control_tests.rs"] -mod tests; +mod doc_tests; From 5e7790ea85128adc426e6169d86a3a5bceb82720 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:03:36 +0000 Subject: [PATCH 77/87] W1: requests a command makes in another document keep their document id when the queue is full --- crates/engine/src/session/documents.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 6d8b43a8bc..9a3ad1b8ed 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -521,14 +521,18 @@ impl Session { pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { let home = self.document_id; self.switch_to(id)?; - let queued = self.ui_requests.len(); + // The queue drops its oldest entries when full, so `f`'s requests are told apart by + // collecting them on their own rather than by position. + let earlier = std::mem::take(&mut self.ui_requests); let r = f(self); - if id != home { - for q in self.ui_requests.iter_mut().skip(queued) { - if let Some(o) = q.as_object_mut() { - o.entry("document").or_insert(json!(id)); - } + let made = std::mem::replace(&mut self.ui_requests, earlier); + for mut q in made { + if id != home + && let Some(o) = q.as_object_mut() + { + o.entry("document").or_insert(json!(id)); } + self.push_ui_request(q); } if self.document_id != home && self.is_open(home) { self.switch_to(home)?; From 4576b8e624c3ba44f9a07383eb53bcc41e003341 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:04:24 +0000 Subject: [PATCH 78/87] W1: a command that ends in another document counts as a state change for the input method --- crates/engine/src/session.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c995499691..e303eddd93 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -2059,7 +2059,7 @@ impl Session { } }; if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { - return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before); + return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before, pending_application_change_before); } // Restrict Editing: outside the exceptions nothing may change. if result.is_ok() @@ -2240,13 +2240,22 @@ impl Session { before_doc: Option, blocks_before: Option>, default_language_before: Option, + pending_application_change_before: bool, ) -> Result { + // Another document is a state change the input method has to see, never an IME edit. + self.ime_edit_outcome = ImeEditOutcome::default(); match &result { Ok(_) => { self.put_back_parked(home, before_doc); self.clamp_selection(); + self.ime_state_seen = Some(self.current_ime_caret_state()); + self.reset_ime_state_revision(); + if !self.ime_origin { + self.pending_ime_application_change = true; + } } Err(e) => { + self.pending_ime_application_change = pending_application_change_before; self.autocap = None; self.prefs.default_language = default_language_before; if self.is_open(home) { From 24986cdb2347c1e4d7d8e1a7eb352e586221bceb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:11:00 +0000 Subject: [PATCH 79/87] W3: new app fields classified as app-wide; window ids and the test host on egui 0.37 --- Cargo.lock | 12 ++++++++++++ crates/ui-egui/src/window_ui.rs | 15 ++++++++++++--- crates/ui-egui/src/windows.rs | 6 +++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ac24dce67..3f308f2300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,6 +1106,17 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -6906,6 +6917,7 @@ dependencies = [ "craft-i18n", "craft-ui", "craft-update", + "craft-window", "egui", "egui_kittest", "image", diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 95c8b99333..91cd571c34 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -162,16 +162,25 @@ impl WordApp { integrated_titlebar: _, mac_titlebar: _, control_rx: _, - shot_token: _, - queued_shots: _, - pending_shots: _, + updates_rt: _, + // The control channel's screenshots and input go to the active window, whichever it is. + shots: _, + capture_epoch: _, + surface_geometry: _, + capture_geometry: _, synthetic: _, + synthetic_pending: _, + synthetic_injected: _, + // The current command's origin, set and cleared around it. + ime_origin: _, styled: _, fonts_hans: _, fonts_korean: _, fonts_system_cjk: _, want_system_cjk: _, recent_fonts_for: _, + // File › Recover lists the copies of every window's documents. + recover_fonts_for: _, fonts_frames: _, applied_theme: _, native_scale: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 303af77c74..338b2ae550 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -666,7 +666,7 @@ impl WordApp { /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a /// search box's text, a gallery's first tile. pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { - egui::Id::new(name).with(self.windows.shown) + egui::Id::unique(name).with(self.windows.shown) } /// The windows other than the drawn one, for state every window contributes to (the faces @@ -718,7 +718,7 @@ mod tests { fn keep(out: egui::FullOutput, id: ViewportId) { DRAWN.with(|d| d.borrow_mut().push(id)); UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); - IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime)); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime.clone())); for (v, o) in &out.viewport_output { COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); } @@ -748,7 +748,7 @@ mod tests { fn frame(ctx: &egui::Context, a: &mut WordApp) { DRAWN.with(|d| d.borrow_mut().clear()); let mut raw = input(ViewportId::ROOT); - a.raw_input_hook(&mut raw); + a.raw_input_hook(ctx, &mut raw); let out = ctx.run_ui(raw, |ui| { a.logic(ui.ctx()); a.ui(ui); From ecdc90cdc45fe26fd3370bdf79d538521ec9cfdd Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 03:15:35 +0000 Subject: [PATCH 80/87] =?UTF-8?q?#3866:=20review=20=E2=80=94=20port=20the?= =?UTF-8?q?=20clipped-cell=20lookup=20test=20(nesting,=20first=20in=20page?= =?UTF-8?q?=20order,=20other=20story/row)=20to=20the=20layout=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/display.rs | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 827ad8f734..f537790f96 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -2188,3 +2188,53 @@ fn upright_east_asian(items: Vec) -> Vec { } out } + +#[cfg(test)] +mod tests_clips { + use super::*; + use wordcraft_doc::Path as DocPath; + + /// A cell whose area is told apart by `x`. + fn cell(story: StoryRef, table: Vec, row: usize, cell: usize, clip: bool, x: f32) -> Placed { + Placed::Cell { rect: Rect::new(x, 0.0, 1.0, 1.0), covered: false, table: DocPath(table), row, cell, story, clip } + } + + /// Which cell (by its `x`) clips a line at `path` of `story`. + fn found(clips: &Clips, story: StoryRef, path: &[u32]) -> Option { + cell_clip(clips, story, path).map(|r| r.x) + } + + /// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an + /// index built once per page: every line used to look through every clipped cell on the page, + /// so a page of exact-height rows cost lines x cells. + #[test] + fn a_line_finds_its_clipped_cell_by_path() { + let mut page = Page { + items: vec![cell(StoryRef::Body, vec![0], 0, 1, false, 1.0), cell(StoryRef::Body, vec![2], 1, 0, true, 2.0)], + ..Default::default() + }; + // A nested clipped cell inside the second one; the outer one comes first in the page. + page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true, 3.0)); + page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true, 4.0)); + // The page's cells in header, footer, items order, as the PDF export and layout see them. + let all: Vec = page.header.iter().chain(&page.footer).chain(&page.items).cloned().collect(); + let clips = clipped_cells(&all); + assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 0]), Some(2.0)); + assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(2.0), "the first cell in page order wins, as in layout"); + assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped"); + assert_eq!(found(&clips, StoryRef::Body, &[2, 0, 0, 0]), None, "another row"); + assert_eq!(found(&clips, StoryRef::Part(7), &[0, 0, 0, 2]), Some(4.0)); + assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); + } + + /// Thousands of clipped cells and as many lines: a lookup each, not a pass over the cells each. + #[test] + fn looking_up_many_clipped_cells_takes_linear_time() { + let make = |n: usize| (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true, r as f32)).collect::>(); + crate::tests_linear::assert_linear_steps("clip lookups", 1000, make, |cells| { + let clips = clipped_cells(cells); + let hits = (0..cells.len() as u32).filter(|r| cell_clip(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); + assert_eq!(hits, cells.len()); + }); + } +} From b9c074a5437a876287ea3601222b6c7276fb2555 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:26:01 +0000 Subject: [PATCH 81/87] W6: a window's input method joins and equation session stay with that window --- crates/engine/src/session/views.rs | 17 +++++++++++++++-- crates/engine/src/tests_documents.rs | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 98287badca..48c991eae3 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -7,7 +7,7 @@ use wordcraft_doc::props::CharProps; use wordcraft_doc::{Pos, StoryRef}; -use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened}; +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; /// One window's view of a document: its selection and caret state, and how it shows the page /// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through @@ -21,6 +21,8 @@ pub struct WindowView { hf_return: Option, typing_open: bool, join_next: bool, + equation_join_next: bool, + ime_text_join: Option, typing: Option, math: Option, also_selected: Vec, @@ -65,6 +67,8 @@ impl Session { hf_return: self.hf_return.take(), typing_open: take(&mut self.typing_open), join_next: take(&mut self.join_next), + equation_join_next: take(&mut self.equation_join_next), + ime_text_join: self.ime_text_join.take(), typing: self.typing.take(), math: self.math.take(), also_selected: take(&mut self.also_selected), @@ -88,8 +92,11 @@ impl Session { // The new window hasn't typed: its first key starts an undo step of its own. typing_open: false, join_next: false, + equation_join_next: false, + ime_text_join: None, typing: None, - math: self.math.clone(), + // An equation editing session is an input method's owner: the new window's is its own. + math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), also_selected: self.also_selected.clone(), column: self.column.clone(), column_mode: self.column_mode, @@ -114,6 +121,8 @@ impl Session { hf_return, typing_open, join_next, + equation_join_next, + ime_text_join, typing, math, also_selected, @@ -135,6 +144,8 @@ impl Session { self.hf_return = hf_return; self.typing_open = typing_open; self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; self.typing = typing; self.also_selected = also_selected; self.column = column; @@ -144,6 +155,8 @@ impl Session { self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); self.typing_open = false; self.join_next = false; + self.equation_join_next = false; + self.ime_text_join = None; self.typing = None; self.also_selected = Vec::new(); self.column = None; diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index 6c83716fe7..36ecb471f2 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -329,3 +329,20 @@ fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); drop(b); } + +/// An input method's promise that the next insertion joins its replacement belongs to the +/// window that made it: another window on the document typing at the same place gets an undo +/// step of its own. +#[test] +fn input_method_joins_stay_with_their_window() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.join_next_ime_text_replacement(); + let b = s.copy_view(); + let a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); + drop(a); +} From 6dca584bbdb196107f0eaaecf1557ec544a74fcd Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:29:51 -0400 Subject: [PATCH 82/87] Task panes: drop a pane outside or inside a docked pane to order a side (#4386) * Task panes: a pane dropped over a docked pane's outer or inner third docks outside or inside it, and the order is kept (craft-libs#357) * Pin craft-libs at b74f1b6 (pane order on an edge, craft-libs#357) * Pin craft-libs at b926b5c (craft-libs main) --- Cargo.lock | 25 +++++-- Cargo.toml | 6 +- NOTICE | 2 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/panes.rs | 129 +++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- docs/shared-pdf.md | 2 +- 9 files changed, 154 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9052a8c77..244e0f453d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,10 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" + [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1037,7 +1042,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1046,7 +1051,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.9.1", @@ -1059,19 +1064,27 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "chrono", "dunce", @@ -2830,7 +2843,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index b183f3a1f4..fe7d7c9c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,8 @@ wuff = "0.2.9" brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -120,7 +120,7 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index a7462a69ca..82e79a0a98 100644 --- a/NOTICE +++ b/NOTICE @@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources. The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision -1e6f127844ec61b3b8afdcc2bb76e7cfe470153f. The backend retains its MIT OR Apache-2.0 licensing +b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md. The backend patch prevents finalization during an unwind and rejects a writer after failed cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 8e129d1aa8..85227c169c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 599fa540e1..8add305554 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 61cf2448c9..32949f317a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -304,6 +304,10 @@ pub struct UiState { /// Where each of the other task panes sits, by [`panes::Pane::key`] (#1756). #[serde(deserialize_with = "panes::lenient_places")] pub pane_places: std::collections::BTreeMap, + /// The task panes in the order the user put them, by [`panes::Pane::key`]: each edge shows its + /// panes in this order, the first outermost. Empty: the default order. + #[serde(deserialize_with = "panes::lenient_order", skip_serializing_if = "Vec::is_empty")] + pub pane_order: Vec, /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet, @@ -425,6 +429,7 @@ impl Default for UiState { styles_pane_float: None, styles_pane_width: None, pane_places: Default::default(), + pane_order: Vec::new(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index bf96e38879..5125dbb868 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,7 +17,7 @@ use wordcraft_engine::StylesPaneDock; use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -/// The task panes, in the order they open: each edge's first pane is outermost. +/// The task panes, in their default order: each edge's first pane is outermost. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Pane { /// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. @@ -94,6 +94,10 @@ impl Pane { Self::Comments, ]; + fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|pane| pane.key() == key) + } + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. fn close_command(self) -> Option<&'static str> { match self { @@ -111,11 +115,45 @@ impl Pane { } } -/// The panes open now, from the session's view flags. +/// Every pane in the order the user left them (`paneOrder` in `ui.json`), then the panes that +/// list lacks in their default order. Each edge shows its panes in this order, the first outermost. +fn order(app: &WordApp) -> Vec { + let mut order: Vec = app.ui.pane_order.iter().filter_map(|key| Pane::from_key(key)).collect(); + for pane in Pane::ALL { + if !order.contains(&pane) { + order.push(pane); + } + } + order +} + +/// Put `pane` directly before `before` in the order, outside it on their edge (`None`: after +/// every pane). +fn move_before(app: &mut WordApp, pane: Pane, before: Option) { + let mut order = order(app); + order.retain(|p| *p != pane); + let at = before.and_then(|b| order.iter().position(|p| *p == b)).unwrap_or(order.len()); + order.insert(at, pane); + app.ui.pane_order = order.iter().map(|p| p.key().to_string()).collect(); +} + +/// `paneOrder` as saved: unknown and repeated keys are dropped, not the whole file. +pub(crate) fn lenient_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let mut keys: Vec = Vec::new(); + for key in v.as_array().map(Vec::as_slice).unwrap_or_default().iter().take(Pane::ALL.len() * 4).filter_map(Value::as_str) { + if Pane::from_key(key).is_some() && !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } + } + Ok(keys) +} + +/// The panes open now, from the session's view flags, in the user's order. pub fn open(app: &WordApp) -> Vec { let v = &app.session.view; let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some(); - [ + let mut open = [ (Pane::SmartArtText, smart_art), (Pane::Navigation, v.nav_pane), (Pane::Clipboard, v.clipboard_pane), @@ -129,7 +167,10 @@ pub fn open(app: &WordApp) -> Vec { ] .into_iter() .filter_map(|(pane, shown)| shown.then_some(pane)) - .collect() + .collect::>(); + let order = order(app); + open.sort_by_key(|pane| order.iter().position(|p| p == pane).unwrap_or(usize::MAX)); + open } /// Where a task pane other than Styles sits, kept in `ui.json` (#1756). Styles keeps its place in @@ -345,16 +386,21 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), + PaneRequest::Before(pane, before) => { + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "before": before.map(Pane::key)})); + } PaneRequest::Focus(_) => {} } } -/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float"}` (#1756): docks or floats a -/// task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` it reads the -/// pane's place. `pane` is the pane's `panePlaces` key; Styles goes through `view.stylesPaneDock`. +/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float", "before"?: key | null}` (#1756): +/// docks or floats a task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` +/// it reads the pane's place. `pane` is the pane's `panePlaces` key; Styles goes through +/// `view.stylesPaneDock`. `before` puts the pane directly before that pane in the order, outside +/// it on their edge, as a drop over a docked pane does; `null` puts it after every pane. pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result { let key = p.get("pane").and_then(Value::as_str).ok_or("`pane` must be a task pane's key, such as \"navigation\"")?; - let pane = Pane::ALL.into_iter().find(|pane| pane.key() == key).ok_or_else(|| format!("unknown task pane `{key}`"))?; + let pane = Pane::from_key(key).ok_or_else(|| format!("unknown task pane `{key}`"))?; let dock = match p.get("dock") { None => None, Some(v) => Some(match v.as_str() { @@ -364,6 +410,17 @@ pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result return Err("`dock` must be left, right or float".into()), }), }; + let before = match p.get("before") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => match v.as_str().and_then(Pane::from_key) { + Some(before) if before != pane => Some(Some(before)), + _ => return Err("`before` must be another task pane's key or null".into()), + }, + }; + if let Some(before) = before { + move_before(app, pane, before); + } if pane == Pane::Styles { let params = p.get("dock").map_or_else(|| json!({}), |d| json!({"dock": d})); let result = app.run("view.stylesPaneDock", params)?; @@ -1336,6 +1393,62 @@ mod tests { assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, docked, "the drag was cancelled"); } + /// With Navigation docked left, Comments dropped over Navigation's outer third docks left of + /// it, where the preview showed, and the order survives a restart; over Navigation's inner + /// third it goes back inside. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn comments_dropped_over_navigations_outer_third_docks_left_of_it() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.commentsPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + // A docked pane's title bar starts at its panel's left edge. + let panel = |h: &egui_kittest::Harness<'static, crate::WordApp>, pane: Pane| h.get_by_label(&format!("Move Pane: {}", pane.title())).rect(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let drag = |h: &mut egui_kittest::Harness<'static, crate::WordApp>, to: egui::Pos2| { + let from = h.get_by_label("Move Pane: Comments").rect().center(); + h.input_mut().events.extend([egui::Event::PointerMoved(from), button(from, true)]); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(from + egui::vec2(-8.0, 6.0))); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(to)); + h.run_steps(1); + h.input_mut().events.push(button(to, false)); + h.run_steps(3); + }; + let nav = panel(&h, Pane::Navigation); + drag(&mut h, egui::pos2(nav.left() + 60.0, nav.center().y)); + let (comments, nav_after) = (panel(&h, Pane::Comments), panel(&h, Pane::Navigation)); + assert_eq!(comments.left(), nav.left(), "Comments is outermost"); + assert!(nav_after.left() > comments.right(), "Navigation sits inside it"); + assert_eq!(super::open(h.state()), vec![Pane::Comments, Pane::Navigation]); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + next.session.view.comments_pane = true; + assert_eq!(super::open(&next), vec![Pane::Comments, Pane::Navigation], "the order is kept"); + + let nav = panel(&h, Pane::Navigation); + let width = Pane::Navigation.default_state().size.unwrap(); + drag(&mut h, egui::pos2(nav.left() + width * 0.85, nav.center().y)); + assert_eq!(super::open(h.state()), vec![Pane::Navigation, Pane::Comments]); + assert!(panel(&h, Pane::Comments).left() > panel(&h, Pane::Navigation).right(), "back inside Navigation"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs(serde_json::from_str(r#"{"paneOrder": ["comments", 3, "nope", "comments", "navigation"], "navPane": true}"#).unwrap()); + assert_eq!(junk.ui.pane_order, vec!["comments", "navigation"], "unknown and repeated keys are dropped"); + for bad in [serde_json::json!({"pane": "comments", "before": "comments"}), serde_json::json!({"pane": "comments", "before": 7})] { + assert!(junk.run("ui.paneDock", bad.clone()).is_err(), "{bad}"); + } + junk.run("ui.paneDock", serde_json::json!({"pane": "navigation", "before": null})).unwrap(); + assert_eq!(junk.ui.pane_order.last().map(String::as_str), Some("navigation")); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 513ef0e818..014966bf95 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | 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.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"}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756)…) | +| `engine.execute` | `command`, `params` | 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.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?` | 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 | diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index a3216940dc..92596b59ab 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`1e6f127844ec61b3b8afdcc2bb76e7cfe470153f`. Rust 1.95 or newer is required. The revision must be +`b926b5c03e256c86ca10934f58d03f628810b5b7`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. From d805dd94f18459bf40237978d9288939a7223cd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:34:39 +0000 Subject: [PATCH 83/87] W3b: ui.screenshot captures the active window, not the first one --- crates/ui-egui/src/control.rs | 73 +++++++++++++++++++++++------------ crates/ui-egui/src/lib.rs | 29 ++++++++------ crates/ui-egui/src/windows.rs | 51 ++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 518e17d2c0..efa28f266e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,20 +456,29 @@ impl SurfaceGeometry { /// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite /// or zero-sized visible surface and recording minimized or occluded state. pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { - let viewport = raw.viewports.get(&raw.viewport_id)?; + Self::from_viewport(raw.viewports.get(&raw.viewport_id)?, raw.screen_rect, raw.max_texture_side, ctx) + } + + /// [`Self::from_raw`] for one viewport's info, content rectangle and texture limit. + pub(crate) fn from_viewport( + viewport: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + ctx: &egui::Context, + ) -> Option { let minimized = viewport.minimized == Some(true); let occluded = viewport.occluded == Some(true); // A minimized/occluded surface may report no rectangle at all. Its stale size is harmless // because such a surface is never capturable; a visible surface without a rectangle is not // fresh enough to use. - let rect = match viewport.inner_rect.or(raw.screen_rect) { + let rect = match viewport.inner_rect.or(screen_rect) { Some(rect) => rect, None if minimized || occluded => ctx.content_rect(), None => return None, }; let zoom = ctx.zoom_factor(); let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom); - let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); + let max_texture_side = max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 { return None; } @@ -479,9 +488,10 @@ impl SurfaceGeometry { Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) } - /// Pairs the surface geometry with the document id a capture belongs to. - pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { - CaptureGeometry { surface: self, document } + /// Pairs the surface geometry with the window's viewport and the document id a capture + /// belongs to. + pub(crate) fn with_target(self, viewport: egui::ViewportId, document: u64) -> CaptureGeometry { + CaptureGeometry { surface: self, viewport, document } } } @@ -491,6 +501,8 @@ impl SurfaceGeometry { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct CaptureGeometry { surface: SurfaceGeometry, + /// The active window's viewport, which the capture command goes to. + viewport: egui::ViewportId, document: u64, } @@ -500,6 +512,18 @@ impl CaptureGeometry { self.document } + /// Why a capture admitted for `viewport` and `document` can't go on against this geometry: + /// another window was brought forward, or the window shows another document. + fn moved_reason(self, viewport: egui::ViewportId, document: u64) -> Option<&'static str> { + if self.viewport != viewport { + Some("screenshot failed: another window was brought forward while the capture was queued") + } else if self.document != document { + Some("screenshot failed: the document changed while the capture was queued") + } else { + None + } + } + /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and /// returns `None` when it can. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { @@ -569,6 +593,9 @@ pub(crate) struct Shot { pub(crate) retries: u32, /// The document the request was admitted for. It never changes; a document switch cancels. pub(crate) document: u64, + /// The viewport of the window the request was admitted for. It never changes; bringing + /// another window forward cancels. + pub(crate) viewport: egui::ViewportId, pub(crate) geometry: CaptureGeometry, pub(crate) epoch: u64, pub(crate) expected: [usize; 2], @@ -623,6 +650,8 @@ fn next_token(app: &mut WordApp) -> u64 { /// earlier request. pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option, reply: Sender) { let token = next_token(app); + // An earlier request in this batch may have brought another window forward. + app.refresh_capture_geometry(ctx); let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); return; @@ -684,6 +713,7 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt issued: false, retries: 0, document: geometry.document(), + viewport: geometry.viewport, geometry, epoch: app.capture_epoch, expected, @@ -708,7 +738,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { let shot = &app.shots.requests[i]; (shot.issued, shot.ready_at, shot.frames) }; - if issued { + // Each window issues its own captures, from a pass that drew its layout. + if issued || app.shots.requests[i].viewport != ctx.viewport_id() { continue; } let frames = if first_pass { frames.saturating_add(1) } else { frames }; @@ -724,8 +755,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { failures.push((i, format!("screenshot failed: {reason} while the capture was queued"))); continue; } - if current.document() != app.shots.requests[i].document { - failures.push((i, "screenshot failed: the document changed while the capture was queued".into())); + if let Some(reason) = current.moved_reason(app.shots.requests[i].viewport, app.shots.requests[i].document) { + failures.push((i, reason.into())); continue; } let Some(expected) = current.expected_size() else { @@ -762,7 +793,7 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { shot.bytes = bytes; shot.epoch = epoch; if let Some(callback) = shot.callback.take() { - ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback)); + ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback)); shot.issued = true; } } @@ -778,13 +809,6 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { } } -impl Shot { - /// The screenshot callback's viewport: the root window. - fn viewport(&self) -> egui::ViewportId { - egui::ViewportId::ROOT - } -} - /// A completed image is either stale because the surface changed (safe to retry once within the /// deadline) or invalid for this request (report it at once; retrying cannot help). enum ImageCheck { @@ -850,6 +874,13 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")); } + // Before the issue check: a window that closed never draws to issue its capture. + if let Some(reason) = current.and_then(|geometry| geometry.moved_reason(shot.viewport, shot.document)) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, reason)); + } if !shot.issued { return ShotAction::Keep; } @@ -859,12 +890,6 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))); } - if current.is_some_and(|geometry| geometry.document() != shot.document) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")); - } shot.slot.as_ref().and_then(|slot| slot.take()) }; match completed { @@ -1080,7 +1105,7 @@ mod tests { /// Builds a capture geometry fixture for document id 7. fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { - CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } + CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), viewport: egui::ViewportId::ROOT, document: 7 } } /// Creates a black test image of the given size. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1cb1c36577..8dd017d3b2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2478,15 +2478,9 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - // Capture geometry/document identity is current before control admission and completion. - // The epoch is monotonic: resizing back to an earlier size is still a new epoch. - let capture_geometry = self.surface_geometry.map(|surface| surface.with_document(self.session.document_id())); - if let Some(geometry) = capture_geometry - && self.capture_geometry != Some(geometry) - { - self.capture_geometry = Some(geometry); - self.capture_epoch = self.capture_epoch.wrapping_add(1); - } + // Capture geometry/window/document identity is current before control admission and + // completion. + self.refresh_capture_geometry(ctx); let native = self.windows.native(); if native { self.printing.poll(ctx); @@ -2724,7 +2718,9 @@ impl WordApp { // Capture the surface from the raw input itself. `InputState::viewport_rect` and the // last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's // current answer, including minimized/occluded and a Wayland `inner_rect: None`. - if let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) { + if raw.viewport_id == egui::ViewportId::ROOT + && let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) + { // A hidden resize and change back still invalidate an older capture, even if // neither intermediate geometry reaches a drawing or logic pass. if self.surface_geometry != Some(geometry) { @@ -3092,6 +3088,17 @@ impl WordApp { } } + /// Make the capture geometry the active window's surface, viewport and document. The epoch is + /// monotonic: resizing back to an earlier size is still a new epoch. + pub(crate) fn refresh_capture_geometry(&mut self, ctx: &egui::Context) { + let (viewport, surface) = self.active_surface_geometry(ctx); + let geometry = surface.map(|surface| surface.with_target(viewport, self.session.document_id())); + if self.capture_geometry != geometry { + self.capture_geometry = geometry; + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } + } + /// Drains queued control requests, dispatches each through `control::handle`, sends its reply or /// admits a screenshot, then puts the receiver back. fn drain_control(&mut self, ctx: &egui::Context) { @@ -9744,7 +9751,7 @@ mod tests { let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); let surface = control::SurfaceGeometry::from_raw(&raw, &ctx).expect("screen rect fallback"); - let geometry = surface.with_document(7); + let geometry = surface.with_target(egui::ViewportId::ROOT, 7); assert_eq!(geometry.expected_size(), Some([480, 300])); assert_eq!(geometry.expected_bytes(), Some(480 * 300 * 4)); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 338b2ae550..2982e606e1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -270,6 +270,18 @@ impl WordApp { self.windows.viewport(key) } + /// The active window's viewport and surface, for `ui.screenshot`: the root's from this + /// frame's raw input, another window's from the input it last drew with (the host's raw-input + /// hook sees only the root's). + pub(crate) fn active_surface_geometry(&self, ctx: &egui::Context) -> (ViewportId, Option) { + let viewport = self.active_viewport(); + if viewport == ViewportId::ROOT { + return (viewport, self.surface_geometry); + } + let (info, screen_rect, max_texture_side) = ctx.input_for(viewport, |i| (i.viewport().clone(), i.raw.screen_rect, i.raw.max_texture_side)); + (viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx)) + } + /// The viewport new windows start from: the host's size, icon and app id. Their title and /// place are the app's. pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { @@ -1065,6 +1077,45 @@ mod tests { assert_eq!(text(&a), "First", "the other window got none of it"); } + /// `ui.screenshot` captures the active window: the capture is sized from its viewport and the + /// command goes to that viewport. A capture still pending when another window comes forward + /// fails with an error instead of capturing that window. + #[test] + fn screenshot_captures_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect)); + focus(child); + frames(&ctx, &mut a, 4); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the capture is admitted"); + assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::>(), [(child, [800, 600])]); + a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); + COMMANDS.with(|c| c.borrow_mut().clear()); + frames(&ctx, &mut a, 4); + let shot_to = + |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); + assert!(shot_to(child), "the active window is captured"); + assert!(!shot_to(ViewportId::ROOT), "the first window is not"); + + let (reply, later) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + for r in [replies, later] { + let v = r.try_recv().expect("the capture was answered"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); + } + assert!(a.shots.requests.is_empty()); + } + /// Quitting with two documents of unsaved changes asks about each in its window, the window /// coming to the front; Cancel keeps both. #[test] From f93c0cbe522ac9df5407b3bcc73971fd9f5e51e1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:57:59 +0000 Subject: [PATCH 84/87] W1: the merge-records test expects the result beside the unsaved source, with no save prompt --- crates/ui-egui/src/dialogs.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index eb82bdf312..bb29b90aee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4801,6 +4801,7 @@ mod tests { let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); let _ = app.run("text.insert", json!({"text": "unsaved"})); assert!(app.session.dirty); + let source = app.session.document_id(); app.dialog = Dialog::open("mergeRecords", &mut app); if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { *choice = 2; @@ -4836,8 +4837,9 @@ mod tests { for _ in 0..2 { h.step(); } - assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); - assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + // The merged document opens beside the unsaved source, so nothing asks to save it. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), None); + assert_ne!(h.state().session.document_id(), source, "the merge result is a new document"); } #[test] From e71c9ce31d411736613ffb9b9c5a09c47c087cac Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 04:01:47 +0000 Subject: [PATCH 85/87] =?UTF-8?q?#3866:=20review=20=E2=80=94=20the=20first?= =?UTF-8?q?-page=20index=20is=20built=20only=20when=20the=20hinted=20page?= =?UTF-8?q?=20doesn't=20show=20the=20paragraph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 5 +++-- crates/layout/src/tests_linear.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 2b1bb66d4c..148ce88cdf 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -543,8 +543,9 @@ impl DocLayout { if pages.story == StoryRef::Body { return self.pieces(StoryRef::Body, path, page_hint); } - let first = pages.first_page(self, path); - self.part_pieces(page_hint, &mut || first, &mut |pi| pages.on(self, pi, path)) + // The first-page index is made only when the hinted page doesn't show the paragraph. + let pages = std::cell::RefCell::new(pages); + self.part_pieces(page_hint, &mut || pages.borrow_mut().first_page(self, path), &mut |pi| pages.borrow_mut().on(self, pi, path)) } /// Pieces of a paragraph of story part `story` on page `pi`. diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index b242b00c3c..c05a475ffe 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -465,3 +465,27 @@ fn drawing_items_one_by_one_matches_a_page_display_of_each() { assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); } } + +/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a +/// long document to find the first page that shows it. +#[test] +fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { + let laid = |n: usize| { + let mut d = Document::from_text("n"); + let huge = CharProps { size: Some(400.0), ..Default::default() }; + d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); + d.last_section.headers.default = Some(id); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); + (d, l, ends) + }; + let steps = |n: usize| { + let (d, l, (a, b)) = laid(n); + assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); + work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) + }; + let (small, big) = (steps(20), steps(160)); + eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); + assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); +} From b94821ea1629766e4aaa279bac126829ec21cb4a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 04:05:47 +0000 Subject: [PATCH 86/87] =?UTF-8?q?#3866:=20review=20=E2=80=94=20count=20a?= =?UTF-8?q?=20step=20per=20page=20walked=20in=20the=20first-page=20index,?= =?UTF-8?q?=20so=20the=20hinted-page=20test=20sees=20an=20eager=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 148ce88cdf..c0f5f20bc4 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -163,6 +163,7 @@ impl<'a> PartPages<'a> { let first = self.first.get_or_insert_with(|| { let mut first: HashMap<&'a Path, usize> = HashMap::new(); for (pi, p) in lay.pages.iter().enumerate() { + crate::work::steps(1); for it in items_of(p, story) { if let Placed::Lines { story: s, path, .. } = it && *s == story From 49c2f61373d5c0eca7e9f07a1f52d36726e79d73 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 08:08:46 +0000 Subject: [PATCH 87/87] #3866: tests_linear imports Page and Rgb (main's layout tests didn't compile after #4392) #4392's tests used Page and Rgb, which other changes merged around it no longer brought into scope, so main's CI gate fails with E0425/E0422 in tests_linear.rs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/layout/src/tests_linear.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 31b451d8dd..4dc2d741dc 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -7,10 +7,11 @@ use super::tests::{SCALE, cost_ratio}; use super::{ - Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, ParaLayout, Paragraph, Path, Placed, - SectionProps, SectionStart, StoryRef, display, layout, para, work, + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, + Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work, }; use wordcraft_doc::para::Run; +use wordcraft_doc::props::Rgb; use wordcraft_doc::{Pos, Table}; fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout {