diff --git a/Cargo.lock b/Cargo.lock index 0f64d8d769..b8a2c6be09 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=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" + [[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=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=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" 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=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" 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=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=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "chrono", "dunce", @@ -1089,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=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -1573,7 +1601,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 +2854,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=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3544,7 +3572,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 +4066,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4338,9 +4366,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 +4417,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4445,7 +4476,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5229,10 +5260,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 +6300,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]] @@ -6886,6 +6917,7 @@ dependencies = [ "craft-i18n", "craft-ui", "craft-update", + "craft-window", "egui", "egui_kittest", "image", diff --git a/Cargo.toml b/Cargo.toml index ee319c50ad..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 = "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 = "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 = "17744430be327d0257496a6a31c8d38fe20a6756" } +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 7fd6e61209..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 -17744430be327d0257496a6a31c8d38fe20a6756. 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/ROADMAP.md b/ROADMAP.md index 91e0e7b4f0..98de542269 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,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-25 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; previously trivial: progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; 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-25 · **Change:** trivial (dark-theme interface text on Linux no longer looks semi-bold; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; 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 (dark-theme interface text on Linux no longer looks semi-bold; previously 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. @@ -140,6 +140,11 @@ 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. +- 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-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human. - 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open. - 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087). @@ -642,6 +647,10 @@ 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 | | 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open | | 2026-10-17 | trivial | Progress log: Standard-encrypted (AES) Word files open (gap 9) | | 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index c8e46c93cc..f9a8299ab5 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] @@ -484,10 +488,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/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index aee3872697..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 = "17744430be327d0257496a6a31c8d38fe20a6756" } +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/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e952a3dfcf..0eae596a9d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -35,14 +35,15 @@ 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 { 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) => { @@ -54,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 2486960195..bc66c94115 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -62,8 +62,17 @@ 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 = WindowGeometry::track(prev, &window); + // Another document window's state is the one it last painted with: the host reports only + // the root window's for this frame. + let active = self.0.active_viewport(); + let tracked = if active == egui::ViewportId::ROOT { + window + } else { + ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect()))) + }; + self.0.ui.window = WindowGeometry::track(prev, &tracked); // 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(|_| window.monitor_size.is_some()); @@ -73,9 +82,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 @@ -418,6 +429,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); @@ -469,6 +482,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/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 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) 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/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); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b09ae01853..7b1ac56d2b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -141,8 +141,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(), @@ -208,7 +211,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(), @@ -217,14 +221,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()); @@ -232,12 +235,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.push_ui_request(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()))?; @@ -260,7 +273,13 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { { doc.styles.default_chr.lang = Some(lang.clone()); } - s.set_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); } @@ -269,7 +288,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 488b9bc4a1..bede814d14 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 } @@ -1096,10 +1104,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})) } @@ -1261,8 +1267,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})) } @@ -1494,8 +1499,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/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/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/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index f40c042c38..83b3c161f3 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)); @@ -817,7 +819,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."); @@ -831,6 +834,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")); } 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/templates.rs b/crates/engine/src/cmd/templates.rs index 60d4044a05..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), } } @@ -231,18 +236,34 @@ 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\…`, `\\.\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(); - matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) + // `\\?\`, `\\.\` 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))); + if prefix { + return !drive; + } + sep(b.first()) && sep(b.get(1)) } fn is_windows_absolute(p: &str) -> bool { @@ -489,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"))); @@ -504,9 +548,33 @@ 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")); + // 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"); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ee7ebaafa5..9a17628664 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -8,9 +8,10 @@ use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect}; 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}"#), + 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"), @@ -96,12 +97,16 @@ fn typing_options(s: &mut Session, text: &str) { } } -/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a -/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step -/// when they rewrite the paragraph. +/// `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. A single +/// trigger character records the IME outcome, and list, AutoCorrect, dash and double-space +/// AutoFormat run as a separate undo step when they rewrite the paragraph. 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 smart_quote = if raw { None } else { autoformat(s, text) }; let was_collapsed = s.sel.is_collapsed(); @@ -714,17 +719,58 @@ 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) { return sel_result(s); } + enter(s, true)?; + 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. More than [`MAX_TYPED_PARAGRAPHS`] lines are +/// refused before anything is typed. +fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult { + 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) { + 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) @@ -734,19 +780,24 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { 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)?, EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?, } 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 { @@ -773,7 +824,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { s.sel = Selection::caret(new); s.goal_x = None; s.push_undo("AutoFormat", plain_doc, plain_sel); - return sel_result(s); + return Ok(()); } super::tools::autocorrect_word(s, true)?; s.autocap.take_if(|c| c.depth == u64::MAX); @@ -782,7 +833,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. With "Set @@ -916,6 +967,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 +1018,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..638f38b7bf 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -551,8 +551,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) }) @@ -1554,12 +1553,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 +1586,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 +1596,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 { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 28b4fd4cb7..c7f5f922fd 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -109,6 +109,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) @@ -128,6 +132,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.push_ui_request(json!({"toggle": "split"})); sel_result(s) @@ -182,6 +189,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, @@ -290,3 +307,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/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/engine/src/io.rs b/crates/engine/src/io.rs index 5af08451bf..960255030b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -113,6 +113,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)) @@ -507,7 +521,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('/')) { @@ -521,6 +535,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/lib.rs b/crates/engine/src/lib.rs index a3c004ac88..8188d58dc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,8 +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, - next_math_session, set_system_locale, + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, + ViewState, WindowView, blank_document, next_math_session, set_system_locale, }; /// The outcome a command reports for IME receipt classification. /// @@ -200,6 +200,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_documents; +#[cfg(test)] mod tests_fonts; #[cfg(test)] mod tests_ime_noop; 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 8ddd7bbb83..861f3ba294 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,34 +372,123 @@ 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, + /// 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 { pub fn start(root: PathBuf, wake: Arc) -> Result { @@ -412,47 +500,47 @@ impl Recovery { 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, - }) + 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 { @@ -466,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) => { @@ -505,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; @@ -707,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 @@ -725,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 @@ -749,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] @@ -767,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. @@ -782,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); @@ -866,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()); @@ -875,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] @@ -957,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] @@ -987,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); } @@ -996,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] @@ -1016,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(); @@ -1043,6 +1149,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(); @@ -1065,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); @@ -1073,16 +1204,140 @@ 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); + 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] 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!(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 94e93b7b68..953eaf53ce 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -25,6 +25,12 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM use crate::{CmdError, ImeEditOutcome, 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)] pub struct Selection { @@ -135,6 +141,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, @@ -215,6 +225,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 } @@ -252,6 +294,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, @@ -372,7 +415,38 @@ impl EditSnapshot { } } -/// An editing session. +/// 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, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (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 +/// in when they become active (see [`Session::documents`]). pub struct Session { pub doc: Document, pub sel: Selection, @@ -446,13 +520,18 @@ 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, 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, /// What Restrict Editing's outright refusals depend on, for greying out commands /// ([`Session::why_disabled`]): computed once per document, revision and author instead of /// for every button on every frame. protect_facts: std::sync::Mutex>, - /// Which document this is: changes when [`Session::set_document`] replaces it (open, new, - /// mail merge, recover), never on an edit. - document_id: u64, /// How many times the document's body was replaced under the same [`Self::document_id`] /// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME /// replacement epoch: a composition must not survive the swap, even when the new body has @@ -479,22 +558,7 @@ pub struct Session { /// inherit the origin. ime_origin: bool, 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: LayoutSlots, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). @@ -545,8 +609,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, @@ -562,6 +630,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). @@ -721,6 +795,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() } @@ -747,10 +822,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}"); } @@ -764,46 +843,94 @@ impl Session { /// Initialise session defaults while sharing the immutable command registry with previews. fn with_registry(doc: Document, registry: Arc) -> 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, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + 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, + sel, + view, + pending, last_highlight: wordcraft_doc::props::Highlight::Yellow, - path: None, - file_name: None, + 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, 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, - equation_join_next: false, - ime_text_join: None, - join_anchor: false, - autocap: None, - rev: 1, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, protect_facts: std::sync::Mutex::new(None), - document_id: 1, - ime_body_epoch: 0, + ime_body_epoch, ime_state_revision: None, ime_state_seen: None, ime_edit_outcome: ImeEditOutcome::default(), @@ -811,38 +938,43 @@ impl Session { ime_state_exhausted: false, pending_ime_application_change: false, ime_origin: false, - cache: LayoutCache::new(), - layout: None, - typing: None, - originals: Default::default(), - picture_looks: Default::default(), + 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, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, } } @@ -986,7 +1118,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 { @@ -996,7 +1128,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) } } @@ -1021,19 +1154,30 @@ impl Session { let hide_deleted = !self.view.show_markup; let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); - 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()))); @@ -1042,14 +1186,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, @@ -1057,7 +1201,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 } @@ -1155,7 +1303,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(); } @@ -1179,6 +1327,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(); } } @@ -1462,8 +1614,11 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { self.autocap = None; - // Never reuse an id after wrapping: the IME context is keyed by it. - self.document_id = self.document_id.saturating_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.note_body_replaced(); self.bib_style = doc_bib_style(&doc); self.doc = doc; @@ -1484,10 +1639,16 @@ 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.clear_versions(); self.touch(); self.dirty = false; + self.made_rev = self.rev; self.relayout(); } @@ -1504,8 +1665,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. @@ -1565,6 +1726,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: _, ime_body_epoch, // The IME revision and its caret token are process-global evidence, not document // state: a rollback must not reuse a revision. @@ -1590,6 +1758,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. @@ -1695,8 +1865,10 @@ impl Session { return; } self.rev = rev; - if self.layout.as_ref().is_some_and(|l| l.0 != rev) { - self.layout = None; + for slot in &mut self.layout { + if slot.as_ref().is_some_and(|l| l.0 != rev) { + *slot = None; + } } if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { self.find.highlights = None; @@ -1815,6 +1987,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(); // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, // protected) leave it at the default. self.ime_edit_outcome = ImeEditOutcome::default(); @@ -1874,7 +2049,7 @@ impl Session { // Commands may leave an equation (a whole-body replacement) even after they fail; put // the editing session back with the document. let math_before = spec.mutates.then(|| self.math.clone()); - let before_doc = if spec.mutates { + let before_doc: Option = if spec.mutates { Some(( self.doc.clone(), self.sel.clone(), @@ -1958,6 +2133,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, pending_application_change_before); + } // Restrict Editing: outside the exceptions nothing may change. if result.is_ok() && guard @@ -2123,6 +2301,63 @@ 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, + 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) { + 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..851cf864e8 --- /dev/null +++ b/crates/engine/src/session/documents.rs @@ -0,0 +1,657 @@ +//! 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, 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 +/// 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) equation_join_next: bool, + pub(super) ime_text_join: Option, + pub(super) join_anchor: 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) ime_body_epoch: u64, + pub(super) cache: LayoutCache, + pub(super) layout: LayoutSlots, + 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_slot: crate::recovery::Slot, + 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, + equation_join_next: false, + ime_text_join: None, + join_anchor: false, + autocap: None, + rev: 1, + document_id: next_document_id(), + untitled, + made_rev: 1, + ime_body_epoch: 0, + cache: LayoutCache::new(), + layout: Default::default(), + 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_slot: Default::default(), + 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: _, + equation_join_next: _, + ime_text_join: _, + join_anchor: _, + autocap: _, + rev: _, + document_id: _, + untitled: _, + made_rev: _, + ime_body_epoch: _, + cache: _, + layout: _, + typing: _, + originals: _, + picture_looks: _, + versions: _, + autosave: _, + bib_style: _, + merge: _, + math: _, + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + 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: _, + last_highlight: _, + // Keyed by document id and revision, so one cache serves every document. + protect_facts: _, + // The input method talks to the window, whichever document it shows; the IME + // context is keyed by `document_id`. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + others: _, + order: _, + untitled_count: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + } = 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, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + 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.equation_join_next, equation_join_next); + swap(&mut self.ime_text_join, ime_text_join); + swap(&mut self.join_anchor, join_anchor); + 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.ime_body_epoch, ime_body_epoch); + 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_slot, recovery_slot); + 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 }; + let d = self.others.swap_remove(at); + self.order.retain(|d| *d != id); + self.forget_recovery_copy(&d); + 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(()) + } + + /// 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 + /// 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); + let id = d.document_id; + self.order.push(id); + self.swap_doc(&mut d); + self.others.push(d); + self.stop_reading_elsewhere(); + id + } + + /// 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 + /// 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); + self.order = vec![fresh.document_id]; + self.swap_doc(&mut fresh); + self.forget_recovery_copy(&fresh); + } + } + self.stop_reading_elsewhere(); + 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. + pub fn is_untouched_blank(&self) -> bool { + 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 + /// 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)?; + // 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); + 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)?; + } + 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 { + 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) + } 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/session/views.rs b/crates/engine/src/session/views.rs new file mode 100644 index 0000000000..48c991eae3 --- /dev/null +++ b/crates/engine/src/session/views.rs @@ -0,0 +1,167 @@ +//! 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, 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 +/// 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, + equation_join_next: bool, + ime_text_join: Option, + 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), + 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), + 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, + equation_join_next: false, + ime_text_join: None, + typing: None, + // 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, + 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, + equation_join_next, + ime_text_join, + 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.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + 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.equation_join_next = false; + self.ime_text_join = None; + 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/speech.rs b/crates/engine/src/speech.rs index 74a6b165d6..215f9304ee 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -551,6 +551,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 { @@ -564,6 +567,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 e6a594d511..d5a0afe609 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -248,6 +248,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. @@ -3954,6 +3981,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(); @@ -4590,10 +4647,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())); @@ -4613,10 +4670,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"); @@ -4771,6 +4831,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)] @@ -5037,6 +5120,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(); @@ -5769,6 +5877,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); @@ -9752,9 +9862,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.")); @@ -9772,7 +9883,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/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs new file mode 100644 index 0000000000..36ecb471f2 --- /dev/null +++ b/crates/engine/src/tests_documents.rs @@ -0,0 +1,348 @@ +//! 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"); +} + +/// 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"); +} + +/// 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); +} + +/// 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); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7b68dbe866..82ca5c595f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -101,6 +101,9 @@ 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). + #[cfg(not(target_arch = "wasm32"))] + hint_strike: std::time::Duration, } /// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget @@ -239,7 +242,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)) { @@ -248,11 +251,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(); @@ -1068,6 +1079,8 @@ 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, }) } @@ -2110,6 +2123,8 @@ struct FlipPen { path: BezPath, room: usize, over: bool, + /// Drawn with the face's TrueType interpreter. + hinted: bool, } impl FlipPen { @@ -2550,6 +2565,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. diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index c67fb6a213..f7cd0fd918 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,25 @@ 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 [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(); diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 1a7a718037..44df25e971 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::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -164,21 +165,103 @@ 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> { - let pic = wordcraft_geom::picture::probe(bytes).ok()?; +/// 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) + } +} + +/// 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, 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 { + /// 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)>, + /// 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), 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.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.seen.insert(key, alpha.clone()); + alpha + } + }; + 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 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); } - let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?; - if !img.color().has_alpha() { - return 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); + *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 (iw, ih) = img.dimensions(); + let (w, h) = img.dimensions(); + 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`]. +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 +280,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 +295,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).ok().flatten() + } + 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/display.rs b/crates/layout/src/display.rs index ca83e65f82..f537790f96 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; @@ -2142,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()); + }); + } +} diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index bb0955ad8b..c0f5f20bc4 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -147,17 +147,41 @@ 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() { + crate::work::steps(1); + 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 +484,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 +544,9 @@ 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)) + // 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/lib.rs b/crates/layout/src/lib.rs index 1dc23c76f6..95c1dca1e5 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -541,8 +541,10 @@ 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. - outlines: HashMap<(String, usize, usize, [u32; 4]), Option>>>, + /// 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, /// The STYLEREF style index of the last pass and what it was built from: the body blocks @@ -748,6 +750,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 @@ -798,23 +802,42 @@ 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`]), 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. +fn picture_outline( + outlines: &mut Outlines, + alphas: &mut contour::Alphas, + key: (String, usize, usize, [u32; 4]), + bytes: &Arc>, + 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, (Arc::downgrade(bytes), 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. 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)); - if let Some(o) = cache.outlines.get(&key) { - return o.clone(); - } - if cache.outlines.len() >= 256 { - cache.outlines.clear(); - } - let outline = contour::picture_outline(bytes, crop).map(Arc::new); - cache.outlines.insert(key, outline.clone()); - outline + picture_outline(&mut cache.outlines, alphas, key, bytes, crop) }) } @@ -2345,6 +2368,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; @@ -4411,6 +4435,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(); @@ -4431,13 +4463,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() { @@ -4537,15 +4566,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; @@ -4974,6 +4998,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 e42b3773d1..316eb87323 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), @@ -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; @@ -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..483ec44976 --- /dev/null +++ b/crates/layout/src/tests_budget.rs @@ -0,0 +1,296 @@ +//! 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::props::HeightRule; +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, 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); + 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 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()); +} + +/// 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 + // 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] +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. + 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, .. }))); +} + +/// 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_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. + 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 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)); + 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, 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_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_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 = 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(); + 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"); +} + +/// 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/tests_linear.rs b/crates/layout/src/tests_linear.rs index e49409693c..4dc2d741dc 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -6,8 +6,12 @@ //! 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, 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 { @@ -201,6 +205,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 +327,169 @@ 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"); + } +} + +/// #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}"); +} diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..0f00c5b994 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. @@ -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 { 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/layout/src/textbox.rs b/crates/layout/src/textbox.rs index c3b87f7c77..22ace35d11 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -208,6 +208,21 @@ 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. 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 +/// 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) +} + /// 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() }; @@ -221,7 +236,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); @@ -233,8 +248,13 @@ 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 items = match layouts.iter().find(|(k, _)| *k == key) { + let key = flow_key(len, body.vert); + // 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(), None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index a07c893913..3de7878c7b 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::{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; @@ -33,7 +33,7 @@ pub struct Remote { next_id: u64, /// Whether a connection ever succeeded (`lazy` starts without one; the app may open later). connected: bool, - /// 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, @@ -62,25 +62,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 { @@ -104,11 +125,13 @@ impl Remote { Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) } } + /// The address the app is reached at, as given. pub fn addr(&self) -> &str { &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 { // Nothing is known about the address until a connection succeeds. if !self.connected { @@ -116,17 +139,19 @@ impl Remote { } (!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 ) }) } + /// 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; 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(); @@ -287,15 +312,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}"); @@ -305,6 +327,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"), @@ -318,6 +345,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). @@ -456,9 +493,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/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 0bca29e643..b3eaf5b49a 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 f23f72f751..87d74bcdb7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -101,21 +101,22 @@ fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() { } } - // The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a - // fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last - // line, which no Enter followed), so what was written depended on the line's place. + // Each break is the Enter it stands for: Enter's AutoCorrect (a capital for a sentence's + // first word, a fixed typo) runs on the word the break ends, so "one" is capitalised and + // "teh" is fixed; the last line, which no Enter follows, is left as typed (#3692). let mut s = Server::new(Box::new(Headless::default())); tool(&mut s, "new_document", json!({})); tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true})); let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); - assert_eq!(texts, ["one", "two teh", "three"]); + assert_eq!(texts, ["One", "two the", "three"]); // CRLF is one break per line end. let mut s = Server::new(Box::new(Headless::default())); tool(&mut s, "new_document", json!({})); tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true})); let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); - assert_eq!(texts, ["a", "b", "c"]); + // A CRLF is one break, and each break capitalises the word it ends (Enter's AutoCorrect). + assert_eq!(texts, ["A", "B", "c"]); // One Undo step takes all the lines back. let mut s = Server::new(Box::new(Headless::default())); @@ -191,17 +192,16 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend that refuses every text insertion (a protected document, say). -struct RefusesInsert { - calls: std::sync::Arc>>, +/// A backend that keeps every command it is sent and refuses typed paragraphs. +struct RefusesParagraph { + calls: std::sync::Arc>>, } -impl crate::backend::Backend for RefusesInsert { +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(), params["params"].clone())); - match command.as_str() { - "text.insert" => Err("editing refused".into()), + self.calls.lock().unwrap().push(params.clone()); + match params["params"]["paragraphs"].as_bool() { + Some(true) => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -209,24 +209,92 @@ impl crate::backend::Backend for RefusesInsert { false } fn describe(&self) -> String { - "refuses edits".into() + "refuses paragraphs".into() } } -/// #1356: a refused edit is reported and nothing is typed after it. The lines are one -/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a -/// line at a time with the next one typed after the break that failed. +/// #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_paragraphs_reports_a_refusal_and_types_nothing_after_it() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() })); + let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); 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("editing refused"), "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); let calls = calls.lock().unwrap(); - assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}"); - assert_eq!(calls[0].0, "text.insert"); - assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(calls[0]["command"], "text.insert"); + 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 +/// 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())); + 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")); + + // 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!(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 +/// 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"); } /// #3685: an over-long request line gets an error reply and the server keeps serving. @@ -245,3 +313,45 @@ fn over_long_request_line_is_rejected_and_serving_continues() { assert!(lines[0].contains("too long"), "{}", lines[0]); assert!(lines[1].contains(r#""id":5"#), "{}", lines[1]); } + +/// 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 f543043e49..26fcafa97b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -48,10 +48,21 @@ 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}}) } +/// Every MCP tool with its title, description, input schema and annotations (`tools/list`). pub fn tool_definitions() -> Value { json!([ tool( @@ -64,31 +75,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 +107,46 @@ 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 (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).", - obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), + &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 + ), + 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 +175,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 { @@ -165,8 +209,11 @@ 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); + let doc = a.get("doc").filter(|d| !d.is_null()); match name { "list_commands" => { let q = st("query").unwrap_or("").to_lowercase(); @@ -188,7 +235,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 +243,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 +255,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,31 +274,37 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { Some(p) => json!({"path": p}), None => json!({}), }, + doc, )) } } "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - // One command for all the lines: `text.insert` takes a carriage return as a - // paragraph break. A command per line was quadratic in the document (each one - // snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command), - // and left an Undo step per line. CRLF is one break, not two (#3973). - wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true}))) + // 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. 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}), doc)) } 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), @@ -312,6 +369,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/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"); +} 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()); -} 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/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1a257045f9..e52538a375 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,14 @@ 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 = "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`). 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 = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +# One native window per open document: the window list, the Window menu and the egui viewports. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs index cfc70fc258..ea46cb4448 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/backstage.rs b/crates/ui-egui/src/backstage.rs index c2a99251fa..64f16b4bf5 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -329,7 +329,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::unique("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() { @@ -571,8 +571,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(()) @@ -1038,29 +1038,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); @@ -1122,14 +1120,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::unique("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::unique("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::unique("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(); @@ -1174,33 +1173,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 991bb0140b..69a375e312 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -144,8 +144,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, @@ -229,6 +230,32 @@ pub struct CanvasState { type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc); 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; @@ -1282,7 +1309,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) @@ -5763,17 +5791,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()); @@ -5800,15 +5828,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/content_controls.rs b/crates/ui-egui/src/content_controls.rs index 923be1d215..b482244590 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/control.rs b/crates/ui-egui/src/control.rs index 5a1ae6387f..efa28f266e 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; @@ -47,7 +51,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(), @@ -63,6 +67,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}, }) } @@ -115,21 +121,102 @@ 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) + } + } +} + /// Dispatches one JSON-lines control request: commands and inspection, parity, synthetic click, /// drag, key, wheel and text input, screenshots and renders, resize, focus and quit, and unknown /// ids as commands. It refuses closing unsaved work and document operations without a document. 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() { @@ -268,7 +355,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), }, @@ -281,11 +368,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O let Some(size) = crate::window_geometry::requested_size(egui::vec2(w, h), max_side) else { return err("width and height must be positive numbers no larger than 3.4e38"); }; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(size)); ok(json!({"width": size.x, "height": size.y})) } "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!({})) } @@ -308,11 +395,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()})) } @@ -369,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; } @@ -392,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 } } } @@ -404,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, } @@ -413,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> { @@ -482,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], @@ -536,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; @@ -597,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, @@ -621,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 }; @@ -637,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 { @@ -675,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; } } @@ -691,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 { @@ -763,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; } @@ -772,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 { @@ -993,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. @@ -1118,3 +1230,7 @@ mod screenshot_tests { assert!(!outside.exists()); } } + +#[cfg(test)] +#[path = "control_tests.rs"] +mod doc_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/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index eb82bdf312..a464e03e7f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4092,7 +4092,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::unique("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})); @@ -4792,15 +4793,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; @@ -4836,8 +4838,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] diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index e14d29f092..b1f38ccb9e 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -621,7 +621,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(); } @@ -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(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") { @@ -677,7 +679,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(); @@ -715,17 +717,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); + 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 { + 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); + } } fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str) { @@ -765,7 +765,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/documents.rs b/crates/ui-egui/src/documents.rs new file mode 100644 index 0000000000..c843e19d2a --- /dev/null +++ b/crates/ui-egui/src/documents.rs @@ -0,0 +1,357 @@ +//! 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 quitting asks about every document with unsaved changes, one after +//! another, each in its window. + +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 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. + /// 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; + } + if self.windows.native() && self.follow_in_windows(id) { + return; + } + 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(); + 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 = 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. + if let Some(placeholder) = self.no_document + && placeholder != id + && self.session.is_open(placeholder) + { + let _ = self.session.close_document(placeholder); + self.parked.remove(&placeholder); + } + } + + /// 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 + } + + /// 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())?; + 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. The Start screen's + /// placeholder is never one, though other windows' documents are. + fn unsaved_documents(&self) -> Vec { + let active = self.session.document_id(); + let mut out = Vec::new(); + 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 && Some(d.id) != self.no_document && (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 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 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 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 (key, label, current) in entries { + if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + if native { + app.show_window(key); + } else { + let _ = app.run("view.switchWindows", json!({"id": key})); + } + 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()); + } + + /// 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(); + 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); + } + + /// 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/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 4f274e8e7f..463467bff7 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -287,7 +287,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); } @@ -535,7 +535,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")); @@ -583,7 +583,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 f40ebf1867..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 } @@ -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 @@ -446,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) { @@ -601,7 +606,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 +629,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 +966,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/format_shape.rs b/crates/ui-egui/src/format_shape.rs index 9fa67a1256..699e2ff0a7 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -80,10 +80,6 @@ fn apply(app: &mut WordApp, ui: &Ui, fill: Option<&Fill>, resp: Option<&Response /// The pane, while open. pub fn pane(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Format Shape") { - let _ = app.run("view.formatShapePane", serde_json::json!({"value": false})); - return; - } // A drag that applied as it went ends as one undo step. if !ui.input(|i| i.pointer.any_down()) && let Some(mark) = ui.data_mut(|d| { diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index a3bfd583b7..b9b1f870bd 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -259,10 +259,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 => { @@ -316,6 +320,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; + } interrupted_preedit |= std::mem::take(&mut app.canvas.ime_interrupted_preedit); let stale_composition_event = if interrupted_preedit { match &e { @@ -674,7 +683,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; @@ -688,12 +697,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; @@ -1469,16 +1483,27 @@ 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. 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.egui_wants_keyboard_input() || 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()); + 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) { + if leave_full_screen(app, ctx, key, modifiers, false) { continue; } if is_global_shortcut(key, modifiers) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 634e5d37f2..fb674a1c5e 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"))] @@ -87,6 +88,8 @@ pub mod updates_ui; pub mod widgets; pub mod window_close; pub mod window_geometry; +mod window_ui; +mod windows; pub mod wordart_menus; pub mod zotero; @@ -292,6 +295,22 @@ 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, + /// 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, @@ -380,6 +399,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`. @@ -409,6 +431,11 @@ 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, + pane_places: Default::default(), + pane_order: Vec::new(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), @@ -500,6 +527,18 @@ 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> { + Ok(panes::float_rect(&Value::deserialize(d)?)) +} + /// `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> { @@ -691,6 +730,16 @@ 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 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`). @@ -721,22 +770,75 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } + // 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, updates_rt: updates_ui::Runtime::default(), control_rx: None, shots: control::Shots::default(), @@ -746,18 +848,13 @@ impl WordApp { synthetic: Vec::new(), synthetic_pending: false, synthetic_injected: 0, - 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, recover_fonts_for: None, recent_fonts_for: Vec::new(), - find_fonts_for: None, fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -769,38 +866,55 @@ 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, ime_origin: false, - 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(), + parked: Default::default(), + windows: windows::Windows::new(shown_document), + print_document: None, + 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, } } @@ -821,6 +935,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 } @@ -843,6 +958,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.check_spelling = self.session.view.proofing; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); @@ -891,6 +1007,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; self.session.view.proofing = self.ui.check_spelling; // Keys for commands this version doesn't have are dropped. self.session.keymap = std::mem::take(&mut self.ui.keyboard); @@ -956,7 +1073,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, @@ -1156,17 +1276,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 @@ -1385,6 +1501,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); } } } @@ -1448,39 +1567,67 @@ 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 refused = panes::flush_comment_drafts(self).is_err(); - if !self.session.dirty && !self.save_unconfirmed() && !refused { + if self.has_document() { + 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, 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}`, + /// 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 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()); } + 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 { + // 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})); + } + self.close_shown_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.ui.backstage = false; + self.close_shown_document()?; + self.recovery_seen = true; + if last { + self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); + } + // 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})) } @@ -1552,7 +1699,18 @@ impl WordApp { } /// Requests commands make of the UI (open a dialog…). - fn handle_request(&mut self, req: &Value) { + 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() + { + 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). if req.get("focus").and_then(Value::as_str) == Some("navSearch") { self.nav_search_focus = true; @@ -1590,6 +1748,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), } } @@ -1598,6 +1757,33 @@ impl WordApp { } } + /// 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 { + 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::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 /// 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. @@ -1696,6 +1882,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}) @@ -1930,6 +2117,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) } @@ -2220,7 +2408,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(), }, } @@ -2229,6 +2419,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; } @@ -2327,31 +2522,29 @@ 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); + } else { + self.poll_printing(ctx); } - 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); + // 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; @@ -2363,21 +2556,67 @@ impl WordApp { if let Some(wait) = self.session.recovery_wait() { ctx.request_repaint_after(wait); } + if !native { + self.window_input(ctx); + } + self.send_control_input(ctx); + // Screenshot deadlines, geometry and completions belong to `logic`; issuing the one + // callback command belongs to `ui` (`flush_shot_and_synthetic`). A queued/settling capture + // needs a real UI pass, and so does an injected synthetic step, even while the window is + // hidden. + control::drain_screenshots(self, ctx); + if self.shots.in_flight() || self.synthetic_pending || !self.synthetic.is_empty() { + let viewport = self.active_viewport(); + if ctx.input_for(viewport, |i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + ctx.request_repaint_of(viewport); + 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_recover_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)); } - // Screenshot deadlines, geometry and completions belong to `logic`; issuing the one - // callback command belongs to `ui` (below). A queued/settling capture needs a real UI - // pass, and so does an injected synthetic step, even while the window is hidden. - control::drain_screenshots(self, ctx); - if self.shots.in_flight() || self.synthetic_pending || !self.synthetic.is_empty() { - if ctx.input(|i| i.viewport().visible()) != Some(true) { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - } - ctx.request_repaint(); + } + + /// A window's own input before it draws: ⌘Q. Runs in [`WordApp::logic`] with one window, + /// else as each window draws (each native window has its own input). The SmartArt Text + /// Pane's Tab keys and dropped files are read in the first UI pass + /// ([`WordApp::handle_ui_input_events`]). + fn window_input(&mut self, ctx: &egui::Context) { + if self.windows.native() && ctx.input_mut(|i| i.consume_shortcut(&QUIT)) { + self.quit(); + } + // 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(); } - self.report_dirty(); } /// Files dropped on the native window. Raw input is taken only in the first UI pass: a @@ -2403,7 +2642,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) }; } if self.session.document_id() != before { @@ -2427,17 +2666,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; } @@ -2512,7 +2763,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) { @@ -2523,18 +2776,11 @@ impl WordApp { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 { + if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 || !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, n)) = self.peek_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; @@ -2543,13 +2789,53 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.iter().take(n).cloned()); + raw.events.extend(events); self.synthetic_pending = true; self.synthetic_injected = n; } - /// Lay out the window. + /// The control channel's next input frame, taken off the queue. + fn next_synthetic(&mut self) -> Option> { + let (events, n) = self.peek_synthetic()?; + self.synthetic.drain(..n); + Some(events) + } + + /// 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; with how many queued events it takes. + fn peek_synthetic(&self) -> Option<(Vec, usize)> { + 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.iter().take(n).cloned()); + Some((events, n)) + } + + /// 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)); @@ -2576,8 +2862,13 @@ impl WordApp { backstage::forget_save_as_error(&ctx); } self.paint_window(ui, &ctx, t); - self.show_overlays(ui, &ctx); - self.sync_title_and_frame(&ctx, t0); + if !moved(self) { + self.show_overlays(ui, &ctx); + } + if !moved(self) { + self.sync_title_and_frame(&ctx, t0); + } + // The synthetic step this frame took is done even when drawing ended early. self.flush_shot_and_synthetic(&ctx); } @@ -2611,6 +2902,8 @@ impl WordApp { /// Draw the File page, the empty-window chrome, or the normal document window. fn paint_window(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, t: theme::Tokens) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; if self.ui.backstage { backstage::show(self, ui); } else if !self.has_document() { @@ -2627,24 +2920,39 @@ 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); } } } - /// Dialogs, panes and other overlays drawn over the window. + /// Dialogs, panes and other overlays drawn over the window. A command that brings another + /// window's state in ends the drawing: the rest would be the other window's. fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; content_controls::popup(self, ctx); dialogs::show(self, ctx); + if moved(self) { + return; + } updates_ui::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, ctx); @@ -2652,7 +2960,13 @@ impl WordApp { 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)); @@ -2661,13 +2975,12 @@ impl WordApp { /// Keep the window title and full-screen state in sync, then record this frame's time. fn sync_title_and_frame(&mut self, ctx: &egui::Context, t0: f64) { - 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); @@ -2820,6 +3133,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) { @@ -3050,20 +3374,11 @@ 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. -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.recover" => params.get("restore").is_some(), - "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), - "mailings.printDocuments" => true, - _ => false, - } +/// User commands that close the shown document: Close. New, Open, restoring a recovered copy, +/// Envelopes, Labels, Finish & Merge and Print Documents 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" } /// Monotonic milliseconds for deadlines, settling and animation. `web_time::Instant` is a real @@ -5737,27 +6052,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] @@ -5781,9 +6088,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")); @@ -5796,20 +6102,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); } @@ -6531,15 +6841,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 [ @@ -6891,6 +7201,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.) @@ -6981,7 +7355,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 @@ -7098,11 +7473,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); @@ -7117,10 +7492,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"); @@ -7319,9 +7694,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)); @@ -7346,21 +7721,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); } @@ -7368,12 +7743,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()); @@ -7393,11 +7768,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(); @@ -7410,11 +7785,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); @@ -7425,12 +7800,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(); @@ -7442,62 +7817,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)); @@ -7615,35 +7989,28 @@ 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. + // A `path` that isn't text merges into a new document beside this one, like no path at all. let mut a = typed(); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); + let document = a.session.document_id(); a.run("mailings.finish", json!({"path": null, "records": "all"})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); + assert_eq!(prompt(&a), None); + assert_ne!(a.session.document_id(), document); } /// A clean document is replaced straight away, like New; a merge written to a file @@ -7672,7 +8039,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 { @@ -7685,14 +8052,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"); @@ -7725,26 +8087,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}"); } } @@ -7976,6 +8340,125 @@ 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_contains("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"); + } + + /// 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 check_spelling_survives_restart() { // #1792: unchecking Check spelling as you type was forgotten on restart. @@ -9152,10 +9635,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(); @@ -9369,7 +9853,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::unique("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(); @@ -9554,7 +10039,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)); } @@ -9971,13 +10456,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: 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, "Navigation"), "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 e4b34a8784..00c31190ef 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,129 +1,456 @@ -//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, -//! Accessibility and Alt Text (see `a11y_pane`), Format Shape (see `format_shape`), Restrict Editing ([`crate::dialogs_review`]), Comments, -//! and the SmartArt Text Pane. +//! Task panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, +//! Accessibility and Alt Text (see `a11y_pane`), Format Shape (see `format_shape`), Restrict Editing +//! ([`crate::dialogs_review`]), Comments, 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::numbering::{Counters, LevelSuffix}; use wordcraft_doc::section::NumFormat; use wordcraft_doc::{Document, Path, 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 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. + SmartArtText, + Navigation, + Clipboard, + Styles, + StyleInspector, + Accessibility, + AltText, + FormatShape, + 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::FormatShape => "Format Shape", + 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::FormatShape => (Edge::End, 280.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)); + + /// 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", + } } - 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)); + + const ALL: [Self; 10] = [ + Self::SmartArtText, + Self::Navigation, + Self::Clipboard, + Self::Styles, + Self::StyleInspector, + Self::Accessibility, + Self::AltText, + Self::FormatShape, + Self::RestrictEditing, + Self::Comments, + ]; + + fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|pane| pane.key() == key) } - 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.session.view.format_shape_pane { - crate::format_shape::forget_drag(ui.ctx()); - } else { - egui::Panel::right("format_shape_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| egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(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::FormatShape => Some("view.formatShapePane"), + 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; +/// 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), + (Pane::Styles, v.styles_pane), + (Pane::StyleInspector, v.style_inspector), + (Pane::Accessibility, v.accessibility_pane), + (Pane::AltText, v.alt_text_pane), + (Pane::FormatShape, v.format_shape_pane), + (Pane::RestrictEditing, app.restrict.open), + (Pane::Comments, v.comments_pane), + ] + .into_iter() + .filter_map(|(pane, shown)| shown.then_some(pane)) + .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 +/// `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). +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: {title}").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::FormatShape => { + egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(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() + } +} + +/// The pane host's name. Its focus and drag state is per window ([`WordApp::window_id`]); where +/// each pane sits is the app's. +const HOST: &str = "task_panes"; + +/// 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) +} + +/// 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; + 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, }); }); - ui.add_space(4.0); - close + if let Some(reverse) = cycle { + craft_ui::panes::focus_next(ctx, app.window_id(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()); + let open = open(app); + if !open.contains(&Pane::FormatShape) { + crate::format_shape::forget_drag(ui.ctx()); + } + route_f6(app, ui.ctx()); + let states: Vec<(Pane, PaneState)> = open.iter().map(|pane| (*pane, pane_state(app, *pane))).collect(); + let out = PaneHost::new(app.window_id(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| states.iter().find(|(p, _)| p == pane).map(|(_, state)| *state), &mut Content { app }); + 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. +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(pane, 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("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), + 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", "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::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() { + Some("left") => SavedDock::Left, + Some("right") => SavedDock::Right, + Some("float") => SavedDock::Float, + _ => 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)?; + 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) => { + let _ = app.run(id, json!({"value": false})); + } + None => app.restrict.open = false, + } } /// Draws the Navigation pane: search box and tabs, page list, find results and heading outline. 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::unique("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); @@ -150,7 +477,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(); @@ -312,12 +639,8 @@ 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) { - 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| { + 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; @@ -358,10 +681,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 @@ -372,7 +691,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); } @@ -435,16 +755,14 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { /// Draws the Comments pane: New Comment and scrollable comment and reply cards with edit, go-to, /// reply, resolve or reopen, and delete actions. 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": ""})); } 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); @@ -464,7 +782,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::unique(("comment_reply_open", id)); + let open_key = egui::Id::unique(("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() @@ -475,7 +793,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::unique(("comment_reply_focus", id)), true)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(("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() { @@ -486,7 +804,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { } }); if replying { - let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(("comment_reply_focus", id)))).unwrap_or(false); + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(("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)); } @@ -518,6 +836,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. @@ -671,10 +994,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); @@ -908,6 +1227,10 @@ fn camel_words(s: &str) -> String { #[cfg(test)] mod tests { + use super::{Content, Pane}; + use craft_ui::panes::PaneContent; + use egui::vec2; + /// #1902: Clipboard Delete returns focus to the document like paste does. #[test] #[cfg(not(target_arch = "wasm32"))] @@ -939,6 +1262,359 @@ mod tests { 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"); + } + + 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}"); + } + } + } + + /// 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"); + } + + /// 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")); + } + + /// 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"); + } + + /// 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] + #[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"); + } + } + } + + /// 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:?}"); + } + + /// 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); + } + + /// `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/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index b8e73a012f..0331bd71ef 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 { @@ -517,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::unique("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); @@ -590,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::unique("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 8a8fafa95b..289c44a394 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,18 +43,31 @@ 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 { 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/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0f498994b5..eb3366e7dd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2354,6 +2354,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/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index 1b456beb09..b58129b3ed 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -28,11 +28,15 @@ pub struct RawWindowState { impl RawWindowState { /// Copy the window state out of this frame's raw input. pub fn from_raw(raw: &egui::RawInput) -> Self { - let viewport = raw.viewports.get(&raw.viewport_id); + Self::from_viewport(raw.viewports.get(&raw.viewport_id), raw.screen_rect) + } + + /// The state of one viewport, from what egui knows about it and its content rectangle. + pub fn from_viewport(viewport: Option<&egui::ViewportInfo>, screen_rect: Option) -> Self { Self { inner_rect: viewport.and_then(|v| v.inner_rect).filter(|r| r.is_finite()), outer_rect: viewport.and_then(|v| v.outer_rect).filter(|r| r.is_finite()), - screen_rect: raw.screen_rect.filter(|r| r.is_finite()), + screen_rect: screen_rect.filter(|r| r.is_finite()), monitor_size: viewport.and_then(|v| v.monitor_size), maximized: viewport.and_then(|v| v.maximized), minimized: viewport.and_then(|v| v.minimized), diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 0000000000..9d938ee243 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,401 @@ +//! 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. + 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: _, + 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: _, + 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: _, + windows: _, + print_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: _, + // Where each task pane sits, and their order. + styles_pane_dock: _, + styles_pane_float: _, + styles_pane_width: _, + pane_places: _, + pane_order: _, + // 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"); + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 0000000000..bb3e19c9d6 --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,1367 @@ +//! 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`]). +//! +//! 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}; + +/// 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, + /// 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. +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(), view: None }], + 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. 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 || !self.windows.driven { + return ViewportId::ROOT; + } + let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + 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) { + self.windows.builder = builder; + } + + /// 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 + } + + /// 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, 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 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 view = w.view.take(); + 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); + 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. `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(), 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; + 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.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 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) { + 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(), view: None }); + changed = true; + } + if changed { + self.settle_windows(); + } + } + + /// [`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. 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); + 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, None), + } + return true; + } + if let Some(key) = other { + 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 (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 }; + if let Some(w) = self.windows.get_mut(key) { + w.requests.push(req.clone()); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + + /// 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 + .entries() + .iter() + .filter_map(|e| { + let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; + 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() { + 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.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 + // 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(); + 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(); + } + + /// 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) { + self.prune_windows(); + self.settle_windows(); + 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); + // 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; + } + } + + /// 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; + } + 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; + } + 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::unique(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.clone())); + 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(ctx, &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"); + 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); + } + + /// A request an agent's command makes for the document of another window is that window's: + /// 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.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(), a.active_viewport()), (second, child), "the active document stays"); + assert!(!a.ui.backstage); + frames(&ctx, &mut a, 2); + 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 + /// 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"); + } + + /// 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"); + } + + /// `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] + 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); + } + + 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"); + } + + /// 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(); + } +} diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index 88e69b1e8a..6d0bd9f7f9 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, @@ -156,6 +163,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 +229,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 +352,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()); diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 07df98e79f..09abd8de43 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" (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 @@ -35,23 +36,13 @@ 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(); - 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); - } - } - } + 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)); - 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,43 +52,134 @@ 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. +/// 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))] -pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { +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 { + 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 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 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 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 }; 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 = 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); + } + 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) 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))] @@ -167,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(())); } @@ -205,6 +287,105 @@ 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), + } + } + } + + /// 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. 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); + // 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) + } + } + 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". + #[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; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c62c3d91..25412d3882 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. @@ -52,10 +54,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), `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 | +| `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,13 +66,50 @@ 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` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for | | `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. 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 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 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 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}} +``` + +`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 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). @@ -94,8 +133,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). +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/file-format-parity.md b/docs/file-format-parity.md index 04d901e9a4..7f56600bf4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -92,7 +92,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 | ❌ | ❌ | | @@ -274,6 +274,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-22 | trivial | Hostile input: DOCX package, comment, font, chart and Flat OPC hardening (#3692, #3723, #3704, #3742, #3648) | | 2026-10-22 | trivial | An embedded font file yields at most 256 faces however many collection members or named instances it lists (#3716) | | 2026-10-22 | trivial | Importer hardening (#3692, #3705, #3713, #3714): spreadsheet header numbering and ODS repeated cells, LaTeX wrapper nesting and verbatim ends, Markdown delimiter runs, blank-span trimming and link destinations, and font/link copies per run are bounded or escaped; normal documents unchanged | +| 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-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | diff --git a/docs/gaps.md b/docs/gaps.md index 42a5ffef7e..dd16596f79 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where WordCraft falls short of Word +> **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 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; 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 @@ -88,7 +89,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) | @@ -149,6 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | | 2026-10-25 | trivial | Small follow-ups from the field, table and AutoFormat batch (#4232) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index c82dad91b8..36f6989c7c 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, 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 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 | ✅ | | — | @@ -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 | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones are drawn unhinted, and a face draws its later glyphs unhinted once at least three of its hinted draws took over 50 ms and those are at least 1/16 of its hinted draws; a colour glyph's gradient stops and clip paths share one budget | | 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) | diff --git a/docs/mcp.md b/docs/mcp.md index f44ecd9aba..09ab419c16 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -36,7 +36,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 @@ -66,12 +69,16 @@ 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`. `font.add` and `font.remove` change the - user font folder, so they are refused when it is outside the write root (#3692). + user font folder, so they are refused when it is outside the write root (#3692). 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. @@ -110,15 +117,41 @@ 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 | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) | +| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo + step; each break is the Enter it stands for, so AutoFormat and AutoCorrect run and a + one-line control refuses; 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 | | `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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8c46325ece..5708be0658 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. -**504 of 520 catalog features have commands (96.9%).** +**505 of 520 catalog features have commands (97.1%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | References | 25 | 25 | | Mailings | 23 | 23 | | Review | 29 | 31 | -| View | 25 | 29 | +| View | 26 | 29 | | Read Mode | 2 | 3 | | Developer | 11 | 11 | | Table Design | 8 | 8 | @@ -61,7 +61,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`) ### Read Mode diff --git a/docs/recovery.md b/docs/recovery.md index 4431efbb44..dfb307565a 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. diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index 99b36bb161..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 -`17744430be327d0257496a6a31c8d38fe20a6756`. 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. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fce845faac..999ae67524 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-25 · **Change:** trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold; previously trivial: page text on Linux and the BSDs is hinted like FreeType's hintslight; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; 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-25 · **Change:** trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; 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 (times in the user's own time zone, in the pane and the balloons, #2322, #2337; 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); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); 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); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); 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% | — | @@ -84,6 +84,9 @@ 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-25 | trivial | Custom Document Properties dialog: File › Info › Properties › Advanced Properties… opens it; the document's custom properties listed with Add, Modify and Delete over a name/value form (text properties only; Word's number/date/yes-no types not offered), OK one undoable `file.properties` step, Cancel discards; modal dialogs 62 → 63 (#2982) | | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | diff --git a/docs/zotero.md b/docs/zotero.md index cf5813494c..4e96520ad5 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` (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 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