diff --git a/README.md b/README.md index 1fb2b6eb40..cf055372c6 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,11 @@

WordCraft

- Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust. + Writing and document design; an open-source, modern word processor, built in pure Rust.

- A fast, open-source word processor with the Word workflow you already know: the ribbon, styles, + A fast, open-source word processor with a familiar workflow: the ribbon, styles, tables, track changes, references and mail merge. It reads and writes .docx, runs natively on macOS, Windows, Linux and BSD, and in the browser via WebAssembly.
By the ArtCraft team. @@ -86,10 +86,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes ## Why WordCraft -- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list, +- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list, Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live, F4 repeats, F7 checks spelling, F8 extends the selection. -- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF +- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF with real, selectable text, links and bookmarks. - **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about 1.4 ms; pages render on demand. @@ -118,7 +118,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | | **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) | -| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | +| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the [parity assessment](docs/target-app-parity.md) and the generated @@ -190,9 +190,9 @@ covers every target starting with it (`wordcraft*=debug`). The logger is | L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation | | L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT | | L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) | -| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog | +| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog | | L5 | `wordcraft-mcp` | MCP server | -| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) | +| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) | | apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser | `cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering @@ -200,12 +200,13 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS ## Roadmap -WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting -depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing -all of Word for real work, about 55% for a typical professional's weekly work and about 63% for -everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt -through a round trip, pagination closer to Word's, a stability sweep and native printing. Details -and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md). +WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog +([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting +depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work, +about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta +needs testing against real-world .docx files, keeping charts and SmartArt through a round trip, +closer pagination fidelity, a stability sweep and native printing. Details and estimates: +[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md). ## Downloads @@ -248,9 +249,10 @@ WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, also avoids DirectX 12 altogether. **If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a -driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's -software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL -instead, without setting anything. The log file records the choice (lines starting with +driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in +software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead, +without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software +adapter. The log file records the choice (lines starting with `graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`. ### macOS @@ -378,8 +380,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 00266507f7..a121d85a4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,8 +2,8 @@ **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-18 · **Change:** 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 (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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** 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 (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. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -136,9 +136,13 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document. +- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in. +- 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey. - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | +| 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | | 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation | @@ -156,6 +160,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-15 | RTF keeps more tracked changes (gap 19): tracked paragraph marks (a `\par` inside a `\revised`/`\deleted` group) and character and paragraph formatting changes (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) are read and written, so their balloons and Accept/Reject survive an RTF round trip | | 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt | | 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked | +| 2026-10-15 | The caret, clicks and selection edges fall between the letters of a ligature (fi, ffi, lam-alef), as in Word: the glyph's advance is shared evenly among its letters, mirrored in right-to-left text (#1974) | | 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) | | 2026-10-16 | Update Field is offered on a table of contents after a save and reopen, and repeated save and update adds no blank line (#2182; the flow in that report already worked since #1197) | | 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. | @@ -502,6 +507,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | +| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | +| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) | | 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) | @@ -575,6 +583,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-17 | trivial | Footer page tables no longer inflate footer text height; body lines wrap at their physical bounds | | 2026-10-17 | minor | Progress log: Scale to paper size on File › Print, #2108 | | 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position | +| 2026-10-15 | trivial | Progress log: caret inside ligatures (#1974) | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 6c284a0881..446b902fac 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -1214,8 +1214,9 @@ mod tests { fn script_parse_resolves_paths_against_the_base() { let s = script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base"))) .unwrap(); - assert_eq!(s.file.as_deref(), Some("/base/in.docx")); - assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), "/base/o.docx".to_string()]); + let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned(); + assert_eq!(s.file, Some(joined("in.docx"))); + assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]); let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap(); assert_eq!(s.save, vec!["o.docx".to_string()]); assert_eq!(s.commands.len(), 1); diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index 97ea47f4de..7196f96deb 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -12,6 +12,12 @@ //! thread first checks that one of them is there and otherwise answers with an error, and the app //! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one. //! +//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999). +//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog +//! is asked for once more without a parent window, and if that also comes back at once the +//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is +//! the person's and is answered as one. +//! //! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog use std::future::Future; @@ -54,28 +60,22 @@ impl Launcher { pub fn show(&self, parent: &eframe::Frame) { while let Ok((req, answer)) = self.queue.try_recv() { // Built here, on the UI thread: macOS shows the panel as a sheet on the window now. - let dialog = rfd::AsyncFileDialog::new().set_parent(parent); - let picking: Picking = match req { - FileDialogRequest::Open { purpose } => { - Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }) - } - FileDialogRequest::OpenMany { purpose } => { - Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }) - } - FileDialogRequest::Save { name } => { - let mut dialog = dialog.set_file_name(name.clone()); - for (label, exts) in save_filters(&name) { - dialog = dialog.add_filter(label, &exts); - } - Box::pin(async move { dialog.save_file().await.into_iter().collect() }) - } - }; + let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req); + #[cfg(all(unix, not(target_os = "macos")))] + let unparented = picker(rfd::AsyncFileDialog::new(), &req); let ctx = self.ctx.clone(); let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || { - // The picker only starts when first polled: without a system dialog it never does. + // The pickers only start when first polled: without a system dialog they never do. let picked = match no_system_dialog() { Some(reason) => Err(reason), - None => Ok(pollster::block_on(picking).iter().map(|f| f.path().to_string_lossy().to_string()).collect()), + None => { + // Only the portal folds a failure into "cancelled" (#2999). + #[cfg(all(unix, not(target_os = "macos")))] + let picked = pick(picking, unparented, TOO_QUICK); + #[cfg(not(all(unix, not(target_os = "macos"))))] + let picked: Result<_, String> = Ok(pollster::block_on(picking)); + picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect()) + } }; let _ = answer.send(picked); ctx.request_repaint(); @@ -88,6 +88,53 @@ impl Launcher { } } +/// The picker for `req` on `dialog`. +fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking { + match req.clone() { + FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }), + FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }), + FileDialogRequest::Save { name } => { + let mut dialog = dialog.set_file_name(name.clone()); + for (label, exts) in save_filters(&name) { + dialog = dialog.add_filter(label, &exts); + } + Box::pin(async move { dialog.save_file().await.into_iter().collect() }) + } + } +} + +/// Quicker than anyone can see a dialog and dismiss it. +// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal +// failing slower than this still reads as a cancel; ask rfd for the response code if it matters. +#[cfg(all(unix, not(target_os = "macos")))] +const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300); + +/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its +/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent +/// window; if that too comes back at once, an error, so the app shows its built-in browser. +/// Linux and the BSDs only (built everywhere for its test). +#[cfg(any(test, all(unix, not(target_os = "macos"))))] +fn pick( + picking: impl Future>, + unparented: impl Future>, + too_quick: std::time::Duration, +) -> Result, String> { + fn timed(f: impl Future>, too_quick: std::time::Duration) -> (Vec, bool) { + let start = std::time::Instant::now(); + let picked = pollster::block_on(f); + let quick = picked.is_empty() && start.elapsed() < too_quick; + (picked, quick) + } + match timed(picking, too_quick) { + (_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"), + (picked, false) => return Ok(picked), + } + match timed(unparented, too_quick) { + (_, true) => Err("the system file dialog closed at once".into()), + (picked, false) => Ok(picked), + } +} + /// The file types Open offers (shared with the built-in browser). fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog { for (name, exts) in wordcraft_ui_egui::open_filters(purpose) { @@ -198,7 +245,24 @@ fn format_name(ext: &str) -> String { #[cfg(test)] mod tests { - use super::{save_exts, save_filters}; + use super::{pick, save_exts, save_filters}; + use std::time::Duration; + + /// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a + /// parent, then the built-in browser. A pick, or a cancel after a while, is the answer. + #[test] + fn an_instant_cancel_retries_then_falls_back() { + let quick = Duration::from_millis(200); + let slow_cancel = || async { + std::thread::sleep(Duration::from_millis(250)); + Vec::::new() + }; + assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1])); + assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent"); + assert!(pick(async { Vec::::new() }, async { vec![] }, quick).is_err(), "the built-in browser"); + assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again"); + assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real"); + } /// #1399: the Save dialog's type list has one named entry per format, the suggested one first, /// every savable extension exactly once. diff --git a/apps/wordcraft/src/graphics.rs b/apps/wordcraft/src/graphics.rs index 65c519ea76..0edd57666f 100644 --- a/apps/wordcraft/src/graphics.rs +++ b/apps/wordcraft/src/graphics.rs @@ -1,6 +1,7 @@ //! Graphics instance configuration applied before wgpu can initialize any driver: Windows //! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip. -// On macOS only the indirect-validation items are used outside tests. +// On macOS only the indirect-validation items are used outside tests. Elsewhere it is the other +// way round, so those two carry their own `allow`. #![cfg_attr(target_os = "macos", allow(dead_code))] use eframe::{ @@ -14,12 +15,14 @@ use eframe::{ /// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft /// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's /// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool { macos && intel } /// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`. /// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) { if !skip { return; diff --git a/contributors/people.toml b/contributors/people.toml index ffde17472b..c4639af254 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -163,3 +163,6 @@ sync_github_name = true [people.maxm11] real_name = "Max Murphy" + +[people.SlavenDj] +sync_github_name = true diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index b5a1c56d0a..b9eb2c6a99 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -242,6 +242,10 @@ pub struct ContentControl { /// The run properties of `w:sdtPr` (`w:rPr`), kept verbatim (XML). #[serde(skip_serializing_if = "String::is_empty")] pub rpr_xml: String, + /// The same run properties, read: the formatting text typed in place of the placeholder + /// takes (ECMA-376 §17.5.2.27). `None` when the control names none. + #[serde(skip_serializing_if = "Option::is_none")] + pub run_props: Option, /// Other `w:sdtPr` children we don't model, kept verbatim (XML) in file order. #[serde(skip_serializing_if = "Vec::is_empty")] pub extra: Vec, diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index a1ce6f9358..5511ac7203 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -111,7 +111,7 @@ pub fn format_number(v: f64, lang: Option<&str>) -> String { if !v.is_finite() { return "0".into(); } - if v.fract() == 0.0 && v.abs() < 1e15 { + if v.fract() == 0.0 && v.abs() < 9.2e18 { return format!("{}", v as i64); } let s = format!("{v:.2}"); @@ -178,4 +178,13 @@ mod tests { } assert_eq!(format_number(f64::NAN, None), "0"); } + + #[test] + fn large_whole_numbers_have_no_decimals() { + assert_eq!(format_number(1e15, None), "1000000000000000"); + assert_eq!(format_number(2.5e15, None), "2500000000000000"); + assert_eq!(format_number(-1e15, None), "-1000000000000000"); + assert_eq!(format_number(9.0e18, None), "9000000000000000000"); + assert_eq!(format_number(1e15 + 0.5, None), "1000000000000000.50"); + } } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 1f2638f221..d375bc0705 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -1400,7 +1400,7 @@ impl Paragraph { let mut a = 0; let mut prev_end = false; for (i, c) in t.char_indices() { - if i >= off { + if i > off { break; } if prev_end && c != ' ' { @@ -1413,7 +1413,9 @@ impl Paragraph { } } let mut b = t.len(); - let mut ended = false; + // A caret on the space right after a sentence's end belongs to that sentence; a terminator + // directly followed by text ("Hello.World") doesn't end the sentence the caret is in. + let mut ended = t.get(..off).unwrap_or("").ends_with(['.', '!', '?']) && t.get(off..).unwrap_or("").starts_with(' '); for (i, c) in t.get(off..).unwrap_or("").char_indices() { if ended && c != ' ' { b = off + i; @@ -1431,6 +1433,19 @@ impl Paragraph { mod tests { use super::*; + #[test] + fn sentence_at_sentence_start() { + let p = Paragraph::with_text("Hello. World", CharProps::default()); + assert_eq!(p.sentence_at(0), (0, 7)); + assert_eq!(p.sentence_at(6), (0, 7)); + assert_eq!(p.sentence_at(7), (7, 12)); + assert_eq!(p.sentence_at(8), (7, 12)); + assert_eq!(p.sentence_at(99), (7, 12)); + let p = Paragraph::with_text("Hello.World", CharProps::default()); + assert_eq!(p.sentence_at(6), (6, 11)); + assert_eq!(p.sentence_at(8), (6, 11)); + } + fn bold() -> CharProps { CharProps { bold: Some(true), ..Default::default() } } diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index a2ef257dfb..8c3a555bc3 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -262,6 +262,7 @@ impl Reader<'_> { let Some(content) = e.child("w:sdtContent") else { return }; let mut ctl = super::sdt::sdt_pr(e.child("w:sdtPr"), true); ctl.end_pr_xml = super::sdt::sdt_end_pr(e.child("w:sdtEndPr")); + ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r)); let mut inner = Blocks::new(); self.read_blocks(sc, content, rels, &mut inner, depth + 1); // A table of contents' control belongs to the TOC: the writer puts it back around the @@ -523,6 +524,7 @@ impl Reader<'_> { let Some(c) = k.child("w:sdtContent") else { return }; let mut ctl = super::sdt::sdt_pr(k.child("w:sdtPr"), false); ctl.end_pr_xml = super::sdt::sdt_end_pr(k.child("w:sdtEndPr")); + ctl.run_props = k.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r)); // The markers take the formatting of the content's first run (typing at the // start of the content continues it). let props = c.find("w:rPr").map(|p| self.run_props(p, ctx)).unwrap_or_else(|| self.run_props_none(ctx)); diff --git a/crates/docx/tests/content_controls.rs b/crates/docx/tests/content_controls.rs index e37de7392e..d1c66c6911 100644 --- a/crates/docx/tests/content_controls.rs +++ b/crates/docx/tests/content_controls.rs @@ -228,3 +228,20 @@ fn deeply_nested_controls_are_bounded() { let back = read(&write(&d).unwrap()).unwrap(); assert_eq!(controls(&back).len(), 60); } + +/// A control's `w:rPr` (ECMA-376 §17.5.2.27) is read as the formatting entered text takes, and +/// still written back as it was. +#[test] +fn a_controls_run_properties_are_read_and_kept() { + let docx = package(&p(&format!( + "{}{}", + t("Name: "), + sdt(r#""#, &t("Eingabe")) + ))); + let d = read(&docx).unwrap(); + let c = by_id(&d, 11); + let rp = c.run_props.expect("run properties read"); + assert_eq!((rp.style.as_deref(), rp.size), (Some("Eingabe"), Some(10.0))); + let out = document_xml(&write(&d).unwrap()); + assert!(out.contains(r#""#), "{out}"); +} diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 6f8e2dda8f..41250a21cf 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -142,12 +142,27 @@ pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { era * 146_097 + doe - 719_468 } +/// The built-in name of the placeholder style. Its id is localized: German Word writes +/// `Platzhaltertext`, English `PlaceholderText`. +const PLACEHOLDER_STYLE_NAME: &str = "Placeholder Text"; + +/// The id of the document's placeholder style: the one named "Placeholder Text", else the +/// English id. +fn placeholder_style_id(doc: &wordcraft_doc::Document) -> String { + doc.styles + .styles + .iter() + .find(|st| st.kind == StyleKind::Character && st.name.eq_ignore_ascii_case(PLACEHOLDER_STYLE_NAME)) + .map(|st| st.id.clone()) + .unwrap_or_else(|| PLACEHOLDER_STYLE.into()) +} + /// The placeholder style, added to the document when it hasn't one. fn ensure_placeholder_style(s: &mut Session) { - if s.doc.styles.get(PLACEHOLDER_STYLE).is_none() { + if s.doc.styles.get(&placeholder_style_id(&s.doc)).is_none() { s.doc.styles.styles.push(Style { id: PLACEHOLDER_STYLE.into(), - name: "Placeholder Text".into(), + name: PLACEHOLDER_STYLE_NAME.into(), kind: StyleKind::Character, based_on: Some("DefaultParagraphFont".into()), chr: CharProps { color: Some(TextColor::Rgb(Rgb(0x66, 0x66, 0x66))), ..Default::default() }, @@ -161,13 +176,13 @@ fn ensure_placeholder_style(s: &mut Session) { /// Formatting for placeholder text based on `base`. fn placeholder_props(s: &mut Session, base: &CharProps) -> CharProps { ensure_placeholder_style(s); - CharProps { style: Some(PLACEHOLDER_STYLE.into()), link: None, ..base.clone() } + CharProps { style: Some(placeholder_style_id(&s.doc)), link: None, ..base.clone() } } -/// `base` without the placeholder style (for real content). -fn content_props(base: &CharProps) -> CharProps { +/// `base` without the placeholder style (for real content), whatever its localized id. +fn content_props(doc: &wordcraft_doc::Document, base: &CharProps) -> CharProps { let mut p = base.clone(); - if p.style.as_deref() == Some(PLACEHOLDER_STYLE) { + if p.style.as_deref().is_some_and(|id| id == PLACEHOLDER_STYLE || id == placeholder_style_id(doc)) { p.style = None; } p @@ -248,7 +263,7 @@ fn content_base(s: &Session, r: &ControlRange) -> CharProps { .para_at(&a) .map(|p| if r.is_empty() { p.props_of_char(r.start.off).clone() } else { p.props_of_char(a.off).clone() }) .unwrap_or_default(); - content_props(&props) + content_props(&s.doc, &props) } // --------------------------------------------------------------------------------------------- @@ -299,7 +314,14 @@ pub fn prepare_edit(s: &mut Session) -> Result, CmdError> { if !r.control.showing_placeholder || !r.contains(&b) { return Ok(None); } - let props = content_base(s, &r); + // Text typed in place of the placeholder takes the control's own run properties (Word, and + // ECMA-376 §17.5.2.27), else the content's formatting without the placeholder style. The + // showing placeholder's own character style is that style, whatever a producer called it + // (Word localizes only the id; another program may localize the name too). + let props = match &r.control.run_props { + Some(rp) => content_props(&s.doc, rp), + None => CharProps { style: None, ..content_base(s, &r) }, + }; s.sel = Selection { anchor: r.content_start(), focus: r.end.clone() }; if let Some(c) = s.doc.control_mut(&r.start) { c.showing_placeholder = false; @@ -423,7 +445,7 @@ fn insert(s: &mut Session, v: &Value, kind: ControlKind) -> CmdResult { // An inline control: around the selection (text and pictures), else at the caret with its // placeholder or value. let wraps = a != b && a.path == b.path && matches!(ctl.kind, ControlKind::RichText | ControlKind::Text { .. } | ControlKind::Picture); - let base = s.doc.para_at(&a).map(|p| content_props(&p.props_of_char(a.off).clone())).unwrap_or_default(); + let base = s.doc.para_at(&a).map(|p| content_props(&s.doc, &p.props_of_char(a.off).clone())).unwrap_or_default(); if wraps { s.doc.insert_object(&b, InlineObject::ControlEnd, &base)?; let start = a.clone(); @@ -464,7 +486,7 @@ fn insert_block(s: &mut Session, mut ctl: ContentControl, a: &Pos, b: &Pos) -> C let last = if b.path.parent() == a.path.parent() { b.path.clone() } else { a.path.clone() }; ctl.block = true; let empty = a.path == last && s.doc.para(story, &a.path).is_some_and(|p| p.is_empty()); - let base = s.doc.para(story, &a.path).map(|p| content_props(&p.props_of_char(0).clone())).unwrap_or_default(); + let base = s.doc.para(story, &a.path).map(|p| content_props(&s.doc, &p.props_of_char(0).clone())).unwrap_or_default(); let item = matches!(ctl.kind, ControlKind::RepeatingSection { .. }).then(|| { let mut i = ContentControl::new(ControlKind::RepeatingSectionItem); i.block = true; @@ -748,6 +770,55 @@ mod editing_tests { run(s, "developer.control", json!({})) } + /// A form from German Word names its placeholder style `Platzhaltertext` ("Placeholder Text"), + /// and each control's `w:rPr` gives the style entered text takes (`Eingabe`, bold black). + /// Text typed over the placeholder takes the control's run properties, as in Word (17 of 17 + /// values filled in Word did); without them it drops the placeholder style whatever its id. + /// WordCraft kept the German placeholder style, so entries stayed grey. + #[test] + fn typing_over_a_localized_placeholder_takes_the_controls_formatting() { + use wordcraft_doc::props::{CharProps, Rgb, TextColor}; + use wordcraft_doc::styles::{Style, StyleKind}; + let style = + |id: &str, name: &str, chr: CharProps| Style { id: id.into(), name: name.into(), kind: StyleKind::Character, chr, ..Default::default() }; + let typed_style = |run_props: Option| { + let mut s = Session::new(wordcraft_doc::Document::new()); + let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() }; + s.doc.styles.styles.push(style("Platzhaltertext", "Placeholder Text", grey)); + s.doc.styles.styles.push(style("Eingabe", "Eingabe", CharProps { bold: Some(true), ..Default::default() })); + run(&mut s, "text.insert", json!({"text": "Name: "})); + run(&mut s, "developer.plainText", json!({"tag": "name"})); + let start = s.doc.control_at(&s.sel.focus).unwrap().start; + s.doc.control_mut(&start).unwrap().run_props = run_props; + // The placeholder uses the document's own placeholder style; none is added. + let r = s.doc.control_at(&s.sel.focus).unwrap(); + let shown = s.doc.para_at(&r.content_start()).unwrap().props_of_char(r.content_start().off).style.clone(); + assert_eq!(shown.as_deref(), Some("Platzhaltertext")); + assert_eq!(s.doc.styles.styles.iter().filter(|st| st.name == "Placeholder Text").count(), 1); + run(&mut s, "caret.right", json!({})); + run(&mut s, "text.insert", json!({"text": "Ada"})); + let r = s.doc.control_at(&s.sel.focus).unwrap(); + s.doc.para_at(&r.content_start()).unwrap().props_of_char(r.content_start().off).style.clone() + }; + let eingabe = CharProps { style: Some("Eingabe".into()), ..Default::default() }; + assert_eq!(typed_style(Some(eingabe)).as_deref(), Some("Eingabe"), "the control's run properties"); + assert_eq!(typed_style(None), None, "no placeholder style left"); + // A producer that localizes the style's name as well: the showing placeholder's own + // style goes all the same. + let mut s = Session::new(wordcraft_doc::Document::new()); + let grey = CharProps { color: Some(TextColor::Rgb(Rgb(0x80, 0x80, 0x80))), ..Default::default() }; + s.doc.styles.styles.push(style("TexteEspaceReserve", "Texte de l'espace réservé", grey)); + run(&mut s, "text.insert", json!({"text": "Nom : "})); + run(&mut s, "developer.plainText", json!({"tag": "nom"})); + let r = s.doc.control_at(&s.sel.focus).unwrap(); + s.doc.format_range(&r.content_start(), &r.end, &|c| c.style = Some("TexteEspaceReserve".into())).unwrap(); + run(&mut s, "caret.right", json!({})); + run(&mut s, "text.insert", json!({"text": "Ada"})); + let r = s.doc.control_at(&s.sel.focus).unwrap(); + let typed = s.doc.para_at(&r.content_start()).unwrap().props_of_char(r.content_start().off).style.clone(); + assert_eq!(typed, None, "a placeholder style localized in id and name"); + } + #[test] fn typing_replaces_the_placeholder_and_emptying_brings_it_back() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index 3666489a6a..b77a6f9d6d 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -189,6 +189,19 @@ fn remove_exceptions(s: &mut Session, all: bool) -> Result<(), CmdError> { Ok(()) } +/// The Developer tab's commands that insert a content control. +const CONTROL_INSERTS: [&str; 9] = [ + "developer.richText", + "developer.plainText", + "developer.picture", + "developer.buildingBlock", + "developer.checkBox", + "developer.comboBox", + "developer.dropDown", + "developer.datePicker", + "developer.repeatingSection", +]; + /// Commands always allowed in a protected document: protection itself, file commands, undo. fn always_allowed(id: &str) -> bool { id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo" @@ -205,10 +218,25 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result = xs.iter().map(|p| p.0).collect(); + assert_eq!(offs, [0, 1, 2, 3]); + assert!(xs.windows(2).all(|w| w[0].1 < w[1].1), "caret x after each Right press: {xs:?}"); +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 91d3286234..4f2e4384f7 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -803,7 +803,7 @@ impl<'r> Builder<'r> { } } if let (Some((o, _)), Some(li)) = (list_level, li_at) { - p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8 }); + p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); self.para_cont = self.stack.get(li).is_some_and(|e| e.used); if let Some(e) = self.stack.get_mut(li) { e.used = true; @@ -1474,9 +1474,9 @@ fn align_css(a: Option) -> Option<&'static str> { } } -fn text_html(t: &str) -> String { +fn text_html(t: &str, at_start: bool) -> String { let mut o = String::new(); - let mut prev_space = false; + let mut prev_space = at_start; for c in t.chars() { match c { '\n' => { @@ -1547,8 +1547,9 @@ pub fn inlines_html(inlines: &[Inline]) -> String { open.push_str(&format!("", css.join(";"))); close.push(""); } + let at_start = o.is_empty(); o.push_str(&open); - o.push_str(&text_html(t)); + o.push_str(&text_html(t, at_start)); for c in close.iter().rev() { o.push_str(c); } @@ -2036,4 +2037,13 @@ mod tests { }; assert_eq!(p.text(), "World"); } + + #[test] + fn leading_space_survives_round_trip() { + let doc = Document::from_text(" lead"); + let html = export(&doc); + assert!(!html.contains("

lead"), "{html}"); + let back = import(&html); + assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead"); + } } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 31346eb200..cc21572686 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -2377,7 +2377,7 @@ impl<'a> Parser<'a> { let level = self.lists.len().saturating_sub(1).min(8) as u8; self.para = Para::default(); if let Some(&ordered) = self.lists.last() { - self.para.list = Some(ListInfo { ordered, level }); + self.para.list = Some(ListInfo { ordered, level, start: 1 }); } let label = self.opt_arg(); if let Some(l) = label { @@ -2896,8 +2896,8 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. assert_eq!(find("On Sample Documents").kind, Kind::Title); assert_eq!(find("Introduction").kind, Kind::Heading(1)); assert_eq!(find("Lists").kind, Kind::Heading(2)); - assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0 })); - assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1 })); + assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0, start: 1 })); + assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1, start: 1 })); assert!(ps.iter().any(|p| p.text().trim() == "b) Nested two" && p.list.is_some()), "{:?}", texts(ARTICLE)); assert_eq!(find("Quoted text.").kind, Kind::Quote); assert_eq!(find("Centered.").align, Some(Align::Center)); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 6e21e8176d..93ef23efec 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -460,7 +460,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) { item.pop(); } - let li = ListInfo { ordered: first.ordered, level: level.min(8) }; + let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; let inner = if depth >= MAX_NEST { let mut p = Para::default(); p.push_text(&item.join(" "), &Fmt::default()); @@ -1355,7 +1355,10 @@ fn write_blocks(blocks: &[FBlock], out: &mut Vec) { prev_top_ordered = li.ordered; } let n = counters.get_mut(lv).map(|c| { - *c += 1; + if *c == 0 { + *c = li.start.saturating_sub(1); + } + *c = c.saturating_add(1); *c }); for d in counters.iter_mut().skip(lv + 1) { @@ -1703,6 +1706,14 @@ mod tests { assert_eq!(back_table2.rows[1][1].text(), "CRITICAL_DISTINCTIVE_INFO"); } + #[test] + fn ordered_list_keeps_start_number() { + let txt = |md: &str| String::from_utf8_lossy(&crate::txt::export(&import(md))).into_owned(); + assert_eq!(txt("5. a\n6. b"), "5.\ta\r\n6.\tb"); + assert_eq!(txt("3. a\n- b\n4. c"), "3.\ta\r\n•\tb\r\n4.\tc"); + assert_eq!(export(&import("5. a\n6. b")).trim_end(), "5. a\n6. b"); + } + #[test] fn lines_after_line_break_stay_in_paragraph() { use wordcraft_doc::{CharProps, Document, Paragraph, StoryRef, para_block}; @@ -1720,10 +1731,10 @@ mod tests { fn lines_after_line_break_stay_in_list_item_and_quote() { let cases = ["a\n- x", "a\n# h", "a\n1. x", "a\n--", "a\n---", "a\n==", "a\n> q"]; let shapes: [(Kind, Option); 5] = [ - (Kind::Normal, Some(ListInfo { ordered: false, level: 0 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 0 })), - (Kind::Normal, Some(ListInfo { ordered: false, level: 1 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 0, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 1, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 1, start: 1 })), (Kind::Quote, None), ]; for s in cases { @@ -1731,7 +1742,7 @@ mod tests { let mut blocks = Vec::new(); if let Some(li) = list.filter(|l| l.level > 0) { let mut parent = Para::new(Kind::Normal); - parent.list = Some(ListInfo { ordered: li.ordered, level: 0 }); + parent.list = Some(ListInfo { ordered: li.ordered, level: 0, start: 1 }); parent.push_text("top", &Fmt::default()); blocks.push(FBlock::Para(parent)); } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index c2502f2e4b..449ba19906 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -112,6 +112,8 @@ pub struct ListInfo { pub ordered: bool, /// 0-based nesting level (0..=8). pub level: u8, + /// Number of the first item of an ordered list (1 unless the source says otherwise). + pub start: u32, } /// Paragraph spacing and indents (points) that a source states on the paragraph itself (RTF); @@ -587,13 +589,25 @@ impl<'a> Builder<'a> { *k = Some(li.ordered); } } + let mut starts: [Option; 9] = [None; 9]; + for li in group { + if let Some(s) = starts.get_mut(li.level.min(8) as usize) + && s.is_none() + { + *s = Some(li.start); + } + } // Levels nobody used follow the first level's kind. let first = group.first().map(|l| l.ordered).unwrap_or(false); let levels: Vec = (0..9) .map(|i| { let ordered = kinds.get(i).copied().flatten().unwrap_or(first); let src = if ordered { &num_levels } else { &bul_levels }; - src.get(i).cloned().unwrap_or_default() + let mut level = src.get(i).cloned().unwrap_or_default(); + if ordered && let Some(s) = starts.get(i).copied().flatten() { + level.start = s; + } + level }) .collect(); let numbering = &mut self.doc.numbering; @@ -851,7 +865,7 @@ fn props_kind(doc: &Document, props: &ParaProps, blank: bool) -> (Kind, Option { + if *c == 0 { + *c = li.start.saturating_sub(1); + } *c = c.saturating_add(1); *c } @@ -1361,12 +1378,12 @@ mod tests { #[test] fn list_labels() { let mut c = ListCounter::default(); - let o = |level| ListInfo { ordered: true, level }; + let o = |level| ListInfo { ordered: true, level, start: 1 }; assert_eq!(c.label(o(0)), "1."); assert_eq!(c.label(o(1)), "a."); assert_eq!(c.label(o(1)), "b."); assert_eq!(c.label(o(0)), "2."); - assert_eq!(c.label(ListInfo { ordered: false, level: 0 }), "•"); + assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1 }), "•"); } #[test] diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 86ee6aceec..2df9d4519a 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -348,7 +348,8 @@ impl Writer { fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) { let mut prev_space = true; let inl = model::equations_as_text(inl); - for i in inl.iter() { + let last = inl.len().saturating_sub(1); + for (idx, i) in inl.iter().enumerate() { match i { Inline::Text(t, f) => { let mut body = String::new(); @@ -389,7 +390,8 @@ impl Writer { } } if spaces > 0 { - flush(&mut body, &mut spaces, prev_space); + // The reader drops a literal space at the end of a paragraph. + flush(&mut body, &mut spaces, prev_space || idx == last); prev_space = true; } // A script, `file:`, UNC or other non-web target is written as plain text, as @@ -1609,7 +1611,7 @@ impl Body<'_> { { let style = self.lists.iter().rev().find_map(|l| l.0.clone()); let ordered = style.and_then(|s| self.styles.lists.get(&s)).and_then(|v| v.get(lv.min(9)).copied()).unwrap_or(false); - p.list = Some(ListInfo { ordered, level: lv.min(8) as u8 }); + p.list = Some(ListInfo { ordered, level: lv.min(8) as u8, start: 1 }); self.para_cont = self.item_used.last().copied().unwrap_or(false); if let Some(u) = self.item_used.last_mut() { *u = true; @@ -3359,5 +3361,10 @@ mod tests { let d = Document::from_text(" indented code "); let back = import(&export(&d).unwrap()).unwrap(); assert_eq!(back.plain_text(wordcraft_doc::StoryRef::Body).trim_end_matches('\n'), " indented code "); + for t in ["trail ", "trail "] { + let d = Document::from_text(t); + let back = import(&export(&d).unwrap()).unwrap(); + assert_eq!(back.plain_text(wordcraft_doc::StoryRef::Body).trim_end_matches('\n'), t); + } } } diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 3a84c68126..95625360ef 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1114,8 +1114,11 @@ struct Reader { colors: Vec>, color_cur: (u8, u8, u8, bool), styles: HashMap, + /// Each style's own character formatting, as its `\stylesheet` entry writes it. + style_defs: HashMap, style_cur: i32, style_name: String, + style_def_cur: StyleDef, meta: Meta, info_text: String, // Lists: list id → ordered per level; override ls → list id. @@ -1384,9 +1387,55 @@ impl Reader { fn commit_style(&mut self) { let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string(); + let def = std::mem::take(&mut self.style_def_cur); if self.styles.len() < 4096 { self.styles.insert(self.style_cur, name); + self.style_defs.insert(self.style_cur, def); } + // The next entry is style 0 (Normal) unless it names a number. + self.style_cur = 0; + } + + /// The character formatting style `id` gives its runs in this file: its stylesheet entry's, + /// then its `\sbasedon` ancestors' (a bounded walk: loops and missing ids end it). + fn file_style(&self, id: i32) -> StyleDef { + let mut out = StyleDef::default(); + let mut seen: Vec = Vec::new(); + let mut cur = Some(id); + while let Some(i) = cur { + if seen.len() >= 16 || seen.contains(&i) { + break; + } + seen.push(i); + let Some(d) = self.style_defs.get(&i) else { break }; + out.bold = out.bold.or(d.bold); + out.italic = out.italic.or(d.italic); + out.size = out.size.or(d.size); + out.font = out.font.or(d.font); + cur = d.based_on; + } + out + } + + /// The formatting a paragraph of kind `kind` in style `id` gives its runs. A style whose + /// entry and `\sbasedon` chain set no character formatting (a bare `{\s2 heading 2;}`) has + /// WordCraft's own (`style_fmt`); otherwise what the chain doesn't set is RTF's default (not + /// bold, not italic, 12 pt). The font alone falls back per property: WordCraft writes the code + /// font on each code paragraph, not in its Code entry. + fn style_base(&self, kind: Kind, id: i32) -> Fmt { + let mut f = style_fmt(kind); + let d = self.file_style(id); + if d.bold.is_some() || d.italic.is_some() || d.size.is_some() || d.font.is_some() { + f.bold = d.bold.unwrap_or(false); + f.italic = d.italic.unwrap_or(false); + f.size = Some(d.size.unwrap_or(12.0)); + } + // A font is matched the way `\f` sets a run's: a monospaced one is the code font. + if let Some(name) = d.font.and_then(|i| self.fonts.get(&i)) { + f.code = model::is_mono(name); + f.font = (!f.code).then(|| name.clone()); + } + f } fn finish_pict(&mut self) { @@ -1526,19 +1575,27 @@ impl Reader { self.text(c.encode_utf8(&mut buf)); } - fn para_kind(&self, st: &St) -> Kind { + /// The paragraph kind `st`'s style names, if it names one. + fn style_kind(&self, st: &St) -> Option { let name = self.styles.get(&st.style).map(|s| s.to_ascii_lowercase()).unwrap_or_default(); if let Some(n) = name.strip_prefix("heading").and_then(|r| r.trim().parse::().ok()) { - return Kind::Heading(n.clamp(1, 6)); + return Some(Kind::Heading(n.clamp(1, 6))); } if st.style != 0 { match name.as_str() { - "title" => return Kind::Title, - "quote" | "intense quote" => return Kind::Quote, - "code" | "html preformatted" | "plain text" => return Kind::Code, + "title" => return Some(Kind::Title), + "quote" | "intense quote" => return Some(Kind::Quote), + "code" | "html preformatted" | "plain text" => return Some(Kind::Code), _ => {} } } + None + } + + fn para_kind(&self, st: &St) -> Kind { + if let Some(k) = self.style_kind(st) { + return k; + } if let Some(o) = st.outline.filter(|o| *o < 9) { return Kind::Heading((o + 1).min(6)); } @@ -1556,7 +1613,7 @@ impl Reader { let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id)); let lv = st.ilvl.min(8); let ordered = list.and_then(|l| l.get(lv as usize).copied()).unwrap_or(false); - p.list = Some(ListInfo { ordered, level: lv }); + p.list = Some(ListInfo { ordered, level: lv, start: 1 }); } p } @@ -1584,12 +1641,14 @@ impl Reader { p.fmt_change = Some(Box::new(model::FmtChange { mark: self.rev_mark(c.auth, c.date), old: (**old).clone() })); } // Headings, code, titles and quotes keep their style formatting implicit, in the text's - // formatting and in the formatting before a tracked change alike. + // formatting and in the formatting before a tracked change alike. The style is the + // file's when its name gave the kind (not an outline level on another style). + let base = if self.style_kind(&self.st) == Some(p.kind) { self.style_base(p.kind, self.st.style) } else { style_fmt(p.kind) }; for i in &mut p.inlines { if let Inline::Text(_, f) = i { - drop_style_formatting(p.kind, f); + drop_style_formatting(p.kind, &base, f); if let Some(c) = f.fmt_change.as_deref_mut() { - drop_style_formatting(p.kind, &mut c.old); + drop_style_formatting(p.kind, &base, &mut c.old); } } } @@ -1822,11 +1881,18 @@ impl Reader { return; } Dest::StyleSheet => { + let d = &mut self.style_def_cur; match w { "s" | "cs" | "ds" | "ts" => { self.style_cur = if w == "s" { pv } else { -1 - pv.saturating_abs() }; self.style_name.clear(); + *d = StyleDef::default(); } + "sbasedon" => d.based_on = Some(pv), + "b" => d.bold = Some(on), + "i" => d.italic = Some(on), + "fs" => d.size = (pv > 0).then(|| (pv as f32 / 2.0).clamp(1.0, 1638.0)), + "f" => d.font = Some(pv), _ => {} } return; @@ -2153,21 +2219,48 @@ fn styled(base: &Fmt, f: &Fmt) -> Fmt { e } -/// Clear from `f` the formatting a paragraph of kind `kind` gets from its style (as main's -/// import always has: any direct size, bold or code font there is taken as the style's). -fn drop_style_formatting(kind: Kind, f: &mut Fmt) { - if matches!(kind, Kind::Heading(_) | Kind::Code | Kind::Title) { - f.size = None; - if kind != Kind::Title { +/// A `\stylesheet` entry's character formatting; `None` where the entry doesn't set it. +#[derive(Clone, Copy, Debug, Default)] +struct StyleDef { + based_on: Option, + bold: Option, + italic: Option, + size: Option, + /// A `\fonttbl` index, named when it's used (the table may follow). + font: Option, +} + +/// Clear from `f` the formatting a paragraph of kind `kind` gets from its style, whose +/// formatting is `base`: a heading's or title's size and bold when they equal the style's, a +/// quote's italic, a code run's font when it's the style's (and, as before, its size and bold). +fn drop_style_formatting(kind: Kind, base: &Fmt, f: &mut Fmt) { + match kind { + Kind::Heading(_) | Kind::Title => { + if f.size.is_some() && f.size == base.size { + f.size = None; + } + if base.bold { + f.bold = false; + } + } + Kind::Code => { + f.size = None; f.bold = false; + let same_font = match (&f.font, &base.font) { + (Some(a), Some(b)) => a.eq_ignore_ascii_case(b), + (a, b) => a.is_none() && b.is_none(), + }; + if f.code == base.code && same_font { + f.code = false; + f.font = None; + } } - if kind == Kind::Code { - f.code = false; - f.font = None; + Kind::Quote => { + if base.italic { + f.italic = false; + } } - } - if kind == Kind::Quote { - f.italic = false; + Kind::Normal | Kind::Rule => {} } } @@ -3264,7 +3357,7 @@ mod tests { fn list_item_with_a_formatting_change_keeps_its_list_on_reject() { use model::{FmtChange, ListInfo}; // A numbered item Alice centred (it was left-aligned), and a second plain item. - let li = Some(ListInfo { ordered: true, level: 0 }); + let li = Some(ListInfo { ordered: true, level: 0, start: 1 }); let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; let old = Para { list: li, align: Some(Align::Left), ..Default::default() }; let mut item = Para { list: li, align: Some(Align::Center), fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; @@ -3294,7 +3387,7 @@ mod tests { use model::{FmtChange, ListInfo}; // Alice took the paragraph out of a numbered list: it is no list item now, but was one. let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; - let old = Para { list: Some(ListInfo { ordered: true, level: 0 }), ..Default::default() }; + let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1 }), ..Default::default() }; let mut item = Para { fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; item.push_text("Item", &Fmt::default()); let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() }; @@ -3310,9 +3403,12 @@ mod tests { fn numbered_item_made_bulleted_gets_its_numbered_list_back_on_reject() { use model::{FmtChange, ListInfo}; let mark = RevMark { author: "Alice".into(), date: "2024-03-15T10:30:00Z".into(), id: None }; - let old = Para { list: Some(ListInfo { ordered: true, level: 0 }), ..Default::default() }; - let mut item = - Para { list: Some(ListInfo { ordered: false, level: 0 }), fmt_change: Some(Box::new(FmtChange { mark, old })), ..Default::default() }; + let old = Para { list: Some(ListInfo { ordered: true, level: 0, start: 1 }), ..Default::default() }; + let mut item = Para { + list: Some(ListInfo { ordered: false, level: 0, start: 1 }), + fmt_change: Some(Box::new(FmtChange { mark, old })), + ..Default::default() + }; item.push_text("Item", &Fmt::default()); let flow = Flow { blocks: vec![FBlock::Para(item)], meta: Meta::default() }; let doc = model::to_doc(&flow); @@ -3491,26 +3587,210 @@ mod tests { assert!(out.contains(&format!("{{\\*\\oldcprops \\f{mono}\\crauth1}}")), "{out}"); } + /// The text runs of `rtf`'s paragraphs, in order, with their formatting. + fn text_runs(rtf: &[u8]) -> Vec<(String, Fmt)> { + let f = parse(rtf).unwrap(); + f.blocks + .iter() + .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) + .flat_map(|p| p.inlines.iter()) + .filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.clone())) } else { None }) + .collect() + } + + /// The formatting of the run whose text is `t`. + fn run_fmt<'a>(runs: &'a [(String, Fmt)], t: &str) -> &'a Fmt { + runs.iter().find(|r| r.0 == t).map(|r| &r.1).unwrap_or_else(|| panic!("no run {t:?} in {runs:?}")) + } + #[test] fn heading_and_code_runs_take_their_style_formatting_on_import() { - // As on main: a heading's or code paragraph's runs drop their direct size, bold and - // (code) font; a size set by hand there isn't kept through RTF. - let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}}{\stylesheet{\s0 Normal;}{\s2 heading 2;}{\s9 Code;}} -\pard\s2\b\fs26 Head\par -\pard\s9\f1\b\fs21 code\par}"; - let f = parse(rtf).unwrap(); - let runs: Vec<&Fmt> = f - .blocks - .iter() - .filter_map(|b| match b { - FBlock::Para(p) => p.inlines.first().and_then(|i| if let Inline::Text(_, f) = i { Some(f) } else { None }), - _ => None, - }) - .collect(); - let head = runs.first().expect("heading run"); + // A stylesheet entry without formatting words (WordCraft's older files, minimal + // stylesheets): WordCraft's own heading and code formatting stand in for the style's. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Arial;}{\f2 Courier New;}}{\stylesheet{\s0 Normal;}{\s2 heading 2;}{\s9 Code;}} +\pard\s2\b\fs32 Head\par +\pard\s2\b\fs26 Small\par +\pard\s9\f2\b\fs21 code\par +\pard\s9\f1\fs21 arial\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); assert_eq!((head.size, head.bold), (None, false)); - let code = runs.get(1).expect("code run"); + // 13 pt isn't heading 2's 16 pt: a size set by hand. + let small = run_fmt(&runs, "Small"); + assert_eq!((small.size, small.bold), (Some(13.0), false)); + let code = run_fmt(&runs, "code"); assert_eq!((code.size, code.bold, code.font.as_deref(), code.code), (None, false, None, false)); + // Arial isn't the code font: set by hand. + let arial = run_fmt(&runs, "arial"); + assert_eq!((arial.size, arial.font.as_deref(), arial.code), (None, Some("Arial"), false)); + } + + #[test] + fn heading_runs_are_compared_with_the_files_heading_style() { + // Word writes resolved values: a 13 pt bold Heading 2 has `\b\fs26` in its stylesheet + // entry and on its runs. Only what differs from the entry is direct formatting. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\ql \f0\fs24 Normal;} +{\s2\ql \keepn\outlinelevel1 \b\fs26 \sbasedon0 \snext0 heading 2;}{\s3\ql \keepn\b0\fs24 \sbasedon0 \snext0 heading 3;}} +\pard\s2\b\fs26 Head\par +\pard\s2\b\fs40 Big\par +\pard\s3\b\fs24 Bolded\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); + assert_eq!((head.size, head.bold), (None, false)); + let big = run_fmt(&runs, "Big"); + assert_eq!((big.size, big.bold), (Some(20.0), false)); + // Heading 3 isn't bold in this file: the bold is the user's. + let bolded = run_fmt(&runs, "Bolded"); + assert_eq!((bolded.size, bolded.bold), (None, true)); + } + + #[test] + fn style_formatting_is_resolved_through_sbasedon() { + // LibreOffice's stylesheet: Heading 2 is based on Heading, which sets the boldness and + // font; Code is based on a style that sets the font. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Liberation Serif;}{\f1 Liberation Sans;}{\f2 Iosevka;}{\f3 Courier New;}} +{\stylesheet{\s0\f0\fs24 Normal;}{\s1\sbasedon0\snext2\f1\b0\fs28 Heading;}{\s2\sbasedon1\snext3\ql\fs32 Heading 2;} +{\s10\sbasedon0\f2\fs20 Source Base;}{\s9\sbasedon10 Code;}} +\pard\s2\f1\fs32 Plain\par +\pard\s2\f1\b\fs32 Bolded\par +\pard\s9\f2\fs20 base\par +\pard\s9\f3\fs20 courier\par}"; + let runs = text_runs(rtf); + let plain = run_fmt(&runs, "Plain"); + assert_eq!((plain.size, plain.bold), (None, false)); + // Heading isn't bold, so neither is Heading 2. + let bolded = run_fmt(&runs, "Bolded"); + assert_eq!((bolded.size, bolded.bold), (None, true)); + let base = run_fmt(&runs, "base"); + assert_eq!((base.size, base.font.as_deref(), base.code), (None, None, false)); + // Code's font is Iosevka here, so Courier New was set by hand. + let courier = run_fmt(&runs, "courier"); + assert_eq!((courier.size, courier.font.as_deref(), courier.code), (None, None, true)); + } + + #[test] + fn code_runs_in_the_files_code_font_have_no_direct_font() { + let rtf = + br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}{\f1 Courier New;}{\f2 Arial;}}{\stylesheet{\s0 Normal;}{\s9\sbasedon0\sa0\f1\fs21 Code;}} +\pard\s9\f1\fs21 mono\par +\pard\s9\f2\fs21 arial\par}"; + let runs = text_runs(rtf); + let mono = run_fmt(&runs, "mono"); + assert_eq!((mono.size, mono.font.as_deref(), mono.code), (None, None, false)); + let arial = run_fmt(&runs, "arial"); + assert_eq!((arial.size, arial.font.as_deref(), arial.code), (None, Some("Arial"), false)); + } + + #[test] + fn hostile_sbasedon_chains_are_harmless() { + let mut chain = String::new(); + // s100 (heading 5) based on s101 … s139, the last one not bold: past the bound, so + // heading 5 takes WordCraft's bold. + for i in 100..140 { + let name = if i == 100 { "heading 5".to_string() } else { format!("x{i}") }; + let b0 = if i == 139 { "\\b0" } else { "" }; + chain.push_str(&format!("{{\\s{i}\\sbasedon{}{b0} {name};}}", i + 1)); + } + let rtf = format!( + r"{{\rtf1{{\stylesheet{{\s0 Normal;}}{{\s1\sbasedon1 heading 1;}}{{\s3\sbasedon4\b0 heading 3;}}{{\s4\sbasedon3 heading 4;}} +{{\s6\sbasedon-5 heading 6;}}{{\s2\sbasedon2147483647\fs-40 heading 2;}}{{\s-2147483648\sbasedon-2147483648 Title;}}{chain}}} +\pard\s1\b\fs40 one\par +\pard\s3\b\fs24 three\par +\pard\s4\b\fs24 four\par +\pard\s6\b\fs22 six\par +\pard\s2\b\fs32 two\par +\pard\s100\b\fs24 five\par +\pard\s-2147483648\fs56 title\par}}" + ); + let runs = text_runs(rtf.as_bytes()); + // Self-based heading 1: WordCraft's values. + assert_eq!((run_fmt(&runs, "one").size, run_fmt(&runs, "one").bold), (None, false)); + // A loop between heading 3 and 4: both take heading 3's `\b0`. + assert!(run_fmt(&runs, "three").bold); + assert!(run_fmt(&runs, "four").bold); + assert_eq!((run_fmt(&runs, "six").size, run_fmt(&runs, "six").bold), (None, false)); + assert_eq!((run_fmt(&runs, "two").size, run_fmt(&runs, "two").bold), (None, false)); + assert!(!run_fmt(&runs, "five").bold); + let _ = run_fmt(&runs, "title"); + } + + #[test] + fn a_word_365_heading_is_not_bold_so_a_bold_run_there_is_the_users() { + // Word 365's Heading 1 isn't bold: its entry sets the font and size but no `\b`, so a + // bold run, or a tracked change to or from bold, is the user's. + let rtf = br"{\rtf1\ansi{\fonttbl{\f1 Aptos;}{\f2 Aptos Display;}}{\colortbl;\red15\green71\blue97;} +{\stylesheet{\ql \f1\fs24 Normal;}{\s1\ql \keepn\outlinelevel0 \f2\fs40\cf1 \sbasedon0 \snext0 heading 1;}} +{\*\revtbl {Unknown;}{Alice;}} +\pard\s1\f2\fs40 Plain\par +\pard\s1\f2\b\fs40 Bold\par +\pard\s1\f2\fs40 {\b{\*\oldcprops \b0\crauth1}Bolded}{\b0{\*\oldcprops \b\crauth1}Unbolded}\par}"; + let runs = text_runs(rtf); + let plain = run_fmt(&runs, "Plain"); + assert_eq!((plain.size, plain.bold), (None, false)); + let bold = run_fmt(&runs, "Bold"); + assert_eq!((bold.size, bold.bold), (None, true)); + let bolded = run_fmt(&runs, "Bolded"); + let old = &bolded.fmt_change.as_deref().expect("change kept").old; + assert_eq!((bolded.bold, old.bold, old.size), (true, false, None)); + let unbolded = run_fmt(&runs, "Unbolded"); + let old = &unbolded.fmt_change.as_deref().expect("change kept").old; + assert_eq!((unbolded.bold, old.bold), (false, true)); + } + + #[test] + fn a_style_chain_without_a_size_has_rtfs_default_size() { + // The entries carry formatting words but no `\fs`: the style's size is RTF's 12 pt. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\s0\f0 Normal;}{\s2\sbasedon0\b heading 2;}} +\pard\s2\b\fs24 Twelve\par +\pard\s2\b\fs40 Twenty\par}"; + let runs = text_runs(rtf); + let twelve = run_fmt(&runs, "Twelve"); + assert_eq!((twelve.size, twelve.bold), (None, false)); + let twenty = run_fmt(&runs, "Twenty"); + assert_eq!((twenty.size, twenty.bold), (Some(20.0), false)); + } + + #[test] + fn a_code_paragraph_round_trips_without_a_direct_code_font() { + // WordCraft's Code entry has no `\f` (the code font is on the paragraph): the code font + // stands in for the style's. + let mut code = Para { kind: Kind::Code, ..Default::default() }; + code.push_text("let x", &Fmt::default()); + let flow = Flow { blocks: vec![FBlock::Para(code)], meta: Meta::default() }; + let rtf = export(&model::to_doc(&flow)); + let runs = text_runs(rtf.as_bytes()); + let f = run_fmt(&runs, "let x"); + assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}"); + } + + #[test] + fn a_stylesheet_entry_without_a_number_is_normal() { + // An entry without `\sN` is style 0; it doesn't replace the entry before it. + let rtf = br"{\rtf1\ansi{\stylesheet{\s2\b\fs26 heading 2;}{\b0\fs30 Normal;}} +\pard\s2\b\fs26 Head\par +\pard\s2\b\fs30 Other\par}"; + let runs = text_runs(rtf); + let head = run_fmt(&runs, "Head"); + assert_eq!((head.size, head.bold), (None, false)); + let other = run_fmt(&runs, "Other"); + assert_eq!((other.size, other.bold), (Some(15.0), false)); + } + + #[test] + fn a_heading_size_set_by_hand_survives_in_the_old_formatting() { + // Reject puts back a size set by hand: it isn't the file's heading size. + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Times New Roman;}}{\stylesheet{\ql \fs24 Normal;}{\s2\ql \b\fs26 \sbasedon0 \snext0 heading 2;}} +{\*\revtbl {Unknown;}{Alice;}} +\pard\s2 {\b\fs26{\*\oldcprops \fs40\crauth1}Shrunk}{\b\fs40{\*\oldcprops \fs26\crauth1}Grown}\par}"; + let runs = text_runs(rtf); + let shrunk = run_fmt(&runs, "Shrunk"); + assert_eq!(shrunk.size, None); + let old = &shrunk.fmt_change.as_deref().expect("change kept").old; + assert_eq!((old.size, old.bold), (Some(20.0), false)); + let grown = run_fmt(&runs, "Grown"); + assert_eq!(grown.size, Some(20.0)); + let old = &grown.fmt_change.as_deref().expect("change kept").old; + assert_eq!((old.size, old.bold), (None, false)); } #[test] diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 4562b8c72a..caad39dced 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -205,7 +205,7 @@ fn markdown_list_items_that_start_like_blocks_keep_their_text() { let bytes = export("md", &d).unwrap().unwrap(); let blocks = flat(&import("md", &bytes).unwrap().unwrap()); let [FBlock::Para(p)] = blocks.as_slice() else { panic!("{text}: {}", String::from_utf8_lossy(&bytes)) }; - assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0 }))); + assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0, start: 1 }))); } } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1bc00495ad..a60c5f94d4 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4595,6 +4595,8 @@ mod tests_complex; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_ligature; +#[cfg(test)] mod tests_numeral; #[cfg(test)] mod tests_pages; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index b8eb82cd16..6fdb28ac28 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -156,6 +156,9 @@ pub struct Line { pub top: f32, pub height: f32, pub baseline: f32, + /// The text's own ascent and descent on this line, without line-spacing leading (the caret's extent). + pub text_asc: f32, + pub text_desc: f32, /// Cluster index range. pub c0: usize, pub c1: usize, @@ -1062,6 +1065,7 @@ impl<'a> Builder<'a> { // caret, selection and line breaking never stop inside the character. bounds.retain(|b| map_starts.binary_search(b).is_ok()); } + let seg_c0 = self.clusters.len(); let mut gi = 0usize; for (bi, &gs) in bounds.iter().enumerate() { let ge = bounds.get(bi + 1).copied().unwrap_or(shape_text.len()); @@ -1102,6 +1106,47 @@ impl<'a> Builder<'a> { dot: s == "." || s == ",", }); } + if matches!(dir, Dir::Para) { + self.split_ligature_advances(seg_c0, rc.spacing, key.rtl); + } + } + } + + /// A ligature glyph (fi, ffi, lam-alef) sits on the first cluster of the letters it draws and + /// the clusters after it have no glyph and no width, so the caret, clicks and selection edges + /// inside it all land after the whole glyph. Give each letter an even share of the glyph's + /// advance, as Word and browsers do (#1974). The glyph stays on the first cluster; in + /// right-to-left text that cluster is the rightmost, so the glyph moves back by the other + /// letters' shares to start at the ligature's left edge. Clusters are grapheme clusters, so a + /// letter with a combining mark is never split. + fn split_ligature_advances(&mut self, from: usize, spacing: f32, rtl: bool) { + let mut i = from; + while let Some(head) = self.clusters.get(i) { + if head.kind != ClKind::Text || head.g1 <= head.g0 { + i += 1; + continue; + } + let n = + self.clusters.get(i + 1..).unwrap_or(&[]).iter().take_while(|c| c.kind == ClKind::Text && c.g1 <= c.g0 && c.adv == spacing).count(); + if n == 0 { + i += 1; + continue; + } + // The letters after the head are glyphless and carry only their character spacing, so + // the ligature's width is the glyph's advance (spacing included) plus those spacings. + let unit = (head.adv + spacing * n as f32) / (n + 1) as f32; + let (g0, g1) = (head.g0 as usize, head.g1 as usize); + if unit.is_finite() { + for c in self.clusters.get_mut(i..=i + n).unwrap_or(&mut []) { + c.adv = unit; + } + if rtl { + for g in self.glyphs.get_mut(g0..g1).unwrap_or(&mut []) { + g.dx -= unit * n as f32; + } + } + } + i += n + 1; } } @@ -2155,6 +2200,22 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio .sum(); let full = base_right - row_lo; skip = narrowed && skipped < 50 && word <= full + 0.01; + // Never split a ligature's letters across lines: the glyph sits on the first + // letter, so back up to it (unless it starts the line). + let glyphless = |k: usize| pl.clusters.get(k).is_some_and(|c| c.kind == ClKind::Text && c.g1 <= c.g0); + let mut b = j; + while b > c0 + 1 && glyphless(b) { + b -= 1; + } + if b < j + && b > c0 + && !skip + && let Some(bx) = xs.get(b - c0).copied() + { + xs.truncate(b - c0); + x = bx; + j = b; + } } break; } @@ -2218,7 +2279,10 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio if !hyph { last_break = Some(j); last_plain = Some((j, x)); - } else if hyph_ok && x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + shrink + 0.01 { + } else if hyph_ok + && !pl.clusters.get(j + 1).is_some_and(|n| n.kind == ClKind::Text && n.g1 <= n.g0) + && x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + shrink + 0.01 + { last_break = Some(j); } } @@ -2312,6 +2376,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio desc = st.descent; } let text_natural = asc + desc; + let (text_asc, text_desc) = (asc.max(obj_asc), desc); let text_pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 }; let mut label_extra = 0.0; if first && let Some(lab) = &pl.label { @@ -2436,6 +2501,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio top, height, baseline, + text_asc, + text_desc, c0, c1, xs, @@ -2482,6 +2549,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio top, height: h, baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) }, + text_asc: asc, + text_desc: desc, c0: n, c1: n, xs: vec![x0], @@ -2956,10 +3025,10 @@ impl ParaLayout { } best } - /// Line ascent/descent of the paragraph mark (for empty paragraphs and caret height). + /// The caret's ascent/descent on a line: the text's own, without line-spacing leading, kept inside the line box. pub fn caret_metrics(&self, li: usize) -> (f32, f32) { let Some(l) = self.lines.get(li) else { return (10.0, 3.0) }; - (l.baseline - l.top, l.top + l.height - l.baseline) + (l.text_asc.min(l.baseline - l.top), l.text_desc.min(l.top + l.height - l.baseline)) } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4aa08f3bf7..174dde1743 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7057,10 +7057,14 @@ fn paragraph_relative_floats_start_above_the_space_before() { let mut d = Document::from_text(""); d.body = vec![wordcraft_doc::para_block(first), wordcraft_doc::para_block(p)]; let l = lay(&d); - let first_bottom = { - let c = l.caret(&Pos::body(0, 0)).unwrap(); - c.top + c.height - }; + let first_bottom = l.pages[0] + .items + .iter() + .find_map(|i| match i { + Placed::Lines { path, para, l0, y, .. } if *path == Path::top(0) => para.lines.get(*l0).map(|ln| y + ln.height), + _ => None, + }) + .unwrap(); let top = l.pages[0].items.iter().find_map(|i| if let Placed::Shape { rect, .. } = i { Some(rect.y) } else { None }).unwrap(); (top, first_bottom) }; @@ -9400,3 +9404,22 @@ fn shared_band_grid_spans() { .unwrap(); assert_eq!(height, 27.0); } + +#[test] +fn caret_height_ignores_line_spacing() { + // #2920: extra line spacing is leading below the text; the caret stays the height of the font. + use wordcraft_doc::props::LineSpacing; + let caret_height = |spacing: Option| { + let mut d = Document::new(); + let chr = wordcraft_doc::CharProps { font: Some("Source Sans 3".into()), size: Some(12.0), ..Default::default() }; + let mut p = wordcraft_doc::Paragraph::with_text("Alpha probe", chr); + p.props = ParaProps { line_spacing: spacing, ..Default::default() }; + d.body = vec![wordcraft_doc::para_block(p)]; + lay(&d).caret(&Pos::body(0, 3)).unwrap().height + }; + let single = caret_height(Some(LineSpacing::Multiple(1.0))); + for spacing in [LineSpacing::Multiple(2.0), LineSpacing::AtLeast(40.0), LineSpacing::Exactly(40.0)] { + let wide = caret_height(Some(spacing)); + assert!((single - wide).abs() < 0.01, "{spacing:?}: single {single}, wide {wide}"); + } +} diff --git a/crates/layout/src/tests_ligature.rs b/crates/layout/src/tests_ligature.rs new file mode 100644 index 0000000000..18fb611d33 --- /dev/null +++ b/crates/layout/src/tests_ligature.rs @@ -0,0 +1,221 @@ +//! The caret inside a ligature (#1974): a glyph that draws several letters (fi, ffi, lam-alef) +//! gives each letter an even share of its advance, so the caret, clicks and selection edges can +//! land between the letters. A single grapheme cluster (letter plus combining mark) stays atomic. + +use super::*; +use wordcraft_doc::{Pos, StoryRef}; + +fn lay(doc: &Document) -> DocLayout { + let mut c = LayoutCache::new(); + layout(doc, &mut c, &LayoutOptions::default()) +} + +fn para_layout(l: &DocLayout) -> &ParaLayout { + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { story: StoryRef::Body, para, .. } = it { + return para; + } + } + } + panic!("no paragraph laid out"); +} + +/// `text` in a bundled font that draws fi and ffi as ligatures, so the tests don't depend on the +/// installed fonts (Liberation Sans, the usual Linux stand-in for the default font, draws none). +fn doc(text: &str) -> Document { + let mut d = Document::from_text(text); + let end = d.body.first().and_then(|b| b.as_para()).map_or(0, |p| p.text.len()); + let _ = d.format_range(&Pos::body(0, 0), &Pos::body(0, end), &|c| c.font = Some(LIGATURE_FONT.into())); + d +} + +const LIGATURE_FONT: &str = "Source Serif 4"; + +fn caret_x(l: &DocLayout, off: usize) -> f32 { + l.caret(&Pos::body(0, off)).map(|c| c.x).unwrap_or(f32::NAN) +} + +/// Does the default font draw `fi` as one glyph? (The tests below need a ligature to mean anything.) +fn has_fi_ligature(l: &DocLayout) -> bool { + let pl = para_layout(l); + pl.clusters.len() >= 2 && pl.clusters.first().is_some_and(|c| c.g1 > c.g0) && pl.glyphs.len() < pl.clusters.len() +} + +#[test] +fn caret_between_the_letters_of_a_ligature_is_inside_it() { + let l = lay(&doc("fi")); + assert!(has_fi_ligature(&l), "the default font should draw fi as a ligature"); + let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); + assert!(a < b && b < c, "caret x before, inside and after fi: {a} {b} {c}"); + assert!((b - a - (c - b)).abs() < 0.05, "an even split: {a} {b} {c}"); +} + +/// Width of "fi" (both letters) with character spacing `sp` points per letter. +fn fi_width_with_spacing(sp: Option) -> (DocLayout, f32) { + let mut d = doc("fi"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 2), &|c| c.spacing = sp).unwrap(); + let l = lay(&d); + let w = caret_x(&l, 2) - caret_x(&l, 0); + (l, w) +} + +#[test] +fn character_spacing_inside_a_ligature_is_kept() { + let (l0, plain) = fi_width_with_spacing(None); + assert!(has_fi_ligature(&l0), "the default font should draw fi as a ligature"); + for sp in [3.0_f32, -0.5] { + let (l, w) = fi_width_with_spacing(Some(sp)); + assert!(has_fi_ligature(&l), "fi stays a ligature with spacing {sp}"); + assert!((w - (plain + 2.0 * sp)).abs() < 0.05, "spacing {sp}: width {w}, expected {}", plain + 2.0 * sp); + let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); + assert!(a < b && b < c, "caret inside the ligature with spacing {sp}: {a} {b} {c}"); + } +} + +#[test] +fn clicks_inside_a_ligature_land_between_its_letters() { + let l = lay(&doc("fi")); + let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); + let cr = l.caret(&Pos::body(0, 1)).unwrap(); + let y = cr.top + cr.height / 2.0; + let hit = |x: f32| l.hit(cr.page, x, y, StoryRef::Body).map(|p| p.off); + assert_eq!(hit(b + 0.2), Some(1)); + assert_eq!(hit(b - 0.2), Some(1)); + assert_eq!(hit(a + 0.2), Some(0)); + assert_eq!(hit(c - 0.2), Some(2)); +} + +#[test] +fn every_letter_of_a_three_letter_ligature_has_its_own_caret() { + let l = lay(&doc("ffi")); + assert!(has_fi_ligature(&l), "the default font should draw ffi as a ligature"); + let xs: Vec = (0..=3).map(|o| caret_x(&l, o)).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}"); +} + +#[test] +fn a_combining_mark_stays_in_its_letters_cluster() { + let l = lay(&doc("fi\u{301}")); + let pl = para_layout(&l); + let offs = pl.line_offsets(0); + assert!(!offs.contains(&2), "no caret between i and its accent: {offs:?}"); +} + +#[test] +fn selection_edges_inside_a_ligature_use_the_same_positions() { + let d = doc("fi"); + let l = lay(&d); + let rects = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, 1), 0); + let r = rects.first().map(|(_, r)| *r).unwrap(); + let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 1), caret_x(&l, 2)); + assert!((r.x - a).abs() < 0.05 && (r.x + r.w - b).abs() < 0.05, "{r:?} {a} {b}"); + assert!(r.x + r.w < c - 0.05, "the highlight of f stops inside the ligature: {r:?} {c}"); +} + +/// "لا" laid out in an installed Arabic font that draws lam-alef as one glyph (Noto Naskh Arabic, +/// the usual fallback, draws two), or `None` when no such font is installed. +fn lam_alef_ligature() -> Option { + ["Noto Sans Arabic", "Noto Kufi Arabic", "Amiri", "Scheherazade New", "DejaVu Sans", "Arial", "Geeza Pro", "Tahoma"].into_iter().find_map( + |family| { + let mut d = Document::from_text("لا"); + d.format_range(&Pos::body(0, 0), &Pos::body(0, 4), &|c| { + c.font = Some(family.into()); + c.font_cs = Some(family.into()); + }) + .ok()?; + let l = lay(&d); + let pl = para_layout(&l); + let glyphs: Vec = pl.clusters.iter().map(|c| c.g1.saturating_sub(c.g0)).collect(); + (glyphs == [1, 0] && pl.styles.iter().any(|s| s.face.family == family)).then_some(l) + }, + ) +} + +#[test] +fn a_right_to_left_ligature_splits_mirrored() { + let Some(l) = lam_alef_ligature() else { + eprintln!("skipped: no lam-alef ligature font"); + return; + }; + // Logical offsets 0 (before lam), 2 (between lam and alef), 4 (after alef); the text runs leftward. + let (a, b, c) = (caret_x(&l, 0), caret_x(&l, 2), caret_x(&l, 4)); + assert!(a > b && b > c, "caret x runs right to left through the ligature: {a} {b} {c}"); + assert!(((a - b) - (b - c)).abs() < 0.05, "an even split: {a} {b} {c}"); +} + +#[test] +fn a_right_to_left_ligature_is_drawn_from_its_left_edge() { + // The glyph stays on lam, the rightmost letter; it must still start at alef's left edge. + let Some(l) = lam_alef_ligature() else { + eprintln!("skipped: no lam-alef ligature font"); + return; + }; + let pl = para_layout(&l); + let line = pl.lines.first().unwrap(); + let (k, head) = pl.clusters.iter().enumerate().find(|(_, c)| c.g1 > c.g0).unwrap(); + let glyph = pl.glyphs.get(head.g0 as usize).unwrap(); + let left = (line.c0..line.c1).filter_map(|k| line.cl_left(k)).fold(f32::INFINITY, f32::min); + let drawn = line.cl_left(k).unwrap() + glyph.dx; + assert!((drawn - left).abs() < 0.05, "glyph drawn at {drawn}, ligature starts at {left}"); +} + +#[test] +fn an_emoji_zwj_sequence_stays_one_cluster() { + // 👩‍💻: woman, zero-width joiner, laptop — one grapheme, one caret stop on each side. + let t = "a\u{1F469}\u{200D}\u{1F4BB}b"; + let l = lay(&Document::from_text(t)); + let offs = para_layout(&l).line_offsets(0); + assert!(!offs.contains(&5) && !offs.contains(&8), "no caret inside the emoji: {offs:?}"); + let xs: Vec = [0, 1, 12, 13].iter().map(|&o| caret_x(&l, o)).collect(); + assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}"); +} + +#[test] +fn an_indic_conjunct_stays_one_cluster() { + // क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it. + let l = lay(&Document::from_text("\u{915}\u{94D}\u{937}")); + let offs = para_layout(&l).line_offsets(0); + assert!(!offs.contains(&3) && !offs.contains(&6), "no caret inside the conjunct: {offs:?}"); +} + +#[test] +fn a_long_word_never_wraps_inside_a_ligature() { + // A narrow page forces an emergency break in the unbroken word; whatever the offset, a line + // never ends between a ligature's glyph and its glyphless letters. + let mut seen_ligature = false; + for k in 0..24 { + let mut d = doc(&format!("{}ffi", "x".repeat(k))); + d.last_section.margin_left = 250.0; + d.last_section.margin_right = 250.0; + let l = lay(&d); + let pl = para_layout(&l); + seen_ligature |= pl.glyphs.len() < pl.clusters.len(); + for line in &pl.lines { + if let Some(next) = pl.clusters.get(line.c1) { + let inside = next.g1 <= next.g0 && next.kind == crate::para::ClKind::Text && line.c1 > line.c0; + assert!(!inside, "k={k}: line ends at {} inside a ligature", line.c1); + } + } + } + assert!(seen_ligature, "the default font should draw ffi as a ligature"); +} + +#[test] +fn a_hyphenation_point_never_splits_a_ligature() { + // Soft hyphens sit between the letters of "ffi"; a line never ends between a ligature's glyph + // and its glyphless letters, whether or not the soft hyphen leaves the ligature intact. + for k in 0..24 { + let mut d = doc(&format!("{}f\u{ad}f\u{ad}i", "x".repeat(k))); + d.last_section.margin_left = 250.0; + d.last_section.margin_right = 250.0; + let l = lay(&d); + let pl = para_layout(&l); + for line in &pl.lines { + if let Some(next) = pl.clusters.get(line.c1) { + let inside = next.g1 <= next.g0 && next.kind == crate::para::ClKind::Text && line.c1 > line.c0; + assert!(!inside, "k={k}: line ends at {} inside a ligature", line.c1); + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 066df38528..b0416c11e6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -739,7 +739,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_transform(view); ctx.set_paint(color(opts.ink_text(*c, *text_bg), *alpha)); let w = *width as f64; - let mut st = kurbo::Stroke::new(w); + // Flat ends, like the PDF export and Word: a round cap sticks half the width out past + // every end, blobbing over the neighbouring border at a table's corners (#2892). Wavy + // underlines keep round ends, as the PDF draws them. + let mut st = kurbo::Stroke::new(w).with_caps(kurbo::Cap::Butt); match stroke { Stroke::Dotted => st = st.with_dashes(0.0, [w, w * 2.0]), Stroke::Dashed => st = st.with_dashes(0.0, [w * 4.0, w * 2.0]), @@ -763,7 +766,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl let (dx, dy) = (x1 - x0, y1 - y0); let len = (dx * dx + dy * dy).sqrt().max(1e-3); let (nx, ny) = (-dy / len * w * 0.9, dx / len * w * 0.9); - ctx.set_stroke(kurbo::Stroke::new(w * 0.6)); + ctx.set_stroke(kurbo::Stroke::new(w * 0.6).with_caps(kurbo::Cap::Butt)); line(ctx, nx, ny); line(ctx, -nx, -ny); return; @@ -1531,6 +1534,26 @@ mod tests { assert!(!img.to_png().is_empty()); } + /// #2892: a rule is stroked with flat ends, as the PDF export and Word draw them. Round caps + /// used to stick half the width out past every end — a blob over the neighbouring border at + /// each corner of a table with thick borders, while the exported PDF had square corners. + #[test] + fn a_thick_rule_ends_flat_not_round() { + let d = Document::new(); + let rule = |stroke| Draw::Line { x0: 10.0, y0: 10.0, x1: 30.0, y1: 10.0, width: 8.0, color: Rgb::BLACK, stroke, alpha: 1.0, text_bg: None }; + let shot = |stroke| render_items(&d, &[rule(stroke)], 0.0, 0.0, 40.0, 20.0, 1.0, &RenderOptions::default()); + let ink = |img: &Rendered, x: u32, y: u32| img.pixel(x, y)[3] > 128; + // An 8 pt rule from x=10 to x=30 covers y 6..14, and nothing beyond either end. + let img = shot(Stroke::Solid); + assert!(ink(&img, 15, 10), "the rule itself is drawn"); + assert!(!ink(&img, 6, 10) && !ink(&img, 33, 10), "no round cap past x=10 or x=30"); + // The double style's two strands end flat as well: its upper strand is centred on y=2.8 + // with a 4.8 pt width, so x=31 lies in the half-disc a round cap would add. + let img = shot(Stroke::Double); + assert!(ink(&img, 15, 3), "the double rule's upper strand is drawn"); + assert!(!ink(&img, 31, 3), "the upper strand ends at x=30"); + } + #[test] fn dark_page_inverts_lightness() { assert_eq!(invert_lightness(Rgb::WHITE), Rgb::BLACK); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 2dfcbedbdf..ac07f8424f 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -358,6 +358,11 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(tl!("Recent")).font(semibold(16.0))); ui.add_space(6.0); + let error = ui.ctx().data(|d| d.get_temp::(open_error_id())).unwrap_or_default(); + if !error.is_empty() { + ui.label(egui::RichText::new(error).color(ui.visuals().error_fg_color)); + ui.add_space(6.0); + } if app.ui.recent.is_empty() { ui.label(egui::RichText::new(tl!("Documents you open will show up here.")).color(t.text_dim)); } @@ -371,13 +376,30 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) { ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, &name, medium(13.0), t.text); ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, &p, regular(11.0), t.text_dim); if resp.clicked() { - let _ = app.run("file.open", json!({"path": p})); - app.ui.backstage = false; - app.canvas.want_focus = true; + let failed = open_recent(app, &p).err(); + ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default())); } } } +fn open_error_id() -> egui::Id { + egui::Id::new("backstage_open_error") +} + +/// Drop the Recent list's open error once File is closed, so it doesn't come back next time. +pub(crate) fn forget_open_error(ctx: &egui::Context) { + ctx.data_mut(|d| d.remove::(open_error_id())); +} + +/// 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}))?; + app.ui.backstage = false; + app.canvas.want_focus = true; + Ok(()) +} + fn open_page(app: &mut WordApp, ui: &mut Ui) { heading(ui, "Open"); if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() { @@ -529,6 +551,19 @@ mod tests { } } + /// #2938: opening a deleted file from Recent keeps File open and reports the error instead of + /// closing it on a greyed-out window. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn opening_a_missing_recent_file_keeps_file_open_with_the_error() { + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + app.ui.backstage = true; + let missing = std::env::temp_dir().join(format!("wordcraft-missing-{}.docx", std::process::id())); + let err = super::open_recent(&mut app, &missing.to_string_lossy()).unwrap_err(); + assert!(!err.is_empty()); + assert!(app.ui.backstage, "File stays open"); + } + /// #1873: the macOS back chevron sits beside the traffic lights windowed, at the plain /// control inset in full screen. #[test] @@ -717,6 +752,124 @@ mod tests { assert!(h.state().canvas.focused, "and the page has the keyboard"); } + /// A harness over the whole app; kittest always runs AccessKit, so a frame that names as + /// focused a widget it didn't draw panics with "Focused ID … is not in the node list". + fn a11y_harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + h.run_steps(4); + h + } + + /// A primary click at `pos` (for chrome with no label to find it by). + fn click_at(h: &mut egui_kittest::Harness<'static, WordApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(4); + } + + /// The macOS back chevron beside the traffic lights, in a window. + fn mac_chevron(a: &WordApp) -> egui::Pos2 { + super::back_chevron_c(0.0, a.mac_titlebar.height / 2.0, false, a.mac_titlebar.content_left) + } + + /// #2972: on macOS the File page's back button is the chevron beside the traffic lights (the + /// "sidebar collapse toggle" of the report; there is no other toggle there). Blank document, + /// File, then the chevron ended 0.8.0 with AccessKit's "Focused ID … is not in the node + /// list". The same spot clicked in the editor (the title bar) must not either. + #[test] + fn mac_back_chevron_returns_to_the_page_with_accessibility_on() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.integrated_titlebar = true; + a.mac_titlebar = crate::chrome::MacTitleBar { height: 28.0, content_left: 70.0 }; + let chevron = mac_chevron(&a); + let mut h = a11y_harness(a); + assert!(h.state().canvas.widget_id.is_some(), "the page has been drawn"); + for _ in 0..2 { + h.state_mut().run("ui.backstage", json!({"value": true})).unwrap(); + h.run_steps(4); + assert!(h.state().ui.backstage); + click_at(&mut h, chevron); + assert!(!h.state().ui.backstage, "the chevron closes the File page"); + assert!(h.state().canvas.focused, "and the page has the keyboard"); + // The same spot in the editor's title bar. + click_at(&mut h, chevron); + assert!(!h.state().ui.backstage); + } + } + + /// #2931: File › New › Blank document with a document open returns to the editor without + /// AccessKit losing the focused node; and so does Home's Blank document tile and Escape. + #[test] + fn blank_document_from_the_file_page_with_accessibility_on() { + let work = |mac: bool| { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Work")), Services::default()); + a.integrated_titlebar = mac; + a11y_harness(a) + }; + for mac in [false, true] { + for page in ["new", "home"] { + // A fresh "Work" document each time, so each path must really make a blank one. + let mut h = work(mac); + h.state_mut().run("ui.backstage", json!({"value": true, "page": page})).unwrap(); + h.run_steps(4); + h.get_by_label("Blank document").click(); + h.run_steps(4); + assert!(!h.state().ui.backstage, "{page}: Blank document returns to the editor"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "", "{page}: a new blank document"); + assert!(h.state().dialog.is_none(), "{page}: nothing asks first"); + } + let mut h = work(mac); + h.state_mut().run("ui.backstage", json!({"value": true})).unwrap(); + h.run_steps(4); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.state().ui.backstage, "Escape closes the File page"); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Work", "Escape keeps the document"); + } + } + + /// #2908: File › Options, then the back arrow (Windows/Linux layout and the macOS chevron), + /// with a document open and from the Start screen, without AccessKit ending the program. + #[test] + fn options_back_arrow_with_accessibility_on() { + for (mac, start) in [(false, false), (true, false), (false, true), (true, true)] { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + a.integrated_titlebar = mac; + if start { + a.ui.start_screen = true; + assert!(a.start_screen()); + } + let back = if mac { mac_chevron(&a) } else { egui::pos2(100.0, 32.0) }; + let mut h = a11y_harness(a); + if !start { + h.state_mut().run("ui.backstage", json!({"value": true})).unwrap(); + h.run_steps(4); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click(); + h.run_steps(4); + assert_eq!(h.state().ui.backstage_page, "options", "mac {mac} start {start}"); + // Into a pane, so the Options page has a focused widget of its own. + h.get_by_label("Display").click(); + h.run_steps(4); + click_at(&mut h, back); + assert!(!h.state().ui.backstage, "mac {mac} start {start}: the arrow closes the File page"); + if start { + assert!(!h.state().has_document(), "mac {mac}: from the Start screen, an empty window"); + } else { + assert!(h.state().canvas.focused, "mac {mac}: and the page has the keyboard"); + } + } + } + /// #260: File › Info's Title and Author fields dropped every keystroke (they edited a copy /// re-read each frame and only wrote it back when the field lost focus), so a save had no /// title or author. Typing now lands in the document as it happens, one undo step per field, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4feeee0562..fabe1d43dc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -148,6 +148,11 @@ pub struct CanvasState { pub balloon_rects: Vec<(u32, Rect)>, /// A paste event arrived since the last Mod+V release (see `keys::canvas_events`). pub(crate) pasted: bool, + /// A V (or Paste key) press reached the canvas since its last release. egui-winit keeps Mod+V + /// presses, so a release without one was Mod+V even when Mod went up first. + pub(crate) v_pressed: bool, + /// The modifiers last held with Mod, for a Mod+V whose Mod is released before V. + pub(crate) paste_mods: Option, /// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret. pub table_tool: Option, /// The Draw Table stroke (or eraser press) in progress. @@ -258,6 +263,8 @@ impl Default for CanvasState { mini_anchor: None, mini_dismissed: None, pasted: false, + v_pressed: false, + paste_mods: None, swallow_text: false, ime_join_text: None, ime_reset: false, diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a60b46151d..b5b63d1414 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -174,7 +174,7 @@ fn undo_list(ui: &mut Ui, app: &mut WordApp) { ui.vertical_centered(|ui| ui.label(egui::RichText::new(undo_list_footer(n)).font(regular(12.0)).color(t.text))); } if let Some(i) = clicked { - let _ = app.run("edit.undoTo", json!({"count": i + 1})); + crate::widgets::run_from_ribbon(app, "edit.undoTo", json!({"count": i + 1})); ui.close(); } } @@ -818,4 +818,25 @@ mod tests { } assert!(h.ctx.read_response(qat_menu_button_id()).is_some()); } + + /// #3040: clicking a row in the Undo list hands the keyboard back to the document. + #[test] + fn undo_list_row_returns_focus_to_the_document() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + a.run("text.insert", json!({"text": "Hello"})).unwrap(); + assert_eq!(a.session.undo_history().len(), 1); + + let (ctx, mut n) = (ctx(), 0); + frame(&ctx, &mut n, vec![], &mut a); + let arrow = ctx.read_response(undo_list_button_id()).unwrap(); + let popup = egui::Popup::default_response_id(&arrow); + click(&ctx, &mut n, arrow.rect.center(), &mut a); + assert!(egui::Popup::is_id_open(&ctx, popup), "the arrow opens the Undo list"); + frame(&ctx, &mut n, vec![], &mut a); + let row = ctx.read_response(undo_list_row_id(0)).unwrap(); + a.canvas.want_focus = false; + click(&ctx, &mut n, row.rect.center(), &mut a); + assert!(a.canvas.want_focus, "the row click returns focus to the document"); + assert!(a.session.undo_history().is_empty(), "the step was undone"); + } } diff --git a/crates/ui-egui/src/dialogs_para.rs b/crates/ui-egui/src/dialogs_para.rs index 32df96f3ed..fb34b1f2ea 100644 --- a/crates/ui-egui/src/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs_para.rs @@ -213,73 +213,87 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { ui.set_min_height(96.0); let mut remove = None; let mut row_active = false; - // The stops as an inline-editable table: position, alignment, clear. Fill the - // frame, so rows span its width and the scrollbar sits at its edge. - egui::ScrollArea::vertical().max_height(96.0).auto_shrink([false, true]).show(ui, |ui| { - for i in 0..f.stops.len() { - let Some(t) = f.stops.get(i).copied() else { continue }; - ui.push_id(i, |ui| { - let row = ui.horizontal(|ui| { - // Position, edited in place: the interface unit by default, any - // unit spelled out, either decimal separator (`.measure`). - let mut pv = t.pos / k; - let r = ui.add_sized( - vec2(58.0, 18.0), - egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2), - ); - if r.changed() { - f.set_pos(i, pv * k); - } - if r.has_focus() || r.dragged() { - row_active = true; - if f.selected != Some(i) { + if f.stops.is_empty() { + // #3031: a fresh paragraph has no stops; say the list is empty instead of + // showing a blank box. + ui.add_space(38.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(tl!("(none)")).small().color(Tokens::get(ui.ctx()).text_dim)); + }); + } else { + // The stops as an inline-editable table: position, alignment, clear. Fill the + // frame, so rows span its width and the scrollbar sits at its edge. + egui::ScrollArea::vertical().max_height(96.0).auto_shrink([false, true]).show(ui, |ui| { + for i in 0..f.stops.len() { + let Some(t) = f.stops.get(i).copied() else { continue }; + ui.push_id(i, |ui| { + let row = ui.horizontal(|ui| { + // Position, edited in place: the interface unit by default, any + // unit spelled out, either decimal separator (`.measure`). + let mut pv = t.pos / k; + let r = ui.add_sized( + vec2(58.0, 18.0), + egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2), + ); + if r.changed() { + f.set_pos(i, pv * k); + } + if r.has_focus() || r.dragged() { + row_active = true; + if f.selected != Some(i) { + pick = Some(i); + } + } + // Alignment, picked in place. + let mut a = t.align; + egui::ComboBox::from_id_salt("tab_align") + .width(86.0) + .selected_text(tl!(crate::ruler_tabs::kind_label(a))) + .show_ui(ui, |ui| { + for v in crate::ruler_tabs::KINDS { + ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v))); + } + }); + if a != t.align { + f.set_align(i, a); pick = Some(i); } - } - // Alignment, picked in place. - let mut a = t.align; - egui::ComboBox::from_id_salt("tab_align").width(86.0).selected_text(tl!(crate::ruler_tabs::kind_label(a))).show_ui( - ui, - |ui| { - for v in crate::ruler_tabs::KINDS { - ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v))); + // Leader, picked in place; the closed box shows just the fill. + let mut l = t.leader; + let shown = LEADERS + .iter() + .find(|(v, _, _)| *v == l) + .map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() }) + .unwrap_or_default(); + egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| { + for (v, label, _) in LEADERS { + ui.selectable_value(&mut l, v, tl!(label)); } - }, - ); - if a != t.align { - f.set_align(i, a); - pick = Some(i); - } - // Leader, picked in place; the closed box shows just the fill. - let mut l = t.leader; - let shown = LEADERS - .iter() - .find(|(v, _, _)| *v == l) - .map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() }) - .unwrap_or_default(); - egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| { - for (v, label, _) in LEADERS { - ui.selectable_value(&mut l, v, tl!(label)); + }); + if l != t.leader { + f.set_leader(i, l); + pick = Some(i); } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(10.0); // clear of the scrollbar + if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() { + remove = Some(i); + } + }); }); - if l != t.leader { - f.set_leader(i, l); - pick = Some(i); + if f.selected == Some(i) { + let tok = Tokens::get(ui.ctx()); + ui.painter().rect_stroke( + row.response.rect.expand(1.0), + 2.0, + Stroke::new(1.0, tok.accent), + egui::StrokeKind::Outside, + ); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(10.0); // clear of the scrollbar - if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() { - remove = Some(i); - } - }); }); - if f.selected == Some(i) { - let tok = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(row.response.rect.expand(1.0), 2.0, Stroke::new(1.0, tok.accent), egui::StrokeKind::Outside); - } - }); - } - }); + } + }); + } if let Some(i) = remove { f.remove_stop(i); } else if f.selected.is_some() && ui.memory(|m| m.focused().is_none()) && ui.input(|inp| inp.key_pressed(egui::Key::Delete)) { @@ -899,4 +913,34 @@ mod tests { f.new_stop(); assert_eq!(f.stops[0].pos, 36.0); } + + /// #3031: a paragraph with no stops — the default — says so in the list box instead of + /// showing a blank frame. + #[test] + fn the_tabs_dialog_says_when_there_are_no_stops() { + let mut app = app_with("plain"); + let _ = app.run("para.tabs", json!({})); + assert!(matches!(&app.dialog, Some(Dialog::Tabs { form }) if form.stops.is_empty())); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + let texts: Vec = h + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect(); + assert!(texts.iter().any(|t| t.contains("(none)")), "empty-state hint shown"); + } } diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index d59869c020..69861a3415 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -12,7 +12,7 @@ use wordcraft_engine::Password; use wordcraft_engine::cmd::tools::AutoCorrectPrefs; use crate::WordApp; -use crate::theme::semibold; +use crate::theme::{Tokens, semibold}; #[derive(Clone, Debug, Serialize)] #[serde(tag = "form", rename_all = "camelCase")] @@ -447,6 +447,10 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { let typed = f.from.trim().to_lowercase(); let mut picked: Option<(String, String)> = None; egui::ScrollArea::vertical().id_salt("ac_list").max_height(170.0).auto_shrink([false, true]).show(ui, |ui| { + if !list.iter().any(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)) { + // #3036: the filter matches nothing — say so instead of showing an empty box. + ui.label(egui::RichText::new(tl!("(none)")).small().color(Tokens::get(ui.ctx()).text_dim)); + } egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |ui| { for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) { let selected = f.from == *a; @@ -845,4 +849,45 @@ mod tests { assert_eq!(p["showIn"], "new"); assert!(p.get("original").is_none()); } + + /// #3036: the Replace list says so when the filter matches nothing instead of going blank. + #[test] + fn the_autocorrect_list_says_when_the_filter_matches_nothing() { + let mut app = app(""); + app.session.prefs.autocorrect.entries.push(("brb".to_string(), "be right back".to_string())); + app.run("tools.autocorrect", json!({})).unwrap(); + let texts = |h: &egui_kittest::Harness<'static, WordApp>| -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() + }; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + assert!(!texts(&h).iter().any(|t| t.contains("(none)")), "entries show, no hint"); + match &mut h.state_mut().dialog { + Some(crate::dialogs::Dialog::Review(d)) => match &mut **d { + ReviewDialog::AutoCorrect(f) => f.from = "zzz".to_string(), + _ => panic!("the AutoCorrect form is open"), + }, + _ => panic!("the AutoCorrect dialog is open"), + } + for _ in 0..2 { + h.step(); + } + assert!(texts(&h).iter().any(|t| t.contains("(none)")), "no matches: the hint shows"); + } } diff --git a/crates/ui-egui/src/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs_smart_art.rs index 0dffdeb5aa..d7f0e78249 100644 --- a/crates/ui-egui/src/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs_smart_art.rs @@ -98,7 +98,7 @@ pub fn preview(p: &Painter, r: Rect, layout: SmartArtLayout, colors: SmartArtCol } else { vec![at(0.5 - hw(y0), y0), at(0.5 + hw(y0), y0), at(0.5 + hw(y1), y1), at(0.5 - hw(y1), y1)] }; - p.add(egui::Shape::convex_polygon(pts, col(k), Stroke::new(1.0, Color32::WHITE))); + p.add(egui::Shape::convex_polygon(pts, col(k), Stroke::new(1.0, line))); } } SmartArtLayout::Radial => { diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index f3319986a4..0b43f5c582 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use egui::{Align2, Rect, Sense, Ui, pos2, vec2}; use crate::file_dialogs::{AfterPick, FileDialogRequest, open_filters}; -use crate::theme::{APP_COLOR, Tokens, regular}; +use crate::theme::{Tokens, regular}; use crate::{WordApp, icons}; /// At most this many entries are listed; a bigger folder shows the first ones and says so. @@ -360,11 +360,21 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> let mut clicked: Option = None; egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| { let entries: &[Entry] = b.listing.as_ref().map(|l| l.entries.as_slice()).unwrap_or(&[]); + // An unreadable folder has its own message below; the hint is for a listing that came back empty. + let listed = b.listing.is_ok(); egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([false, false]).show_rows( ui, ROW, entries.len(), |ui, range| { + if listed && entries.is_empty() { + // #3037: an empty folder (or a type filter that matches nothing) says so + // instead of showing a blank box. + ui.add_space(140.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(tl!("No items to show.")).small().color(t.text_dim)); + }); + } for e in entries.get(range).unwrap_or(&[]) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click()); if b.selected.as_ref() == Some(&e.path) { @@ -442,11 +452,8 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let (ok, cancelb) = crate::dialogs::ok_cancel_responses( - ui, - |ui| ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR)), - tl!("Cancel"), - ); + let (ok, cancelb) = + crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!(b.ok_label()))), tl!("Cancel")); cancel |= cancelb.clicked(); pick |= ok.clicked() || enter; }); @@ -561,4 +568,48 @@ mod tests { assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Saved without a portal")); let _ = std::fs::remove_dir_all(&dir); } + + /// #3037: an empty folder (or a type filter that matches nothing) says so instead of a blank + /// box; a matching file replaces the hint. + #[test] + 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); + 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)); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + let texts = |h: &egui_kittest::Harness<'static, WordApp>| -> Vec { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect() + }; + for _ in 0..4 { + h.step(); + } + assert!(texts(&h).iter().any(|t| t.contains("No items to show.")), "empty-state hint shown"); + std::fs::write(dir.join("chapter.docx"), b"x").unwrap(); + if let Some(b) = &mut h.state_mut().file_browser.open { + b.navigate(dir.clone()); + } + for _ in 0..2 { + h.step(); + } + assert!(texts(&h).iter().any(|t| t.contains("chapter.docx")), "the file is listed"); + assert!(!texts(&h).iter().any(|t| t.contains("No items to show.")), "the hint is gone"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index af0a3a3144..1994f2a154 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2412,6 +2412,7 @@ {name} already exists. Choose Save again to replace it. {name} вече съществува. Изберете „Запазване“ отново, за да го замените. This folder can't be read: {error} Тази папка не може да бъде прочетена: {error} Showing the first {count} items. Показани са първите {count} елемента. + No items to show. Няма елементи за показване. File name: Име на файла: Browse in WordCraft… Разглеждане в WordCraft… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Отваряне на файл чрез собствения браузър на WordCraft, когато системният диалогов прозорец за файлове не се появява. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 041f03be1d..a7c5060735 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2325,6 +2325,7 @@ preview Title Ukázka {name} already exists. Choose Save again to replace it. Položka {name} již existuje. Dalším zvolením Uložit ji nahradíte. This folder can't be read: {error} Tuto složku nelze přečíst: {error} Showing the first {count} items. Zobrazuje se prvních {count} položek. + No items to show. Žádné položky k zobrazení. File name: Název souboru: Browse in WordCraft… Procházet ve WordCraftu… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otevřít soubor pomocí vlastního prohlížeče souborů WordCraftu, když se systémový dialog pro práci se soubory nezobrazí. diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index a80b38d0b2..15bbbb54d4 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -1998,6 +1998,7 @@ template Letter Pismo Show/Hide ¶ Prikaži/sakrij ¶ Show\nComments Prikaži\nkomentare Showing the first {count} items. Prikazuje se prvih {count} stavki. + No items to show. Nema stavki za prikaz. Shows a tree: each item below the item it belongs to. Prikazuje stablo: svaka stavka nalazi se ispod stavke kojoj pripada. Shows items around a central idea: the first item is the centre. Prikazuje stavke oko središnje ideje: prva stavka je središte. Shows items of equal weight as blocks in rows. Prikazuje stavke jednake težine kao blokove u retcima. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0998d7d86a..33951fee5f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2318,6 +2318,7 @@ {name} already exists. Choose Save again to replace it. Már létezik: {name}. A felülírásához válaszd újra a Mentést. This folder can't be read: {error} Ez a mappa nem olvasható: {error} Showing the first {count} items. Az első {count} elem látható. + No items to show. Nincs megjeleníthető elem. File name: Fájlnév: Browse in WordCraft… Tallózás a WordCraftban… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Fájl megnyitása a WordCraft saját tallózójával, ha a rendszer fájlpárbeszédablaka nem jelenik meg. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 314f7cada3..3dd2b6d61d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2365,6 +2365,7 @@ Scripts Indici Secant Function Funzione secante Showing the first {count} items. Sono mostrati i primi {count} elementi. + No items to show. Nessun elemento da mostrare. Sine Function Funzione seno Sine theta Seno di theta Single Brackets Parentesi singole diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 784182eb7d..ccbeb471fe 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2016,6 +2016,7 @@ Show changes in: Pokaż zmiany w: Show the mini toolbar when text is selected Pokazuj minipasek narzędzi po zaznaczeniu tekstu Showing the first {count} items. Pokazano pierwsze elementy: {count}. + No items to show. Brak elementów do wyświetlenia. Shows a tree: each item below the item it belongs to. Pokazuje drzewo: każdy element pod elementem, do którego należy. Shows items around a central idea: the first item is the centre. Pokazuje elementy wokół głównej idei: pierwszy element jest w środku. Shows items of equal weight as blocks in rows. Pokazuje równorzędne elementy jako bloki w wierszach. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7880999f98..cd064b4eeb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2198,6 +2198,7 @@ preview Title Пример {name} already exists. Choose Save again to replace it. {name} уже существует. Чтобы заменить его, снова выберите «Сохранить». This folder can't be read: {error} Не удаётся прочитать папку: {error} Showing the first {count} items. Показаны первые элементы: {count}. + No items to show. Нет элементов для показа. File name: Имя файла: Browse in WordCraft… Обзор в WordCraft… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Открыть файл во встроенном обозревателе WordCraft, если системное диалоговое окно выбора файлов не появляется. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 50cab37aac..2098fc2784 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1785,6 +1785,7 @@ {name} already exists. Choose Save again to replace it. {name} finns redan. Välj Spara igen för att ersätta den. This folder can't be read: {error} Den här mappen kan inte läsas: {error} Showing the first {count} items. Visar de första {count} objekten. + No items to show. Inga objekt att visa. File name: Filnamn: Browse in WordCraft… Bläddra i WordCraft… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Öppna en fil med WordCrafts egen filbläddrare när systemets fildialogruta inte visas. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e7771f20a5..50799c62de 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2197,6 +2197,7 @@ preview Title Örnek {name} already exists. Choose Save again to replace it. {name} zaten var. Değiştirmek için Kaydet'i yeniden seçin. This folder can't be read: {error} Bu klasör okunamıyor: {error} Showing the first {count} items. İlk {count} öğe gösteriliyor. + No items to show. Gösterilecek öğe yok. File name: Dosya adı: Browse in WordCraft… WordCraft'ta Gözat… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Sistemin dosya iletişim kutusu açılmadığında dosyayı WordCraft'ın kendi dosya tarayıcısıyla açın. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6616368ace..e2bfe75ec2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2400,6 +2400,7 @@ {name} already exists. Choose Save again to replace it. {name} đã tồn tại. Chọn Lưu lần nữa để thay thế. This folder can't be read: {error} Không thể đọc thư mục này: {error} Showing the first {count} items. Đang hiển thị {count} mục đầu tiên. + No items to show. Không có mục nào để hiển thị. File name: Tên tệp: Browse in WordCraft… Duyệt trong WordCraft… Open a file with WordCraft's own browser when the system file dialog doesn't appear. Mở tệp bằng trình duyệt tệp của WordCraft khi hộp thoại tệp của hệ thống không xuất hiện. diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 358f7cc8f6..865059a3e7 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -200,6 +200,20 @@ fn is_paste_release(key: Key, modifiers: egui::Modifiers) -> bool { (modifiers.command || key == Key::Paste) && !modifiers.alt && !modifiers.shift } +/// The modifiers a V release stands for: its own, or, when Mod went up before V and no V press +/// came (egui-winit keeps Mod+V presses), those held with Mod. +fn paste_release_modifiers(released: Modifiers, press_seen: bool, held: Option) -> Modifiers { + if released.command || press_seen { released } else { held.unwrap_or(released) } +} + +/// Whether `key` is a Shift, Ctrl, Alt or Super key, whose own presses don't end a Mod+V. +fn is_modifier_key(key: Key) -> bool { + matches!( + key, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ) +} + /// Whether releasing `key` with `modifiers` is Mod+Alt+V (Paste Special). fn is_paste_special_release(key: Key, modifiers: egui::Modifiers) -> bool { key == Key::V && modifiers.command && modifiers.alt && !modifiers.shift @@ -241,7 +255,26 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { let alt_codes = alt_codes_supported(ctx); // `DeleteSurrounding` and the text replacing what it erased arrive in the same frame. app.canvas.ime_join_text = None; + // Mod held since an earlier frame (its ModifiersChanged went by then). + let held = ctx.input(|i| i.modifiers); + if held.command { + app.canvas.paste_mods = Some(held); + } for e in events { + // Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod + // can go down and up within one frame, leaving the frame's modifiers without it. + match &e { + egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m), + egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true, + egui::Event::Key { key, pressed: true, modifiers, .. } if !is_modifier_key(*key) => { + app.canvas.paste_mods = modifiers.command.then_some(*modifiers); + } + egui::Event::WindowFocused(_) => { + app.canvas.v_pressed = false; + app.canvas.paste_mods = None; + } + _ => {} + } // An Alt shortcut (Alt+Shift+D, Alt+F7…) may also send its key's text; it isn't typed. match &e { egui::Event::Key { pressed: true, .. } => { @@ -273,8 +306,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } // Desktop: Mod+V with no text on the clipboard sends no paste event, only the key's // release; paste the clipboard's picture then (#45). A paste event before the release - // means it was text. (The web reads pasted pictures itself.) + // means it was text. (The web reads pasted pictures itself.) Mod released before V still + // pastes: a release with no V press seen was Mod+V. if let egui::Event::Key { key: key @ (Key::V | Key::Paste), pressed: false, modifiers, .. } = &e { + let press_seen = std::mem::take(&mut app.canvas.v_pressed); + let modifiers = &paste_release_modifiers(*modifiers, press_seen, app.canvas.paste_mods.take()); let paste_keys = is_paste_release(*key, *modifiers); let pasted = std::mem::take(&mut app.canvas.pasted); if !pasted && paste_keys && app.session.math.is_none() && app.services.clipboard_picture.is_some() { @@ -711,6 +747,99 @@ mod tests { assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false))); } + /// An empty document whose clipboard holds a 4 × 4 picture. + fn picture_app() -> WordApp { + use crate::paste_picture::ClipboardPicture; + let services = crate::Services { + clipboard_picture: Some(Box::new(|| Some(ClipboardPicture::Pixels { width: 4, height: 4, rgba: vec![200; 4 * 4 * 4] }))), + ..Default::default() + }; + WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services) + } + + /// How many pictures the document holds (the Selection Pane's list). + fn pictures(a: &mut WordApp) -> usize { + a.session.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(|x| x.len()).unwrap_or(0) + } + + /// One canvas frame with `modifiers` held, then `events`. + fn mods_frame(ctx: &egui::Context, a: &mut WordApp, modifiers: Modifiers, events: Vec) { + let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(a, ui.ctx())).drop_without_applying_deltas(); + } + + /// A press or release of `key` with `modifiers`. + fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers } + } + + /// Left Ctrl+V with one hand often lets Ctrl up a moment before V. egui-winit keeps the + /// Mod+V press, so the canvas sees only Ctrl's release, then V's without Mod: still a paste. + #[test] + fn mod_v_released_mod_first_pastes_the_clipboard_picture() { + let ctx = egui::Context::default(); + let mut a = picture_app(); + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::ControlLeft, true, ctrl)]); + // The Mod+V press: egui-winit sends no event for it. + mods_frame(&ctx, &mut a, ctrl, vec![]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::ControlLeft, false, Modifiers::NONE)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // A plain V afterwards (its press arrives) isn't a paste. + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, true, Modifiers::NONE)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // Nor is Ctrl+B, Ctrl up, then a V release whose press never came (focus moved in). + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::B, true, ctrl)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::ControlLeft, false, Modifiers::NONE), egui::Event::WindowFocused(true)]); + mods_frame(&ctx, &mut a, Modifiers::NONE, vec![key_event(Key::V, false, Modifiers::NONE)]); + assert_eq!(pictures(&mut a), 1); + + // Mod+Shift+V released Mod first stays Paste Formatting, not a picture paste. + let ctrl_shift = Modifiers { shift: true, ..ctrl }; + mods_frame(&ctx, &mut a, ctrl_shift, vec![]); + mods_frame(&ctx, &mut a, Modifiers::SHIFT, vec![key_event(Key::ControlLeft, false, Modifiers::SHIFT)]); + mods_frame(&ctx, &mut a, Modifiers::SHIFT, vec![key_event(Key::V, false, Modifiers::SHIFT)]); + assert_eq!(pictures(&mut a), 1); + + // Released V first, as before, still pastes. + let mut a = picture_app(); + mods_frame(&ctx, &mut a, ctrl, vec![]); + mods_frame(&ctx, &mut a, ctrl, vec![key_event(Key::V, false, ctrl)]); + assert_eq!(pictures(&mut a), 1); + } + + /// Ctrl down, the kept Mod+V press, Ctrl up and V up can all arrive in one frame: the frame's + /// modifiers no longer have Mod, but the ModifiersChanged events in between did. + #[test] + fn mod_v_within_one_frame_pastes_the_clipboard_picture() { + let ctx = egui::Context::default(); + let mut a = picture_app(); + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + let events = vec![ + egui::Event::ModifiersChanged(ctrl), + key_event(Key::ControlLeft, true, ctrl), + egui::Event::ModifiersChanged(Modifiers::NONE), + key_event(Key::ControlLeft, false, Modifiers::NONE), + key_event(Key::V, false, Modifiers::NONE), + ]; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| canvas_events(&mut a, ui.ctx())).drop_without_applying_deltas(); + assert_eq!(pictures(&mut a), 1); + } + + /// A V release keeps its own modifiers unless Mod went up first and no V press arrived. + #[test] + fn paste_release_modifiers_fall_back_to_those_held_with_mod() { + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + assert_eq!(paste_release_modifiers(ctrl, false, None), ctrl, "Mod still down"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, false, Some(ctrl)), ctrl, "Mod up first, press kept by egui-winit"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, true, Some(ctrl)), Modifiers::NONE, "a plain V's press arrived"); + assert_eq!(paste_release_modifiers(Modifiers::NONE, false, None), Modifiers::NONE); + } + #[test] fn shift_delete_is_only_shift_and_delete() { let m = |shift, ctrl, alt| egui::Modifiers { shift, ctrl, command: ctrl, alt, ..Default::default() }; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 13cbb3d9db..3a2398d858 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2208,6 +2208,10 @@ impl WordApp { if self.session.view.focus_mode && self.dialog.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.run("view.focus", json!({"value": false})); } + // A failed Recent open is news only until File closes (#2938). + if !self.ui.backstage { + backstage::forget_open_error(&ctx); + } if self.ui.backstage { backstage::show(self, ui); } else if !self.has_document() { @@ -3005,6 +3009,29 @@ mod tests { assert!(!b.ui.backstage && b.has_document()); } + /// #3045: on the empty window (no document behind the Start screen) the ribbon is greyed + /// out, but the tab strip works: File reaches the Start screen again, where a document can + /// be made. Disabling the Share / Editing / Comments row disabled the whole line, the tab + /// strip with it: the tabs took clicks that did nothing. + #[test] + fn empty_window_ribbon_tabs_reach_the_start_screen() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.start_screen(); + let mut h = harness(a); + h.run_steps(4); + // Esc (or the back arrow) leaves the Start screen for the empty window, as #1129 has it. + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.state().ui.backstage, "the empty window"); + assert!(!h.state().has_document(), "with no document"); + // File, the first tab, is live and reaches the Start screen. + h.get_by_label("File").click(); + h.run_steps(4); + assert!(h.state().ui.backstage && h.state().ui.backstage_page == "home", "File reaches the Start screen"); + assert!(h.query_by_label("Blank document").is_some(), "where a document can be made"); + } + /// #1908: with no document open the Home ribbon's split buttons grey out like the plain /// buttons beside them, and their drop-down arrow opens no menu. #[test] diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index 92749b2633..40e76dd672 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -285,7 +285,7 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) { ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2)); if resp.on_hover_text(h.name()).clicked() { app.canvas.last_highlight = h.ooxml().to_string(); - let _ = app.run("format.highlight", json!({"color": h.ooxml()})); + widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()})); ui.close(); } if i % 5 == 0 { @@ -305,7 +305,7 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) { fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) { egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { if ui.selectable_label(false, "Automatic").clicked() { - let _ = app.run("format.color", json!({"color": "auto"})); + widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"})); ui.close(); return; } @@ -678,4 +678,51 @@ mod tests { click_at(&mut h, pos2(arrow.min.x - 12.0, arrow.center().y)); assert_eq!(format_state(&mut h).get("highlight"), Some(&Value::Null), "the button applies No Color, not yellow again"); } + + /// #3039: picking Automatic from the bar's font-colour menu hands the keyboard back to the + /// document. + #[test] + fn the_font_colour_menus_automatic_returns_focus_to_the_document() { + use egui_kittest::kittest::{By, Queryable}; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), Default::default()); + a.run("select.all", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 400.0)).build_ui_state( + |ui, app: &mut WordApp| { + egui::Area::new(egui::Id::new("font_colour_probe")).fixed_pos(pos2(50.0, 50.0)).show(ui.ctx(), |ui| { + font_color_menu(ui, app); + }); + }, + a, + ); + steps(&mut h); + let automatic = h.get(By::new().label_contains("Automatic")).rect().center(); + h.state_mut().canvas.want_focus = false; + click_at(&mut h, automatic); + assert!(h.state().canvas.want_focus, "Automatic returns focus to the document"); + } + + /// #3038: a pick from the bar's highlight grid hands the keyboard back to the document, + /// like the No Color button beside the swatches (it ran through `run_from_ribbon` already). + #[test] + fn picking_a_highlight_swatch_returns_focus_to_the_document() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), Default::default()); + a.run("select.all", json!({})).unwrap(); + let origin = egui::Id::new("hl_swatch_origin"); + let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state( + move |ui, app: &mut WordApp| { + egui::Area::new(egui::Id::new("hl_probe")).fixed_pos(pos2(100.0, 100.0)).show(ui.ctx(), |ui| { + ui.ctx().data_mut(|d| d.insert_temp(origin, ui.next_widget_position())); + highlight_menu(ui, app); + }); + }, + a, + ); + steps(&mut h); + // The first rendered swatch is Yellow (None is skipped), 18 × 18 at the grid's origin. + let first = h.ctx.data(|d| d.get_temp::(origin)).expect("the grid drew"); + h.state_mut().canvas.want_focus = false; + click_at(&mut h, first + Vec2::new(9.0, 9.0)); + assert!(h.state().canvas.want_focus, "the swatch pick returns focus to the document"); + assert!(format_state(&mut h).get("highlight").is_some_and(|v| !v.is_null()), "the first swatch applied a highlight"); + } } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c600daaea5..8fe22cdc28 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -216,18 +216,20 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { } /// Constrain each heading to the available pane width so long document text cannot -/// enlarge the panel. The full heading stays accessible on hover. +/// enlarge the panel. A heading too long for the width wraps onto further lines — a word +/// wider than the pane is broken too — and the row grows with it, so the label reads whole +/// instead of ending in an ellipsis (#3015). fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool) -> egui::Response { ui.horizontal(|ui| { ui.add_space(level as f32 * 14.0); let label = egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) }); - ui.add_sized( - vec2(ui.available_width(), ui.spacing().interact_size.y), + let width = ui.available_width(); + ui.add( // The trailing grow atom takes the spare width, so the text starts flush left - // under its parent instead of centred, as in Word (#1199). - egui::Button::selectable(selected, (label, egui::Atom::grow())).truncate(), + // under its parent instead of centred, as in Word (#1199). A minimum height + // keeps short headings on one row; a wrapped one becomes as tall as it needs. + egui::Button::selectable(selected, (label, egui::Atom::grow())).wrap().min_size(vec2(width, ui.spacing().interact_size.y)), ) - .on_hover_text(text) }) .inner } @@ -312,18 +314,18 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("styles.inspector", json!({})); } ui.separator(); - let mut list: Vec<(String, String, bool)> = app + let mut list: Vec<(String, String, bool, bool)> = app .session .doc .styles .styles .iter() .filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table) - .map(|s| (s.id.clone(), crate::i18n::style_name(s), s.kind == wordcraft_doc::StyleKind::Character)) + .map(|s| (s.id.clone(), crate::i18n::style_name(s), s.kind == wordcraft_doc::StyleKind::Character, s.builtin)) .collect(); list.sort_by_key(|s| s.1.to_lowercase()); egui::ScrollArea::vertical().show(ui, |ui| { - for (id, name, is_char) in list { + for (id, name, is_char, builtin) in list { ui.horizontal(|ui| { let r = ui.selectable_label(id == cur, &name); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -343,7 +345,7 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { app.canvas.want_focus = true; ui.close(); } - if ui.button(tl!("Delete")).clicked() { + if ui.add_enabled(!builtin, egui::Button::new(tl!("Delete"))).clicked() { let _ = app.run("styles.delete", json!({"style": id})); app.canvas.want_focus = true; ui.close(); @@ -871,6 +873,44 @@ mod tests { assert!(h.state().canvas.focused, "Delete returns focus to the document"); assert!(h.state().session.clip_history.items().is_empty(), "the item was deleted"); } + + /// #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] + #[cfg(not(target_arch = "wasm32"))] + fn built_in_styles_grey_out_delete_in_the_styles_pane() { + use egui_kittest::kittest::{NodeT, Queryable}; + + /// Draw only the pane, so each style name labels exactly one node — the ribbon's Styles + /// gallery shows the same names — right-click the row, and report whether its Delete is + /// greyed out. + fn delete_is_disabled(style: &str) -> bool { + let mut app = + crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + app.run("styles.create", serde_json::json!({"name": "Callout"})).unwrap(); + // The pane writes in the "semibold" family, which the app installs with the rest of the + // interface fonts (`chrome::title_bar`). Fonts take effect the frame after they are set, + // so the first frame only installs them and the pane is drawn from the second on. + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 1200.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + super::styles(app, ui); + }, + app, + ); + h.run_steps(4); + h.get_by_label(style).click_secondary(); + h.run_steps(3); + h.get_by_label("Delete").accesskit_node().is_disabled() + } + assert!(delete_is_disabled("Normal"), "the built-in style's Delete is greyed out"); + assert!(!delete_is_disabled("Callout"), "the custom style's Delete stays enabled"); + } } #[cfg(test)] @@ -878,9 +918,12 @@ mod navigation_tests { use super::*; #[test] - fn long_headings_truncate_without_stretching_navigation() { - let text = "An extremely long heading with words that must stay inside the navigation pane ".repeat(20); - for (width, level) in [(260.0, 0), (180.0, 5)] { + fn long_headings_wrap_without_stretching_navigation() { + // A heading that runs well past the width, and a single word wider than the pane. + let headings = ["An extremely long heading with words that must stay inside the navigation pane ".repeat(20), "x".repeat(400)]; + // Draw one heading in a pane of `width`: the pane rect, the heading row's rect, the label's + // line count and whether it was elided. + let render = |width: f32, level: u8, text: &str| -> (egui::Rect, egui::Rect, usize, bool) { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 600.0)); @@ -890,23 +933,33 @@ mod navigation_tests { let result = egui::Panel::left("navigation_test") .default_size(width) .resizable(true) - .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| nav_heading(ui, level, &text, true)).inner); + .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| nav_heading(ui, level, text, true)).inner); panel_rect = result.response.rect; heading_rect = result.inner.rect; }); - - assert!(panel_rect.width() < width + 20.0, "long heading expanded the {width}px pane to {panel_rect:?}"); - assert!(heading_rect.max.x <= panel_rect.max.x, "heading escaped pane: {heading_rect:?} / {panel_rect:?}"); - assert!( - output.shapes.iter().any(|s| match &s.shape { - egui::Shape::Text(t) => { - t.galley.elided && t.galley.rows.last().is_some_and(|r| r.row.glyphs.last().is_some_and(|g| g.chr == '…')) - } - _ => false, - }), - "long heading must render a trailing ellipsis at {width}px" - ); + let (rows, elided) = output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) => Some((t.galley.rows.len(), t.galley.elided)), + _ => None, + }) + .unwrap_or((0, false)); output.drop_without_applying_deltas(); + (panel_rect, heading_rect, rows, elided) + }; + for (width, level) in [(260.0, 0), (180.0, 5)] { + let (_, one_line, _, _) = render(width, level, "Kilns"); + for text in &headings { + let (panel_rect, heading_rect, rows, elided) = render(width, level, text); + assert!(panel_rect.width() < width + 20.0, "long heading expanded the {width}px pane to {panel_rect:?}"); + assert!(heading_rect.max.x <= panel_rect.max.x, "heading escaped pane: {heading_rect:?} / {panel_rect:?}"); + // The heading wraps onto further lines instead of ending in an ellipsis (#3015). + assert!(rows > 1, "long heading must wrap onto further lines at {width}px, got {rows}"); + assert!(!elided, "long heading must not end in an ellipsis at {width}px"); + // ... and its row grows with the text, where a short heading keeps one row. + assert!(heading_rect.height() > one_line.height(), "a wrapped heading row is taller at {width}px: {heading_rect:?} vs {one_line:?}"); + } } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0b59780afa..1c45f5f685 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -332,54 +332,57 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .collect(); let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // No document open (#1129): nothing to share, edit or comment on. - if !app.has_document() { - ui.disable(); - } - let (share_size, share_text) = if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) }; - let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click()); - ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); - let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x }; - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)), - "share", - t.on_accent, - t.on_accent, - ); - if share_text { - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, share)); - if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { - let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); - } - ui.add_space(6.0); - let track = app.session.doc.settings.track_changes; - let mode = if plan.buttons == 0 { - crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0)) - } else { - crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true) - }; - egui::Popup::menu(&mode).show(|ui| { - if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": false})); - ui.close(); + // No document open (#1129): nothing to share, edit or comment on — but only + // these buttons. Disabling the whole line took the tab strip with it: the + // tabs took clicks that did nothing, so the empty window had no way back to + // the Start screen (#3045). + ui.add_enabled_ui(app.has_document(), |ui| { + let (share_size, share_text) = + if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) }; + let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click()); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); + let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x }; + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)), + "share", + t.on_accent, + t.on_accent, + ); + if share_text { + ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); } - if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": true})); - ui.close(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, share)); + if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { + let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); + } + ui.add_space(6.0); + let track = app.session.doc.settings.track_changes; + let mode = if plan.buttons == 0 { + crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0)) + } else { + crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true) + }; + egui::Popup::menu(&mode).show(|ui| { + if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": false})); + ui.close(); + } + if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": true})); + ui.close(); + } + }); + ui.add_space(4.0); + let comments_button = if plan.buttons == 0 { + crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0)) + } else { + crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false) + }; + if comments_button.clicked() { + let _ = app.run("view.commentsPane", json!({})); } }); - ui.add_space(4.0); - let comments_button = if plan.buttons == 0 { - crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0)) - } else { - crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false) - }; - if comments_button.clicked() { - let _ = app.run("view.commentsPane", json!({})); - } ui.add_space(8.0); ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { // The tabs that don't fit go into a » menu at the end of the strip (#897, #2026). diff --git a/crates/ui-egui/src/wordart_menus.rs b/crates/ui-egui/src/wordart_menus.rs index a9f7fac623..f312bc22c1 100644 --- a/crates/ui-egui/src/wordart_menus.rs +++ b/crates/ui-egui/src/wordart_menus.rs @@ -48,7 +48,7 @@ fn paint_style(p: &egui::Painter, r: Rect, id: &str, theme: &[Rgb]) { p.rect_filled( Rect::from_min_max(pos2(r.center().x - r.width() * 0.25, r.max.y - 3.0), pos2(r.center().x + r.width() * 0.25, r.max.y)), 1.0, - Color32::from_gray(190), + Tokens::get(p.ctx()).text_dim, ); } if let Some(o) = fx.outline.and_then(|o| o.color) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e88e43b22a..d68488edf5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `

` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** trivial (RTF import compares heading and code formatting with the file's stylesheet; 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: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -77,13 +77,13 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) | | Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds` (durable ids) and `commentsExtensible` (UTC dates) read and regenerated consistently with the written paragraph ids; `people.xml` carried verbatim | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | -| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF reads and writes character and paragraph changes (`\oldcprops`, `\oldpprops`, with the formatting the flow model knows, list membership included); as Word writes them, `\oldcprops` holds only the properties that changed (with `\crauth`/`\crdate` inside it) and `\oldpprops` the full old set from `\pard`; a run without a direct font or size has its paragraph style's written there (heading, Title, Quote, Code), Normal's as `\f0\fs24`; a size or bold set by hand on heading/code text isn't kept through RTF (as before), so Reject there gives the style's value; a formatting change on the paragraph mark itself and section, table, row and cell changes are dropped. ODT ignores them | +| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF reads and writes character and paragraph changes (`\oldcprops`, `\oldpprops`, with the formatting the flow model knows, list membership included); as Word writes them, `\oldcprops` holds only the properties that changed (with `\crauth`/`\crdate` inside it) and `\oldpprops` the full old set from `\pard`; a run without a direct font or size has its paragraph style's written there (heading, Title, Quote, Code), Normal's as `\f0\fs24`; on import a heading's, Title's, Quote's or Code run's size, bold, italic or code font counts as the style's only when it equals the file's own stylesheet entry (`\b`, `\i`, `\fs`, `\f`, followed through `\sbasedon`; what the chain doesn't set is RTF's default, not bold and 12 pt, so Word 365's non-bold headings keep a bold set by hand; WordCraft's style values only where the chain sets no character formatting at all, and the code font where it sets no `\f`), so a size or bold set by hand there is kept, before and after a tracked change, and Reject gives it back; a formatting change on the paragraph mark itself and section, table, row and cell changes are dropped. ODT ignores them | | 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. `w:doNotTrackMoves` (Track moves off) round-trips; with it a tracked cut and paste records a plain deletion and insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result; `ASK` and `SET` keep it but show nothing, as in Word (#1765). 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) | | 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) | | 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. 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 — while other properties (title, tag, placeholder…) stay editable | +| 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 | | 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 | ❌ | ❌ | | @@ -233,12 +233,15 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | +| 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | A Word 2003 XML or Flat OPC document, or a web page, saved under a `.doc`, `.docx` or `.rtf` name opens by what it holds, as in Word, instead of going to the Word 97-2003 reader and showing a blank page (#2853) | | 2026-10-18 | trivial | `w:sym`: the symbol's `w:font` is kept apart from the run font (ECMA-376 §17.3.3.30), so changing the text's font (or Clear Formatting) keeps Wingdings/Symbol characters showing, and they are written back as ``; Insert › Symbol in a symbol font does the same (#2036). A private-use character with no symbol font of its own in a non-symbol run is not guessed | | 2026-10-18 | trivial | Manage Sources imports Word's `Sources.xml` (`b:Sources`) into the document's bibliography sources, sharing the reader of a document's own bibliography part (#2640) | | 2026-10-14 | trivial | RTF export writes sections: `\sectd` formatting and `\sect` per section, round-tripping through the reader (gap 19) | | 2026-10-14 | trivial | `ASK` and `SET` fields keep their result but show nothing (#1765) | | 2026-10-16 | trivial | Strict OOXML line heights: `w:line="13.90pt"` under `lineRule="auto"` is read as Word renders it — through twips (13.90 × 20 = 278; the 1.1583 multiple), not as 240ths of a line — so a Word 2024 strict file opens at Word's line spacing and page count (#2329) | +| 2026-10-15 | trivial | RTF import reads each stylesheet entry's bold, italic, size and font (through `\sbasedon`; unset means RTF's default once the chain sets any) and keeps a heading's, Title's, Quote's or Code run's formatting set by hand, in the text and in the old formatting of a tracked change (gap 19) | | 2026-10-15 | trivial | RTF import's heading, Title and Code normalisation is main's again (direct size, bold and code font cleared); only the writer resolves old formatting to the style's values (gap 19) | | 2026-10-15 | trivial | RTF old formatting in heading, Title, Quote and Code paragraphs is written with the style's size and font, not RTF's 12 pt default; `\pard` inside `\oldcprops` no longer drops its words (gap 19) | | 2026-10-15 | trivial | RTF `\oldcprops` holds only the properties a tracked change altered, as Word for Mac writes it: read on top of the run's formatting (Reject no longer clears its font and size) with `\crauth`/`\crdate` inside the group, and written as the differences (gap 19) | diff --git a/docs/gaps.md b/docs/gaps.md index fbadfe74bf..a65a6d80cc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -106,6 +106,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | +| 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-17 | trivial | #1327: Restrict Editing exceptions no longer let table, row or cell formatting change protected tables; formatting-only protection refuses Paste Formatting, table shading/borders/alignment/margins/text direction, table-style edits and locked table styles; recipient-list commands no longer rewrite merge-field results in a protected document. No gap row (enforcement fix); estimates unchanged | | 2026-10-17 | trivial | #21: Find in list and "Show only records that will be merged" in Edit Recipient List (#1576) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 657cf932a8..4873f44812 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; 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: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; 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: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -49,6 +49,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested. Positional tabs (`w:ptab`, ECMA-376 §17.3.3.23) go to the left edge, centre or right edge of the margins or indents whatever the tab stops, and to the next line when the text has passed that place (#2367, `layout::tests::right_positional_tab_ends_text_on_the_right_margin` and the three after it); not compared with Word on lines narrowed by floating objects or right-to-left paragraphs | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | | Drop caps (dropped and in margin, lines, distance, font; `w:framePr w:dropCap`) | ✅ | 2026-10-17 | — | +| Caret, clicks and selection inside a ligature (fi, ffi, lam-alef) | ✅ | The glyph's advance is shared evenly among the letters' grapheme clusters, mirrored in right-to-left text; a grapheme cluster (a letter with a combining mark, an emoji ZWJ sequence, an Indic conjunct) is never split. `layout::tests_ligature`, `engine::tests_typing::right_arrow_moves_through_a_ligature_one_letter_at_a_time` (#1974); observed in Word for Mac 16.113.4 | — | | All Caps and Small Caps: the full capital of each letter, so ß draws as SS (ff as FF); the capitals drawn for one character are one cluster, so the caret, selection, hit testing and line breaking treat ß as one character | ✅ | `layout::tests::caps_draw_sharp_s_as_two_capitals_in_one_cluster` (#1581). The PDF text layer still reads the document's lowercase letters until #1473 | — | | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet. | 1–2 | @@ -250,6 +251,7 @@ Page-relative footer tables keep their physical wrap bounds without adding to fo | 2026-10-17 | trivial | Footer page tables preserve text height and wrap intersecting body lines | | 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position | | 2026-10-16 | trivial | Synthetic regression confirms a fitting exact-height row containing overflowing pictures moves to the next page whole. Existing runtime behavior is unchanged. | +| 2026-10-15 | trivial | The caret, clicks and selection edges fall between the letters of a ligature (#1974) | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 898f36fb6c..d4948067e1 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-18 · **Change:** 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-18 · **Change:** 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 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -27,12 +27,12 @@ Document title opens Rename Document; the account badge opens Options › Genera | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **56** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients (Filter and Sort, #778; Find in list and a merged-only view, #1576), Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | -| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments, 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 | +| 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, 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 | | Keyboard shortcuts | **152** commands bound in `CommandSpec::key`, 172 key combinations (counted from the registry on Linux, 2026-10-13); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, Shift+F11 (#339), Next Field on Alt+F1 and Alt+Shift+F1 (Word's other keys for it) since F11 went to full screen (#980; GNOME and KDE may keep Alt+F1 for their own overview or application menu, and then Next Field has no key there; Previous Field keeps Shift+F11); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493); Alt+Q to "Tell me" command search (#819, not on macOS); on macOS, Word for Mac's own keys: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon. The Spike: Ctrl+F3 cuts to it, Ctrl+Shift+F3 inserts it and empties it (⌘F3, ⌘⇧F3 on macOS). Missing: en dash on the numeric keypad Insert Hyperlink has Word's Link to: Existing File or Web Page / Place in This Document (top of the document, headings, bookmarks) (#1234). | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **60** (`Dialog` enum: Discard Recovery, Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Cross-reference (#916); Drop Cap with Position, Font, Lines to drop and Distance from text, opened from Insert › Drop Cap › Drop Cap Options… (#2601); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407); Page Setup has a Paper size list and custom width and height, opened from Layout › Size › More Paper Sizes… (#1433); every dialog's close control is a box at the title bar's right end, the full title-bar height and 1.6× as wide, all of it clickable, red with a white ✕ on hover as a Windows caption button (#1885); Split Cells (columns, rows, merge before split; defaults follow the selection as in Word: twice its columns, its rows, Merge offered for several cells or one merged across columns; a merged cell splits into 1 to its height rows, a number that doesn't divide its height refused with a divisor message, more than its height with the range message; 1 × 1 changes nothing; parts split across columns are equal and other rows keep their grid lines) and Convert Text to Table (live column count, read-only rows; Initial column width Auto or 0 to 22 in, AutoFit to contents/window; Paragraphs/Commas/Tabs/Other "-" separators) (#2592); Size, from Picture Format › Size's launcher: height and width in the interface unit (a size typed in any unit, `300 px` too), scale of the original or the opening size, Lock aspect ratio, the original size with its pixels and Reset (#2573)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | -| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings truncate with an ellipsis instead of expanding the pane, #759), 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 | +| 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 truncate with an ellipsis instead of expanding the pane, #759), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | +| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714) | | 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% | — | @@ -74,6 +74,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-18 | trivial | Long navigation headings wrap onto further lines inside the pane instead of ending in an ellipsis; the row grows with the text and the pane still resizes (#759, #3015) | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-16 | trivial | Dragging the vertical scrollbar shows a tip beside the pointer with the page at the top of the window ("Page 7 of 10"); nothing said where the drag would land (#2551) | | 2026-10-17 | trivial | Recipient List dialog: Find in list box with a match count, "Show only records that will be merged", striped rows; hidden rows keep their record for edit and remove (#1576) |