diff --git a/ROADMAP.md b/ROADMAP.md index eaf14995b3..a1fbd1da05 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **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 @@ -183,6 +183,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. - 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. +- 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. @@ -243,6 +244,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-24: A line touching a floating table at its bottom edge keeps clear of the table (#3977), in both row-span selection and word skipping. Shapes keep their strict overlap rule. Original Source Sans 3 boundary and narrow-strip controls cover modes 11/12/14/15; the expected Word distinction is cyril265's Word for Mac 16.114.1 observation. - 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document. - 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs. +- 2026-10-25: Track Changes: when the heading AutoFormat removes tabs the same author typed, or the border-line AutoFormat puts a border on the first paragraph of a story, the formatting change is recorded under the author making it. Another author's earlier formatting change on that paragraph is replaced, so the change is no longer credited to that author and Reject All brings back the paragraph as that author left it. - 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. - 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). @@ -743,6 +745,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps ## Revision history +> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. +> > 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. > > 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658). diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs index 9bb08b90e0..7ceeabb37b 100644 --- a/crates/doc/src/link.rs +++ b/crates/doc/src/link.rs @@ -1,5 +1,6 @@ -//! Which hyperlink targets may leave the document: followed by the OS, or written into an -//! exported HTML/Markdown file where a browser would run them. +//! Hyperlink targets: where a `HYPERLINK` field code points, and which targets may leave the +//! document: followed by the OS, or written into an exported HTML/Markdown file where a browser +//! would run them. /// Whether Ctrl/⌘+click may hand a document's hyperlink to the system: web and email links only, /// so a document can't launch `file:` paths, programs or custom-scheme handlers. Exact: a leading @@ -39,10 +40,121 @@ fn scheme(url: &str) -> Option { (first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).then(|| s.to_ascii_lowercase()) } +/// Where a `HYPERLINK` field code points, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) +/// or `#bookmark`. None for any other field, or one with no target. +pub fn hyperlink_target(instr: &str) -> Option { + // Only a `HYPERLINK` code is split up (a field's code may run to megabytes). + if !instr.trim_start().get(..9).is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) { + return None; + } + let args = field_args(instr); + let mut args = args.iter().peekable(); + if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) { + return None; + } + let is_switch = |(a, quoted): &&(String, bool)| !quoted && a.starts_with('\\'); + let (mut url, mut anchor) = (None, None); + while let Some(arg @ (a, _)) = args.next() { + if !is_switch(&arg) { + url = url.or(Some(a.as_str())); + continue; + } + // `\l` names a bookmark, `\o` a tooltip, `\t` a frame, `\*` `\#` `\@` a format; `\m` and `\n` take none. + let takes = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t" | "\\*" | "\\#" | "\\@"); + let value = takes.then(|| args.next_if(|v| !is_switch(v))).flatten(); + if a.eq_ignore_ascii_case("\\l") + && let Some((v, _)) = value + { + anchor = Some(v.as_str()); + } + } + let url = url.map(str::trim).filter(|u| !u.is_empty()); + let anchor = anchor.map(|a| a.trim().trim_start_matches('#')).filter(|a| !a.is_empty()); + match (url, anchor) { + // `\l` replaces a fragment the address already has, as in Word. + (Some(u), Some(a)) => Some(format!("{}#{a}", u.split_once('#').map_or(u, |(base, _)| base))), + (Some(u), None) => Some(u.to_string()), + (None, Some(a)) => Some(format!("#{a}")), + (None, None) => None, + } +} + +/// A field code's words: the field's name, its switches and its arguments, each with whether +/// it was quoted. In quotes a backslash escapes the next character, as in Word. +fn field_args(instr: &str) -> Vec<(String, bool)> { + let mut out = Vec::new(); + let mut chars = instr.chars().peekable(); + while let Some(c) = chars.next() { + if c.is_whitespace() { + continue; + } + let mut word = String::new(); + if c == '"' { + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => word.extend(chars.next()), + _ => word.push(c), + } + } + out.push((word, true)); + } else { + word.push(c); + while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') { + word.push(c); + } + out.push((word, false)); + } + } + out +} + #[cfg(test)] mod tests { use super::*; + /// Every reader's `HYPERLINK` cases: switches with and without values, quotes, escapes, spelling. + #[test] + fn hyperlink_field_codes_name_their_targets() { + for (code, want) in [ + (r#"HYPERLINK "http://example.com" \l "heading""#, Some("http://example.com#heading")), + (r#"HYPERLINK \l "heading""#, Some("#heading")), + (r#"HYPERLINK \l "_Toc1" "#, Some("#_Toc1")), + (r#"Hyperlink "http://example.com/a path""#, Some("http://example.com/a path")), + ("HYPERLINK https://example.org/b", Some("https://example.org/b")), + ("HYPERLINK\t\"http://example.com\"", Some("http://example.com")), + (r#"HYPERLINK"http://example.com""#, Some("http://example.com")), + (r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")), + (r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")), + (r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)), + (r#"HYPERLINK " http://example.com ""#, Some("http://example.com")), + // Switches that take a value, before or after the address, and those that take none. + (r#"HYPERLINK "http://example.com" \o "Help text""#, Some("http://example.com")), + (r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#, Some("https://example.org/")), + (r#"HYPERLINK \l "bm" \o "tip""#, Some("#bm")), + (r#"HYPERLINK \l "Sec" \m"#, Some("#Sec")), + (r#"HYPERLINK \n "http://example.com""#, Some("http://example.com")), + (r#"HYPERLINK \l "x" \* MERGEFORMAT"#, Some("#x")), + (r#"HYPERLINK "http://example.com" \l \o "tip""#, Some("http://example.com")), + (r#"HYPERLINK \l "bm" \l"#, Some("#bm")), + // A bookmark's own `#`, an address that has a fragment, and an empty address. + (r##"HYPERLINK "http://example.com" \l "#top""##, Some("http://example.com#top")), + (r#"HYPERLINK "http://example.com/#a" \l "b""#, Some("http://example.com/#b")), + (r#"HYPERLINK "" \l "x""#, Some("#x")), + // No target, or not a `HYPERLINK` field. + ("HYPERLINK", None), + (r#"HYPERLINK "" \o "tip""#, None), + (r#"HYPERLINKS "https://example.org/""#, None), + (r#"HYPERLINKX "http://example.com""#, None), + (r#"HYPERLINK\l "x""#, None), + ("PAGEREF _Toc1 \\h", None), + ("ab日本語", None), + ("", None), + ] { + assert_eq!(hyperlink_target(code).as_deref(), want, "{code:?}"); + } + } + #[test] fn only_web_and_mail_links_are_followable() { for ok in ["https://example.com/a?b#c", "http://example.com", "HTTPS://EXAMPLE.COM", "mailto:someone@example.com"] { diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs index af289d318e..a5d85012c8 100644 --- a/crates/docbin/src/fmt.rs +++ b/crates/docbin/src/fmt.rs @@ -109,12 +109,13 @@ pub(crate) fn ico(i: u8) -> Option { ICO.get(i as usize).copied() } -/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model -/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX -/// reader maps them. +/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType; 0x02, thick, is in +/// the BRC of Microsoft's Word 97-2007 specification). Types the model doesn't carry (the gap +/// variants, 3-D, inset/outset) draw as single lines, as the DOCX reader maps them. fn brc_style(t: u8) -> BorderStyle { match t { 0x00 => BorderStyle::None, + 0x02 => BorderStyle::Thick, 0x03 => BorderStyle::Double, 0x06 => BorderStyle::Dotted, 0x07 | 0x16 => BorderStyle::Dashed, @@ -125,15 +126,15 @@ fn brc_style(t: u8) -> BorderStyle { } } -/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2; -/// 1-point steps for page borders, which never reach a paragraph property). +/// The border width in points: `dptLineWidth` in 1/8-point steps from 2 to 96, as DOCX `w:sz` +/// reads (1-point steps for page borders, which never reach a paragraph property). fn brc_width(t: u8, dpt: u8) -> f32 { - if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 } + if t >= 0x40 { f32::from(dpt) } else { (f32::from(dpt.max(2)) / 8.0).min(12.0) } } /// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a /// truncated operand is ignored. -fn brc80(op: &[u8]) -> Option { +pub(crate) fn brc80(op: &[u8]) -> Option { let [dpt, ty, ico_index, tail, ..] = op else { return None }; if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] { return Some(Border::default()); diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 8742c6f639..856a307c24 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -26,6 +26,7 @@ mod table; use std::io::{Read, Seek}; +use wordcraft_doc::link::hyperlink_target; use wordcraft_doc::para::InlineObject; use wordcraft_doc::para::NoteKind; use wordcraft_doc::para::Paragraph; @@ -722,85 +723,6 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) { pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() }); } -/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target -/// (`#bookmark` for internal links); `None` when not a hyperlink. -fn hyperlink_target(instr: &str) -> Option { - let t = instr.trim(); - let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) { - t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim() - } else { - return None; - }; - if rest.is_empty() { - return None; - } - - let mut url: Option = None; - let mut local: Option = None; - let mut switch: Option<&str> = None; - - let mut i = 0; - let bytes = rest.as_bytes(); - while i < bytes.len() { - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } - if i >= bytes.len() { - break; - } - if bytes[i] == b'\\' { - let start = i; - i += 1; - while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') { - i += 1; - } - switch = Some(rest.get(start..i).unwrap_or("")); - continue; - } - let arg = if bytes[i] == b'"' { - i += 1; - let start = i; - while i < bytes.len() && bytes[i] != b'"' { - i += 1; - } - let val = rest.get(start..i).unwrap_or(""); - if i < bytes.len() && bytes[i] == b'"' { - i += 1; - } - val.to_string() - } else { - let start = i; - while i < bytes.len() && !bytes[i].is_ascii_whitespace() { - i += 1; - } - rest.get(start..i).unwrap_or("").to_string() - }; - let clean_arg = arg.replace("%22", "\""); - match switch.take() { - Some("\\l") => local = Some(clean_arg), - Some(_) => {} - None => { - if url.is_none() { - url = Some(clean_arg); - } - } - } - } - - match (url, local) { - (Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => { - let frag = l.trim_start_matches('#'); - if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) } - } - (Some(u), _) if !u.is_empty() => Some(u), - (None, Some(l)) if !l.is_empty() => { - let frag = l.trim_start_matches('#'); - Some(format!("#{frag}")) - } - _ => None, - } -} - /// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream. fn pic_location(chpx: &[u8]) -> Option { for prl in sprm::iter(chpx) { diff --git a/crates/docbin/src/table.rs b/crates/docbin/src/table.rs index 8604140a20..3b0daf6395 100644 --- a/crates/docbin/src/table.rs +++ b/crates/docbin/src/table.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use wordcraft_doc::Rgb; use wordcraft_doc::para::Paragraph; -use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; +use wordcraft_doc::props::{Align, Borders, CellProps, HeightRule, RowProps, TableProps, VAlign, VMerge}; use wordcraft_doc::table::{Cell, Row, Table}; use wordcraft_doc::{Block, Blocks, para_block}; @@ -458,32 +458,12 @@ fn def_table_edges(operand: &[u8]) -> Vec { } /// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top, -/// left, bottom, right, inside-horizontal, inside-vertical. +/// left, bottom, right, inside-horizontal, inside-vertical, read as paragraph borders are. fn table_borders(operand: &[u8]) -> Option { - let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(brc80); + let b = |i: usize| operand.get(1 + i * 4..1 + i * 4 + 4).and_then(fmt::brc80); Some(Borders { top: b(0), left: b(1), bottom: b(2), right: b(3), between: b(4), inside_v: b(5) }) } -/// One Brc80: width in 1/8 pt, type, palette colour, space/flags byte. -fn brc80(b: &[u8]) -> Option { - let [w, ty, ico, flags] = b else { return None }; - let style = match ty { - 0x00 => BorderStyle::None, - 0x02 | 0x15 => BorderStyle::Thick, - 0x03 => BorderStyle::Double, - 0x04 | 0x05 => BorderStyle::Dotted, - 0x06 | 0x07 => BorderStyle::Dashed, - 0x08 | 0x09 => BorderStyle::DotDash, - 0x0A | 0x18 => BorderStyle::Wave, - 0x11 => BorderStyle::Triple, - _ => BorderStyle::Single, - }; - if *ty == 0 && *w == 0 { - return None; - } - Some(Border { style, width: (*w as f32 / 8.0).clamp(0.25, 12.0), color: fmt::ico(*ico), space: (flags & 0x1F) as f32 }) -} - /// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns /// show the foreground; clear (0) and the other patterns show the background (Word writes a /// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill. diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 6354316069..b251cadbb5 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1209,6 +1209,53 @@ fn table_merges_borders_shading() { assert_eq!(top.width, 0.5); } +/// Table borders read the BrcType byte as paragraph borders do; a nil Brc80 is no border. +#[test] +fn table_borders_read_border_types_as_paragraphs_do() { + use wordcraft_doc::props::BorderStyle as S; + let text = "a\u{7}\u{7}tail\r"; + let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]); + let row_end = { + let mut v = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(&[0, 2880])].concat(); + v.extend_from_slice(&0xD605u16.to_le_bytes()); + v.push(0x18); + // Dotted, triple, double wave, nil, 3-D emboss 255/8 pt wide, all zero. + for brc in [[8, 0x06, 1, 0], [8, 0x0A, 1, 0], [8, 0x15, 1, 0], [0xFF; 4], [0xFF, 0x18, 1, 0], [0, 0, 0, 0]] { + v.extend_from_slice(&brc); + } + v + }; + let spec = StructSpec { + text, + header_stories: vec![], + sections: vec![(text.chars().count() as u32, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, in_tbl), (2, 0, row_end), (3, 0, grpprl(&[(0x6424, &[0xFF, 0x01, 1, 0])]))], + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table"); + let b = tbl.props.borders.expect("table borders"); + let seen = [b.top, b.left, b.bottom, b.right, b.between, b.inside_v].map(|s| s.map(|s| (s.style, s.is_visible(), s.width))); + let want = [ + (S::Dotted, true, 1.0), + (S::Triple, true, 1.0), + (S::Wave, true, 1.0), + (S::None, false, 0.0), + (S::Single, true, 12.0), + (S::None, false, 0.25), + ]; + assert_eq!(seen, want.map(Some)); + // A paragraph border is at most 12 points too, as a table border and a DOCX `w:sz` are. + let tail = doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.text == "tail").expect("tail paragraph"); + assert_eq!(tail.props.borders.and_then(|b| b.top).map(|t| t.width), Some(12.0)); +} + // --------------------------------------------------------------------------------------------- // D3: lists (PlfLst + PlfLfo) and the combined document. @@ -1469,6 +1516,16 @@ fn hyperlink_target_needs_the_whole_field_name() { assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into())); } +/// A bare `\m` or `\n` keeps the address after it; escapes, an empty address and `%22` read as in DOCX. +#[test] +fn hyperlink_target_keeps_the_address_after_a_bare_switch() { + assert_eq!(super::hyperlink_target(r#"HYPERLINK \m "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK \n "http://example.com""#), Some("http://example.com".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "C:\\Docs\\f.doc""#), Some(r"C:\Docs\f.doc".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "" \l "bm""#), Some("#bm".into())); + assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com/a%22b""#), Some("http://example.com/a%22b".into())); +} + #[test] fn bookmarks_become_objects() { let text = "Hello world\r"; @@ -2482,17 +2539,21 @@ fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() { } /// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80 -/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). +/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour). A thick +/// border (type 0x02) stays thick in both, as tables read it too. #[test] fn paragraph_borders_read_from_both_forms() { + use wordcraft_doc::props::BorderStyle as S; let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text + let thick80 = [8u8, 0x02, 0x06, 0x01]; let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2 + let thick = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x02, 0x02, 0x00]; let text = "boxed\r\r"; let spec = StructSpec { text, papx_entries: vec![ - (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), - (6, 0, grpprl(&[(0xC64E, &brc)])), + (0, 0, grpprl(&[(0x6424, &b80), (0x6425, &thick80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])), + (6, 0, grpprl(&[(0xC64E, &brc), (0xC64F, &thick)])), ], ..structured_spec(text, text.chars().count() as u32) }; @@ -2500,13 +2561,16 @@ fn paragraph_borders_read_from_both_forms() { let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph"); let b = p0.props.borders.expect("borders"); let top = b.top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0)); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 1.0)); assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); - assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some()); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "Brc80 thick"); + assert!(b.bottom.is_some() && b.right.is_some() && b.between.is_some()); let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph"); - let top = p1.props.borders.expect("borders").top.expect("top border"); - assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0)); + let b = p1.props.borders.expect("borders"); + let top = b.top.expect("top border"); + assert_eq!((top.style, top.width, top.space), (S::Single, 1.0, 2.0)); assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); + assert_eq!(b.left.map(|l| (l.style, l.width)), Some((S::Thick, 1.0)), "BrcOperand thick"); } /// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb` diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 6f2f66b577..c7156dbdbb 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -172,8 +172,9 @@ impl PropCtx { } /// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour - /// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as - /// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one. + /// scheme mapping, with its `tint` or else its `shade` (hex bytes) applied to the HSL + /// luminance as Word does: given both, Word applies the tint alone (MS-OI29500 17.3.2.6 b, + /// 17.4.32 a). When given it supersedes the fixed colour; `None` leaves the fixed one. fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option { let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i); let slot = match e.attr(attr)? { @@ -199,8 +200,7 @@ impl PropCtx { let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0); if let Some(t) = byte(tint) { c = adjust_lum(c, |l| l * t + (1.0 - t)); - } - if let Some(s) = byte(shade) { + } else if let Some(s) = byte(shade) { c = adjust_lum(c, |l| l * s); } Some(c) @@ -208,6 +208,10 @@ impl PropCtx { /// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour. pub fn shd_fill(&self, e: &El) -> Option { + // `nil` counts as no shading, whatever fill it carries (MS-OI29500 17.18.78). + if e.attr("w:val") == Some("nil") { + return None; + } if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") { return Some(c); } @@ -283,7 +287,7 @@ impl PropCtx { c.underline_color = self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse)); } - "w:bdr" => c.border = Some(border(k)), + "w:bdr" => c.border = Some(self.border(k)), "w:shd" => c.shading = self.shd_fill(k), "w:vertAlign" => { c.vert_align = k.attr("w:val").map(|v| match v { @@ -362,7 +366,7 @@ impl PropCtx { (None, None) => {} } } - "w:pBdr" => p.borders = Some(borders(k)), + "w:pBdr" => p.borders = Some(self.borders(k)), "w:shd" => p.shading = self.shd_fill(k), "w:tabs" => { let tabs: Vec = k.children("w:tab").take(64).filter_map(tab_stop).collect(); @@ -477,29 +481,32 @@ fn shd_fill(e: &El) -> Option { None } -pub fn border(e: &El) -> Border { - let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single")); - let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0); - let color = e.attr("w:color").and_then(Rgb::parse); - let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0); - Border { style, width, color, space } -} +impl PropCtx { + /// One border (`CT_Border`); its theme colour supersedes `w:color`, as for text. + pub fn border(&self, e: &El) -> Border { + let style = BorderStyle::from_ooxml(e.attr("w:val").unwrap_or("single")); + let width = e.attr("w:sz").and_then(|v| measure(v, 8.0)).unwrap_or(0.5).clamp(0.0, 12.0); + let color = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| e.attr("w:color").and_then(Rgb::parse)); + let space = e.attr("w:space").and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 31.0); + Border { style, width, color, space } + } -/// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`. -pub fn borders(e: &El) -> Borders { - let mut b = Borders::default(); - for k in e.els() { - match k.local() { - "top" => b.top = Some(border(k)), - "left" | "start" => b.left = Some(border(k)), - "bottom" => b.bottom = Some(border(k)), - "right" | "end" => b.right = Some(border(k)), - "between" | "insideH" => b.between = Some(border(k)), - "insideV" => b.inside_v = Some(border(k)), - _ => {} + /// `w:pBdr`, `w:tblBorders`, `w:tcBorders`, `w:pgBorders`. + pub fn borders(&self, e: &El) -> Borders { + let mut b = Borders::default(); + for k in e.els() { + match k.local() { + "top" => b.top = Some(self.border(k)), + "left" | "start" => b.left = Some(self.border(k)), + "bottom" => b.bottom = Some(self.border(k)), + "right" | "end" => b.right = Some(self.border(k)), + "between" | "insideH" => b.between = Some(self.border(k)), + "insideV" => b.inside_v = Some(self.border(k)), + _ => {} + } } + b } - b } /// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right]. @@ -533,7 +540,7 @@ impl PropCtx { } "w:jc" => t.align = k.attr("w:val").map(align), "w:tblInd" => t.indent = tw(k, "w:w"), - "w:tblBorders" => t.borders = Some(borders(k)), + "w:tblBorders" => t.borders = Some(self.borders(k)), "w:shd" => t.shading = self.shd_fill(k), "w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"), "w:bidiVisual" => t.bidi_visual = on_off(k), @@ -639,7 +646,7 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { "w:gridSpan" => c.span = k.attr("w:val").and_then(u32_of).unwrap_or(1).clamp(1, 63), "w:hMerge" => hcont = k.attr("w:val") != Some("restart"), "w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue }, - "w:tcBorders" => c.borders = Some(borders(k)), + "w:tcBorders" => c.borders = Some(pc.borders(k)), "w:shd" => c.shading = pc.shd_fill(k), "w:noWrap" => c.no_wrap = on_off(k), "w:hideMark" => c.hide_mark = on_off(k), @@ -676,7 +683,7 @@ pub struct HfRef { } /// `w:sectPr` (header/footer references are returned for the caller to resolve). -pub fn sectpr(e: &El) -> (SectionProps, Vec) { +pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec) { let mut s = SectionProps::default(); let mut refs = Vec::new(); for k in e.els() { @@ -781,7 +788,7 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { _ => PageVAlign::Top, } } - "w:pgBorders" => s.page_borders = Some(borders(k)), + "w:pgBorders" => s.page_borders = Some(pc.borders(k)), "w:docGrid" => { let mut g = DocGrid::default(); if let Some(t) = k.attr("w:type").and_then(DocGridType::from_ooxml) { diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index 13f6f3e153..64ffee3522 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -289,10 +289,10 @@ impl Reader<'_> { } pub fn read_section(&mut self, s: &El, rels: &Rels) -> wordcraft_doc::SectionProps { - let (mut sp, refs) = sectpr(s); + let (mut sp, refs) = sectpr(&self.pc, s); // §17.13.5.32: the section's properties before a tracked change (no header references). if let Some(ch) = s.child("w:sectPrChange") { - let old = ch.child("w:sectPr").map(|o| sectpr(o).0).unwrap_or_default(); + let old = ch.child("w:sectPr").map(|o| sectpr(&self.pc, o).0).unwrap_or_default(); sp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); } for r in refs { diff --git a/crates/docx/src/read/story/fields.rs b/crates/docx/src/read/story/fields.rs index 1847d3e8ad..68b29f200e 100644 --- a/crates/docx/src/read/story/fields.rs +++ b/crates/docx/src/read/story/fields.rs @@ -1,4 +1,5 @@ use super::{CharProps, FieldState, InlineObject, Item, MAX_FIELD_DEPTH, PB, Phase, Reader, StoryCtx}; +use wordcraft_doc::link::hyperlink_target; /// Enclosed characters (`eq \o\ac(○,字)`), with their style from the code's font sizes: Word /// enlarges the symbol (bigger than the text, which keeps the field's size) or shrinks the text @@ -24,69 +25,6 @@ fn is_range_instr(instr: &str) -> bool { crate::read::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) } -/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or -/// `#bookmark`. None for any other field, or one with no target. -fn hyperlink_target(instr: &str) -> Option { - // Only a `HYPERLINK` code is split up (a field's code may run to megabytes). - if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) { - return None; - } - let args = field_args(instr); - let mut args = args.iter(); - if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) { - return None; - } - let (mut url, mut anchor) = (None, None); - while let Some((a, quoted)) = args.next() { - if *quoted || !a.starts_with('\\') { - url = url.or(Some(a.as_str())); - continue; - } - // `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none. - let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten(); - if a.eq_ignore_ascii_case("\\l") { - anchor = value.map(|(v, _)| v.as_str()); - } - } - let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty())); - match (url, anchor) { - (Some(u), Some(a)) => Some(format!("{u}#{a}")), - (Some(u), None) => Some(u.to_string()), - (None, Some(a)) => Some(format!("#{a}")), - (None, None) => None, - } -} - -/// A field code's words: the field's name, its switches and its arguments, each with whether -/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash. -fn field_args(instr: &str) -> Vec<(String, bool)> { - let mut out = Vec::new(); - let mut chars = instr.chars().peekable(); - while let Some(c) = chars.next() { - if c.is_whitespace() { - continue; - } - let mut word = String::new(); - if c == '"' { - while let Some(c) = chars.next() { - match c { - '"' => break, - '\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()), - _ => word.push(c), - } - } - out.push((word, true)); - } else { - word.push(c); - while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') { - word.push(c); - } - out.push((word, false)); - } - } - out -} - impl Reader<'_> { // ---- fields ---- diff --git a/crates/docx/src/read/styles.rs b/crates/docx/src/read/styles.rs index cf9bccf51b..6e8533e07c 100644 --- a/crates/docx/src/read/styles.rs +++ b/crates/docx/src/read/styles.rs @@ -196,7 +196,7 @@ impl Reader<'_> { let mut t = TableStyleParts::default(); if let Some(tp) = s.child("w:tblPr") { if let Some(b) = tp.child("w:tblBorders") { - t.borders = Some(props::borders(b)); + t.borders = Some(self.pc.borders(b)); } t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e)); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); @@ -210,7 +210,7 @@ impl Reader<'_> { let f = PartFormat { chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(), fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)), - borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders), + borders: tc.and_then(|p| p.child("w:tcBorders")).map(|b| self.pc.borders(b)), align: ppr.as_ref().and_then(|p| p.align), v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v { "center" => VAlign::Center, @@ -229,7 +229,7 @@ impl Reader<'_> { t.fill = f.fill; } if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) { - t.borders = Some(props::borders(b)); + t.borders = Some(self.pc.borders(b)); } if f.v_align.is_some() { t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() }); diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 82d506793e..b7ef00c704 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -2169,6 +2169,68 @@ fn theme_colour_wins_over_the_fixed_colour() { assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}"); } +/// `w:shd w:val="nil"` paints nothing despite its fill: a CV template's red cell showed over the name. +#[test] +fn nil_shading_paints_no_fill() { + let body = r#" + + + +x"#; + let d = read_body(body); + let Block::Table(t) = &*d.body[0] else { panic!("table first") }; + assert_eq!(t.rows[0].cells[0].props.shading, None); + assert_eq!(t.rows[0].cells[1].props.shading, Some(Rgb(0xC0, 0, 0))); + let p = paras(&d); + assert_eq!(p[0].props.shading, None); + assert_eq!(p[0].props_of_char(0).shading, None); +} + +/// A border's theme colour wins over its stale `w:color`: a template's white rule showed as black. +#[test] +fn border_theme_colour_wins_over_the_fixed_colour() { + let body = r#" +x + + +"#; + let styles = r#""#; + let d = themed_docx(body, styles, None); + let p = paras(&d); + let pb = p[0].props.borders.unwrap(); + assert_eq!(pb.top.and_then(|b| b.color), Some(Rgb(0xFF, 0xFF, 0xFF))); + assert_eq!(pb.left.and_then(|b| b.color), Some(Rgb(0xFF, 0, 0)), "no theme colour keeps the fixed one"); + assert_eq!(p[0].props_of_char(0).border.and_then(|b| b.color), Some(Rgb(0xED, 0x7D, 0x31))); + let Block::Table(t) = &*d.body[1] else { panic!("table second") }; + assert_eq!(t.props.borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x72, 0xC4))); + let cell = t.rows[0].cells[0].props.borders.and_then(|b| b.left).and_then(|b| b.color); + assert!(near(cell.map(TextColor::Rgb), Rgb(0x53, 0x81, 0x35)), "{cell:?}"); + assert_eq!(d.last_section.page_borders.and_then(|b| b.top).and_then(|b| b.color), Some(Rgb(0x44, 0x54, 0x6A))); + let st = d.styles.get("T").and_then(|s| s.table.as_ref()).and_then(|t| t.borders); + assert_eq!(st.and_then(|b| b.bottom).and_then(|b| b.color), Some(Rgb(0xA5, 0xA5, 0xA5))); +} + +/// Given both `themeTint` and `themeShade`, Word applies the tint alone, for text colours, +/// shading and borders (MS-OI29500 17.3.2.6 b, 17.4.32 a; MS-OE376 2.3.2.3 a). +#[test] +fn theme_tint_wins_over_theme_shade() { + let both = r#"w:themeColor="accent1" w:themeTint="99" w:themeShade="7F""#; + let body = format!( + r#"a"# + ); + let d = themed_docx(&body, "", None); + let p = paras(&d); + let ch = p[0].props_of_char(0); + // accent1 4472C4 lightened by tint 99 alone, as Word writes it into w:val. + let tinted = Rgb(0x8E, 0xAA, 0xDB); + assert!(near(ch.color, tinted), "text colour {:?}", ch.color); + let run_border = ch.border.and_then(|b| b.color).map(TextColor::Rgb); + assert!(near(run_border, tinted), "text border {run_border:?}"); + let para_border = p[0].props.borders.and_then(|b| b.top).and_then(|b| b.color).map(TextColor::Rgb); + assert!(near(para_border, tinted), "paragraph border {para_border:?}"); + assert!(near(ch.shading.map(TextColor::Rgb), tinted), "shading {:?}", ch.shading); +} + #[test] fn theme_colours_follow_the_colour_scheme_mapping() { // Styles are read before settings; the mapping still applies to them. diff --git a/crates/engine/src/cmd/fmt_revisions.rs b/crates/engine/src/cmd/fmt_revisions.rs index 97c1458efe..330bdfd93f 100644 --- a/crates/engine/src/cmd/fmt_revisions.rs +++ b/crates/engine/src/cmd/fmt_revisions.rs @@ -16,7 +16,7 @@ use wordcraft_doc::props::VMerge; use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps}; use wordcraft_doc::section::SectionProps; use wordcraft_doc::table::{Cell, Row, Table}; -use wordcraft_doc::{Block, Blocks, Document, Paragraph, Revision, RevisionKind}; +use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, Revision, RevisionKind, StoryRef}; use crate::Session; @@ -212,6 +212,25 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) { } } +/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also +/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the +/// command. As in [`record`], the author's own earlier change keeps the formatting before it and +/// another author's is superseded. +pub(crate) fn record_para_props(s: &mut Session, story: StoryRef, path: &Path, before: &ParaProps) -> Result<(), crate::CmdError> { + if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting { + return Ok(()); + } + let Some(after) = s.doc.para(story, path).map(|p| p.props.clone()) else { return Ok(()) }; + let revisions = s.doc.revisions.clone(); + let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &revisions, revisions: &mut s.doc.revisions, rid: None }; + if let Some(Replacement(want)) = r.change(before, &after, ParaProps::formatting, |p| &p.fmt_change) { + let p = s.doc.para_mut(story, path)?; + p.props.fmt_change = want; + p.touch(); + } + Ok(()) +} + /// Compare Documents: record how paragraph `new` (the revised version, same text) is formatted /// differently from `old` as tracked formatting changes by `author`. pub(crate) fn compare_formatting(old: &Paragraph, new: &mut Paragraph, revisions: &mut Vec, author: &str, date: &str) { diff --git a/crates/engine/src/cmd/text/autoformat.rs b/crates/engine/src/cmd/text/autoformat.rs index feb0f0a9af..65b971e39b 100644 --- a/crates/engine/src/cmd/text/autoformat.rs +++ b/crates/engine/src/cmd/text/autoformat.rs @@ -315,10 +315,11 @@ pub(super) fn heading_line(s: &Session, at: &Pos) -> Option { return None; } let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None }; - // Normal and outside a list, also one the style sheet gives (a numbered Normal style). + // Normal and outside a list, also one the style sheet gives (a numbered Normal style), with + // no outline level: resolving drops the body-text level 9 and above. let plain = |p: &wordcraft_doc::Paragraph| { let r = s.doc.styles.resolve_para(&p.props); - p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9) + p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none() }; let text = prev.text.trim_start_matches('\t'); let tabs = prev.text.len() - text.len(); @@ -357,14 +358,8 @@ pub(super) fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result crate::cmd::para::set_heading(s, at.story, &prev, Some(level))?; // Removed tabs change the text, and the formatting comparison after the command only looks // at paragraphs that keep theirs, so the style change is recorded here. - if s.doc.settings.track_changes - && s.prefs.markup.track_formatting - && s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none()) - { - let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format); - let p = s.doc.para_mut(at.story, &prev)?; - p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting()); - p.touch(); + if s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text) { + crate::cmd::fmt_revisions::record_para_props(s, at.story, &prev, &before.props)?; } s.sel = Selection::caret(at.clone()); Ok(()) @@ -398,14 +393,8 @@ pub(super) fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Resu p.touch(); // The split changes the block count, and the formatting comparison after the command // skips a container whose block count changed, so the border is recorded here. - if s.doc.settings.track_changes - && s.prefs.markup.track_formatting - && let Some(before) = before.filter(|b| b.fmt_change.is_none()) - { - let rev = crate::cmd::new_revision(s, wordcraft_doc::RevisionKind::Format); - let p = s.doc.para_mut(at.story, &at.path)?; - p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting()); - p.touch(); + if let Some(before) = before { + crate::cmd::fmt_revisions::record_para_props(s, at.story, &at.path, &before)?; } s.sel = Selection::caret(new); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c7f5f922fd..61dc7ba181 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -177,11 +177,11 @@ fn full_screen_available(_: &Session) -> Option<&'static str> { fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { if read && !s.view.read_mode { - // Reading controls are session-only, and reset on each entry to the view. + // Width, size and spacing reset on each entry. Page Color follows the saved preference. s.view.read_column_width = None; s.view.read_zoom = 1.0; s.view.read_line_spacing = None; - s.view.read_page_color = ReadPageColor::None; + s.view.read_page_color = s.prefs.read_page_color; } s.view.mode = m; s.view.read_mode = read; @@ -256,6 +256,7 @@ fn read_page_color(s: &mut Session, v: &Value) -> CmdResult { _ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())), }; s.view.read_page_color = color; + s.prefs.read_page_color = color; Ok(json!({"pageColor": color})) } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 2a21cc5c53..cb645ce1d6 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -49,6 +49,9 @@ pub struct Prefs { /// Check spelling as you type (File › Options › Proofing). The live value is /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). pub check_spelling: bool, + /// Read Mode's Page Color. The live value is `ViewState::read_page_color`. + #[serde(deserialize_with = "lenient_read_page_color")] + pub read_page_color: crate::cmd::view::ReadPageColor, pub ignore_uppercase: bool, pub ignore_numbers: bool, pub ignore_internet: bool, @@ -85,6 +88,7 @@ impl Default for Prefs { marks: Default::default(), mark_grammar: true, check_spelling: true, + read_page_color: Default::default(), ignore_uppercase: true, ignore_numbers: true, ignore_internet: true, @@ -112,3 +116,30 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::Prefs; + use crate::cmd::view::ReadPageColor; + + #[test] + fn read_page_color_loads_from_old_and_damaged_prefs() { + let old: Prefs = serde_json::from_str(r#"{"checkSpelling":false}"#).unwrap(); + assert_eq!(old.read_page_color, ReadPageColor::None); + assert!(!old.check_spelling); + for bad in [r#"{"readPageColor":"unknown","checkSpelling":false}"#, r#"{"readPageColor":42,"checkSpelling":false}"#] { + let prefs: Prefs = serde_json::from_str(bad).unwrap(); + assert_eq!(prefs.read_page_color, ReadPageColor::None); + assert!(!prefs.check_spelling); + } + let saved: Prefs = serde_json::from_str(r#"{"readPageColor":"sepia"}"#).unwrap(); + assert_eq!(saved.read_page_color, ReadPageColor::Sepia); + assert_eq!(serde_json::to_value(saved).unwrap()["readPageColor"], "sepia"); + } +} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 851cf864e8..4f7ce2f11e 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -385,6 +385,7 @@ impl Session { let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; let mut d = self.others.swap_remove(at); self.swap_doc(&mut d); + self.view.read_page_color = self.prefs.read_page_color; self.others.push(d); Ok(()) } @@ -459,6 +460,7 @@ impl Session { doc.ensure_nonempty(); let mut view = self.view.clone(); view.outline.collapsed.clear(); + view.read_page_color = self.prefs.read_page_color; let mut d = DocState::new(doc, untitled, view); let id = d.document_id; self.order.push(id); diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 50606e2101..ef08e94576 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -31,6 +31,7 @@ impl Session { self.hf_return = None; self.go_back_step = 0; self.view.outline.collapsed.clear(); + self.view.read_page_color = self.prefs.read_page_color; // A copy the replaced document kept stays in the recovery list. #[cfg(not(target_arch = "wasm32"))] { diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 48c991eae3..d9645fdf3a 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -135,6 +135,7 @@ impl Session { let current = document_id == self.document_id && rev == self.rev; self.sel = sel; self.view = view; + self.view.read_page_color = self.prefs.read_page_color; self.pending = pending; self.goal_x = goal_x; self.page_hint = page_hint; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a9ae6aa195..8834e4ae30 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6560,7 +6560,7 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() { } /// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the -/// document alone, is not saved, and starts at None on each entry to Read Mode. +/// document alone, starts at None and is kept between documents and runs as a preference. #[test] fn reading_page_color_is_a_transient_view_setting() { use crate::cmd::view::ReadPageColor; @@ -6571,18 +6571,46 @@ fn reading_page_color_is_a_transient_view_setting() { assert_eq!(s.view.read_page_color, ReadPageColor::None); assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"})); assert_eq!(s.view.read_page_color, ReadPageColor::Sepia); + assert_eq!(s.prefs.read_page_color, ReadPageColor::Sepia); run(&mut s, "view.readPageColor", json!({"value": "inverse"})); assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); + assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse); for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] { assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}"); } assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color"); + assert_eq!(s.prefs.read_page_color, ReadPageColor::Inverse); assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none()); assert_eq!(s.doc, original); assert_eq!(s.dirty, dirty); + // The color stays for the next entry to Read Mode, while the other reading controls reset. + run(&mut s, "view.readTextSize", json!({"value": 150})); run(&mut s, "view.printLayout", json!({})); run(&mut s, "view.readMode", json!({})); - assert_eq!(s.view.read_page_color, ReadPageColor::None); + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); + assert_eq!(s.view.read_zoom, 1.0); +} + +#[test] +fn reading_page_color_follows_saved_prefs_and_new_documents() { + use crate::cmd::view::ReadPageColor; + let mut s = s(); + s.prefs.read_page_color = ReadPageColor::Sepia; + run(&mut s, "view.readMode", json!({})); + assert_eq!(s.view.read_page_color, ReadPageColor::Sepia); + let first = s.document_id(); + s.view.read_page_color = ReadPageColor::None; + run(&mut s, "file.new", json!({})); + assert_eq!(s.view.read_page_color, ReadPageColor::Sepia); + run(&mut s, "view.readPageColor", json!({"value": "inverse"})); + s.activate(first).unwrap(); + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); + s.view.read_page_color = ReadPageColor::None; + run(&mut s, "file.open", json!({"path": "reading-color.docx", "data": ""})); + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); + s.view.read_page_color = ReadPageColor::None; + s.set_document(wordcraft_doc::Document::new()); + assert_eq!(s.view.read_page_color, ReadPageColor::Inverse); } /// File › Options › Advanced › Numeral: the display mode for digits (display only). diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index e246c57a0c..178dfdd6a8 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -833,6 +833,28 @@ fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() { assert!(bottom(&s, 0).is_none()); } +#[test] +fn a_tracked_border_line_at_the_top_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers the characters' paragraph; Ben's Enter gives it the border. The formatting + // change is Ben's and holds Ana's centered paragraph without a border. + let mut s = typed("---"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert!(p.props.borders.and_then(|b| b.bottom).is_some()); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.align, c.old.borders.and_then(|b| b.bottom).is_some())), Some((Some(Align::Center), false))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.props.align, p.props.borders.and_then(|b| b.bottom).is_some()), (Some(Align::Center), false)); +} + #[test] fn a_tracked_table_line_keeps_its_characters_as_a_deletion() { let mut s = typed("+--+------+"); @@ -1169,6 +1191,10 @@ fn two_enters_after_a_short_line_make_it_a_heading() { let s = with_heading_styles("One two three four five\n\n"); assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1")); assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]); +} + +#[test] +fn a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is() { // Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style. for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] { let s = with_heading_styles(keys); @@ -1192,9 +1218,20 @@ fn two_enters_after_a_short_line_make_it_a_heading() { } typ(&mut q, "Item\n\n"); assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q)); - // Off by default. + // A line with an outline level stays; the body-text level (9 and above) is no outline level. + for (level, style) in [(0, None), (8, None), (9, Some("Heading1")), (u8::MAX, Some("Heading1"))] { + let mut q = with_heading_styles("Title\n"); + q.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.outline_level = Some(level); + typ(&mut q, "\n"); + assert_eq!(styled(&q).first().and_then(|(_, st)| st.as_deref()), style, "outline level {level}"); + } +} + +#[test] +fn built_in_heading_styles_are_off_by_default() { let s = typed("Introduction\n\n"); - assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]); + let h = |t: &str| (t.to_string(), None); + assert_eq!(styled(&s), [h("Introduction"), h(""), h("")]); } #[test] @@ -1211,6 +1248,18 @@ fn undo_right_after_a_heading_autoformat_keeps_the_enter() { #[test] fn a_tracked_heading_autoformat_can_be_rejected() { + // Enter in the empty paragraph turns a zero-tab line into Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + run(&mut s, "review.rejectAll", json!({})); + assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None))); + // With Track Changes on, the tab is marked deleted and the style change is a formatting // revision, so Reject All brings the line back as it was. let mut s = with_heading_styles("\tMethods\n"); @@ -1244,6 +1293,64 @@ fn a_tracked_heading_autoformat_can_be_rejected() { assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); } +#[test] +fn a_tracked_heading_autoformat_supersedes_another_authors_formatting() { + use wordcraft_doc::props::Align; + // Ana centers a zero-tab line; Ben's Enter makes it Heading 1. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading1"))); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ben")); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + + // Ana centers the line; Ben types a tab before it and makes it Heading 2. Ben's tab is + // removed, and the heading's formatting change is Ben's, holding Ana's centered Normal line. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + s.author = "Ben".into(); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2"))); + let ch = p.props.fmt_change.as_deref(); + let author = ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()); + assert_eq!(author, Some("Ben"), "{:?}", s.doc.revisions); + assert_eq!(ch.map(|c| (c.old.style.as_deref(), c.old.align)), Some((None, Some(Align::Center)))); + // Rejecting it brings back Ana's centered Normal line. + run(&mut s, "review.rejectAll", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + assert_eq!((p.text.as_str(), p.props.style.as_deref(), p.props.align), ("Title", None, Some(Align::Center))); + // The author's own earlier change keeps the formatting before it. + let mut s = with_heading_styles("Title\n"); + run(&mut s, "review.trackChanges", json!({"value": true})); + s.author = "Ana".into(); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}})); + run(&mut s, "para.align", json!({"value": "center"})); + typ(&mut s, "\t"); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + typ(&mut s, "\n"); + let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default(); + let ch = p.props.fmt_change.as_deref(); + assert_eq!(ch.and_then(|c| s.doc.revisions.get(c.rev as usize)).map(|r| r.author.as_str()), Some("Ana")); + assert_eq!(ch.map(|c| c.old.style.as_deref()), Some(None)); + assert!(ch.is_some_and(|c| c.old.align != Some(Align::Center)), "{ch:?}"); +} + #[test] fn a_locked_heading_style_is_not_applied_as_you_type() { let mut s = s(); @@ -1255,6 +1362,25 @@ fn a_locked_heading_style_is_not_applied_as_you_type() { assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}"); } +#[test] +fn enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph() { + // Read only, with an editable region from the end of "Title" to the start of "End.": Enter + // twice in the region breaks the paragraph and leaves the protected line Normal text, and a + // border line typed there goes under the paragraph above it, inside the region. + let mut s = Session::new(wordcraft_doc::Document::from_text("Title\nX\nEnd.")); + run(&mut s, "tools.autocorrect", json!({"headingStyles": true})); + s.sel = crate::Selection { anchor: Pos::body(0, 5), focus: Pos::body(2, 0) }; + run(&mut s, "review.restrict", json!({"exception": "add"})); + run(&mut s, "review.restrict", json!({"mode": "readOnly"})); + run(&mut s, "select.text", json!({"text": "X"})); + typ(&mut s, "\n\n"); + assert_eq!(s.doc.body.len(), 5, "{:?}", styled(&s)); + assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s)); + typ(&mut s, "---\n"); + assert!(s.doc.para_at(&Pos::body(2, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).is_some()); + assert_eq!(s.doc.plain_text(StoryRef::Body).replace(wordcraft_doc::para::OBJ, ""), "Title\n\n\n\nEnd."); +} + #[test] fn arrow_bullets() { for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index e875b70b8e..baae9d6033 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -61,7 +61,7 @@ pub struct ViewState { pub read_width: f32, #[serde(skip, default = "default_read_height")] pub read_height: f32, - /// Reader-only layout controls; they are neither document formatting nor saved UI prefs. + /// Reader-only width, size and spacing controls are neither document formatting nor saved UI prefs. /// `None` chooses responsive column width and keeps the document's line spacing. #[serde(skip)] pub read_column_width: Option, @@ -69,7 +69,7 @@ pub struct ViewState { pub read_zoom: f32, #[serde(skip)] pub read_line_spacing: Option, - /// Read Mode's Page Color: the paper the reading columns are drawn on. + /// Read Mode's Page Color: the paper the reading columns are drawn on. `Prefs` saves its value. #[serde(skip)] pub read_page_color: crate::cmd::view::ReadPageColor, pub dark_mode: bool, diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 1b2b34f690..81f4ce7f3f 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -235,7 +235,9 @@ impl Writer { // leaks the reader's NTLM credentials. match f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) { Some(l) => { - out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&l.replace('"', "%22")), body)); + // In a field's quotes `\\` is one backslash; a quote is stored as `%22`. + let target = l.replace('\\', "\\\\").replace('"', "%22"); + out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&target), body)); } None => out.push_str(&body), } diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index 0e2c3ad195..be96df6e1a 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -1558,8 +1558,42 @@ fn hyperlink_field_targets() { assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into())); assert_eq!(hyperlink(r#"HYPERLINK \l "heading""#), Some("#heading".into())); assert_eq!(hyperlink(r#"Hyperlink "http://example.com""#), Some("http://example.com".into())); - assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/path\\latest".into())); + assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/path\latest""#), Some("http://example.com/pathlatest".into())); assert_eq!(hyperlink(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into())); + // A tooltip or frame before the address, a longer field name, escapes and the writer's `%22`. + assert_eq!(hyperlink(r#"HYPERLINK \o "tip" \t "_blank" "http://example.com""#), Some("http://example.com".into())); + assert_eq!(hyperlink(r#"HYPERLINKX "http://example.com""#), None); + assert_eq!(hyperlink(r#"HYPERLINK "C:\\Docs\\f.rtf""#), Some(r"C:\Docs\f.rtf".into())); + assert_eq!(hyperlink(r#"HYPERLINK "http://example.com/a%22b""#), Some(r#"http://example.com/a"b"#.into())); +} + +/// A link with a quote in it survives export and import: the writer stores it as `%22`. +#[test] +fn a_quote_in_a_link_round_trips() { + let rtf = br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "http://a.b/x%22y"}}{\fldrslt{link}}}\par}"#; + let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone())); + let d = import(rtf).unwrap(); + assert_eq!(link(&d).as_deref(), Some(r#"http://a.b/x"y"#)); + let exported = export(&d); + assert!(exported.contains("x%22y"), "{exported}"); + assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(r#"http://a.b/x"y"#)); +} + +/// Backslashes in a link survive export and import, a trailing one and two in a row too. +#[test] +fn backslashes_in_a_link_round_trip() { + let link = |d: &Document| d.body.iter().find_map(|b| b.as_para()?.runs.iter().find_map(|r| r.props.link.clone())); + for target in [r"docs\", r"a\\b", r"..\up\f.doc"] { + let mut d = import(br#"{\rtf1\ansi{\field{\*\fldinst{HYPERLINK "x"}}{\fldrslt{link}}}\par}"#).unwrap(); + for b in d.body.iter_mut() { + if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { + p.runs.iter_mut().filter(|r| r.props.link.is_some()).for_each(|r| r.props.link = Some(target.into())); + } + } + assert_eq!(link(&d).as_deref(), Some(target)); + let exported = export(&d); + assert_eq!(link(&import(exported.as_bytes()).unwrap()).as_deref(), Some(target), "{exported}"); + } } #[test] diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index 94815e196e..1b0d0c8d68 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -507,76 +507,7 @@ impl Reader { } } -/// The target of a `HYPERLINK "url"` (or `HYPERLINK "url" \l "bookmark"`, `HYPERLINK \l "bookmark"`) field instruction. +/// The target of a `HYPERLINK` field instruction; `%22` is a quote, as the writer stores one. pub(super) fn hyperlink(instr: &str) -> Option { - let t = instr.trim(); - let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) { - t[9..].trim() - } else { - return None; - }; - if rest.is_empty() { - return None; - } - - let mut url: Option = None; - let mut local: Option = None; - let mut switch: Option<&str> = None; - - let mut i = 0; - let bytes = rest.as_bytes(); - while i < bytes.len() { - while i < bytes.len() && bytes[i].is_ascii_whitespace() { - i += 1; - } - if i >= bytes.len() { - break; - } - if bytes[i] == b'\\' { - let start = i; - i += 1; - while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') { - i += 1; - } - switch = Some(&rest[start..i]); - continue; - } - let arg = if bytes[i] == b'"' { - i += 1; - let start = i; - while i < bytes.len() && bytes[i] != b'"' { - i += 1; - } - let val = &rest[start..i]; - if i < bytes.len() && bytes[i] == b'"' { - i += 1; - } - val.to_string() - } else { - let start = i; - while i < bytes.len() && !bytes[i].is_ascii_whitespace() { - i += 1; - } - rest[start..i].to_string() - }; - let clean_arg = arg.replace("%22", "\""); - if switch.take() == Some("\\l") { - local = Some(clean_arg); - } else if url.is_none() { - url = Some(clean_arg); - } - } - - match (url, local) { - (Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => { - let frag = l.trim_start_matches('#'); - if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) } - } - (Some(u), _) if !u.is_empty() => Some(u), - (None, Some(l)) if !l.is_empty() => { - let frag = l.trim_start_matches('#'); - Some(format!("#{frag}")) - } - _ => None, - } + wordcraft_doc::link::hyperlink_target(instr).map(|t| t.replace("%22", "\"")) } diff --git a/crates/layout/src/kinsoku.rs b/crates/layout/src/kinsoku.rs index 6bf0cd7d9b..432a5f826e 100644 --- a/crates/layout/src/kinsoku.rs +++ b/crates/layout/src/kinsoku.rs @@ -1,9 +1,13 @@ //! Asian typography line-breaking rules (the paragraph's `kinsoku` and `word_wrap` flags), applied //! on top of the Unicode line-break opportunities (UAX #14). //! -//! - Kinsoku on (the default): no line starts with closing East Asian punctuation (、。,)」…) -//! and none ends with opening punctuation ((「【…). UAX #14 already forbids most of these -//! breaks; the lists make sure of it for the standard Japanese/Chinese sets. +//! - Kinsoku on (the default): no ordinary break opportunity falls before closing East Asian +//! punctuation (、。,)」…), a small kana (っゃゕヶ), the prolonged sound mark (ー ー) or an +//! iteration mark, and none falls after opening punctuation ((「【…). UAX #14 already forbids +//! most of these breaks, and classes the small kana and the prolonged sound mark CJ/NS, whose +//! break is only a tailoring choice; the lists make sure of it for the standard Japanese/Chinese +//! sets. A line too narrow for any opportunity still takes the emergency break in +//! `para::break_lines`, which can put one of these characters at the start of the line. //! - Kinsoku off: a line may break between any two East Asian characters, punctuation included. //! - Word wrap off: Latin words may break at any character. //! - Automatic spacing (`autoSpaceDE`, `autoSpaceDN`): which neighbouring characters get room @@ -14,10 +18,15 @@ use unicode_segmentation::UnicodeSegmentation; -/// East Asian characters a line may not start with: closing brackets and quotes, the -/// ideographic comma and full stop, fullwidth and halfwidth punctuation, the middle dot, -/// iteration and sound marks. -const NO_START: &str = "、。,.・:;?!゛゜ヽヾゝゞ々〻〉》」』】〕〗〙〛〞〟)]}⦆。」、・゙゚%¢"; +/// 行頭禁則: East Asian characters a line may not start with, grouped as JIS X 4051 §4 and +/// Word's default 禁則処理 do: +/// - 小書きの仮名 (small kana): hiragana `ぁぃぅぇぉっゃゅょゎゕゖ`, katakana `ァィゥェォッャュョヮヵヶ`, +/// the katakana phonetic extensions (U+31F0…U+31FF) and the halfwidth small kana `ァィゥェォャュョッ`; +/// - 長音記号 (the prolonged sound mark): `ー` and its halfwidth form `ー`; +/// - 繰り返し記号 (iteration marks, with the sound marks): `゛゜ヽヾゝゞ々〻゙゚`; +/// - 閉じ括弧類 and the rest: closing brackets and quotes, the ideographic comma and full stop, +/// the fullwidth and halfwidth punctuation, the middle dot, `%` and `¢`. +const NO_START: &str = "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー゛゜ヽヾゝゞ々〻゙゚、。,.・:;?!〉》」』】〕〗〙〛〞〟)]}⦆。」、・%¢"; /// East Asian characters a line may not end with: opening brackets and quotes, currency signs. const NO_END: &str = "〈《「『【〔〖〘〚〝([{⦅「$£¥"; @@ -149,4 +158,36 @@ mod tests { let any = breaks("Hello café", true, false); assert_eq!(any, ["e", "l", "l", "o", "c", "a", "f", "é"]); } + + #[test] + fn kinsoku_no_line_starts_with_a_small_kana_or_the_prolonged_sound_mark() { + // A break opportunity sits right before each of these (after a kanji, or after a 、); + // JIS X 4051 §4 行頭禁則 drops it, as Word's default 禁則処理 does. + let ja = "漢っ、ゃ漢ー漢ッ漢ャ漢ゕ漢ヶ漢ㇺ漢ッ漢ャ漢ー"; + let non_starters = ["っ", "ゃ", "ー", "ッ", "ャ", "ゕ", "ヶ", "ㇺ", "ッ", "ャ", "ー"]; + let on = breaks(ja, true, true); + for c in non_starters { + assert!(!on.iter().any(|s| s == c), "kinsoku: no line starts with {c}: {on:?}"); + } + // Kinsoku off: a line may break between any two East Asian characters, these included. + let off = breaks(ja, false, true); + for c in non_starters { + assert!(off.iter().any(|s| s == c), "kinsoku off: a line may start with {c}: {off:?}"); + } + // Text with no East Asian characters breaks exactly as before either way. + let en = "Hello wonderful (world), 50%."; + assert_eq!(breaks(en, true, true), breaks(en, false, true)); + assert_eq!(breaks(en, true, true), ["w", "(", "5"]); + } + + /// 行頭禁則: the small kana and the prolonged sound mark are CJ/NS in UAX #14, where the break + /// before them is forbidden only optionally — so the list itself must hold every one of them. + #[test] + fn no_start_holds_every_small_kana_and_the_prolonged_sound_mark() { + for c in "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー".chars() + { + assert!(super::is_east_asian(c), "U+{:04X} {c} is East Asian text", c as u32); + assert!(super::NO_START.contains(c), "NO_START (行頭禁則) must hold U+{:04X} {c}", c as u32); + } + } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index f1176a3557..5cc292f269 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7532,6 +7532,31 @@ fn kinsoku_keeps_a_manual_line_break_before_a_closing_bracket() { assert_eq!(lines[1].0, bracket, "the second line starts at the bracket: {lines:?}"); } +/// 行頭禁則 (JIS X 4051 §4) end to end: a paragraph wrapping in a narrow column never starts a +/// line with a small kana or the prolonged sound mark, even where the break opportunity that +/// would put one there sits right after a kanji. +#[test] +fn a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark() { + let text = "漢っ漢ゃ漢ー漢ッ漢ャ漢ゕ漢ヶ漢ㇺ漢ッ漢ャ漢ー漢"; + let mut d = Document::from_text(text); + d.last_section.page_w = 72.0 + 72.0 + 72.0; // 72 pt of text: a few East Asian characters + let l = lay(&d); + let mut lines = Vec::new(); + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { para, l0, l1, .. } = it { + lines.extend(para.lines[*l0..*l1].iter().map(|line| (line.start, line.stop))); + } + } + } + assert!(lines.len() > 1, "the paragraph wraps: {lines:?}"); + let non_starters = "ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇺㇻㇼㇽㇾㇿァィゥェォャュョッーー"; + for &(start, _) in &lines { + let first = text.get(start..).and_then(|t| t.chars().next()).unwrap_or_default(); + assert!(!non_starters.contains(first), "a line must not start with {first}: {lines:?}"); + } +} + /// #332: a rotated floating shape keeps text clear of its rotated bounds (it reaches further at /// the side), its frame stays put (rotation is about the centre) and it is drawn turned; hit /// testing follows the turned shape, not its frame. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3704b2f433..c959f02dc9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -186,6 +186,12 @@ impl RenderOptions { self.ink(self.read_paper.or(doc.settings.page_color).unwrap_or(self.paper)) } + /// The on-screen color of paper with no page color: [`RenderOptions::read_paper`], else + /// [`RenderOptions::paper`]. White Read Mode paper on a dark page is the dark paper grey. + pub fn blank_paper(&self) -> Rgb { + self.ink(self.read_paper.unwrap_or(self.paper)) + } + /// The on-screen colour for a document colour (see [`RenderOptions::dark`]). pub fn ink(&self, c: Rgb) -> Rgb { if !self.dark { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 71288bf3db..71a761f16f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -360,6 +360,26 @@ fn read_paper_replaces_the_page_background() { assert!(darkest(&plain) < 0x40 && darkest(&on_sepia) <= darkest(&plain) + 8, "{} {}", darkest(&plain), darkest(&on_sepia)); } +/// The paper the canvas draws under a page before its raster: white, the reading paper, or +/// the dark paper grey on a dark page, never the document's page color. +#[test] +fn blank_paper_is_the_reading_paper_or_white_on_screen() { + let mut d = Document::from_text("Hello"); + d.settings.page_color = Some(Rgb(0x10, 0x10, 0x40)); + let grey = Rgb(DARK_PAPER, DARK_PAPER, DARK_PAPER); + for (opts, paper) in [ + (RenderOptions::default(), Rgb::WHITE), + (RenderOptions { read_paper: Some(SEPIA_PAPER), ..Default::default() }, SEPIA_PAPER), + (RenderOptions { read_paper: Some(Rgb::WHITE), dark: true, ..Default::default() }, grey), + (RenderOptions { dark: true, ..Default::default() }, grey), + ] { + assert_eq!(opts.blank_paper(), paper); + if opts.read_paper.is_some() { + assert_eq!(opts.paper_of(&d), paper, "the reading paper hides the page color"); + } + } +} + /// A PNG signature, an IHDR declaring `w`×`h` 8-bit RGBA and a short IDAT (all CRCs valid, so /// the header parses), with no real pixel data. fn ihdr_only_png(w: u32, h: u32) -> Vec { diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 872cd05188..7f1ffb7fff 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -63,6 +63,7 @@ impl WordApp { self.session.prefs = std::mem::take(&mut self.ui.editing); // Check spelling as you type follows the saved preference (#1792). self.session.view.proofing = self.session.prefs.check_spelling; + self.session.view.read_page_color = self.session.prefs.read_page_color; // The saved dictionaries folder (#1063) is searched from the start. if let Some(f) = self.session.prefs.dictionaries_folder.as_deref() { wordcraft_engine::cmd::options::use_dictionaries_folder(Some(f)); @@ -73,6 +74,7 @@ impl WordApp { self.session.view.nav_pane = self.ui.nav_pane; self.session.view.styles_pane_dock = self.ui.styles_pane_dock; self.session.view.proofing = self.ui.check_spelling; + self.session.prefs.check_spelling = self.ui.check_spelling; // Keys for commands this version doesn't have are dropped. self.session.keymap = std::mem::take(&mut self.ui.keyboard); let reg = self.session.registry.clone(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f74d10fc37..f3af2fb299 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -60,7 +60,7 @@ pub use navigation::{nearest_page, page_at, page_to_screen}; use navigation::{scrollbar_page_tip, viewed_page}; #[cfg(test)] use pages::{DRAWING_GRID_MAX_LINES, DRAWING_GRID_MIN_PX}; -pub(crate) use pages::{PageKeys, page_doc, page_screen_scale, preview_sel, screen_render_options}; +pub(crate) use pages::{PageKeys, dark_page, page_doc, page_screen_scale, preview_sel, screen_render_options}; use pages::{ drawing_grid, drawing_grid_step, gridline_cells, page_image, page_key, page_render_options, shown_layout, texel_aligned_rect, view_key, visible_tiles, @@ -484,8 +484,7 @@ struct CanvasView { area: Rect, hruler: Option, vruler: Option, - dark_page: bool, - sepia_page: bool, + paper: Color32, animate: bool, opts: egui::InputOptions, geo: Geometry, @@ -495,10 +494,13 @@ struct CanvasView { } fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> CanvasView { - use wordcraft_engine::cmd::view::ReadPageColor; let read_mode = app.session.view.read_mode; - let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode }; - let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia; + // The paper under each page, as the page raster draws it without a page color: the dark page + // of View › Switch Modes, or in Read Mode the Page color's white, sepia or dark paper. + let paper = { + let p = page_render_options(app, false).blank_paper(); + Color32::from_rgb(p.0, p.1, p.2) + }; let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !read_mode @@ -566,7 +568,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C { app.session.page_hint = c.page; } - CanvasView { area, hruler, vruler, dark_page, sepia_page, animate, opts, geo, dim_body, grid_story, caret } + CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret } } fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option { @@ -830,8 +832,7 @@ struct PagePaint<'a> { t: &'a Tokens, painter: &'a egui::Painter, dim_body: bool, - dark_page: bool, - sepia_page: bool, + paper: Color32, hidden: Option<(usize, Pos)>, view: u64, content_origin: Pos2, @@ -878,8 +879,7 @@ fn draw_page_gridlines(app: &WordApp, painter: &egui::Painter, t: &Tokens, page: } fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { - let PagePaint { app, ui, layout, geo, t, painter, dim_body, dark_page, sepia_page, hidden, view, content_origin, ppp, max_tex, grid_story } = - pass; + let PagePaint { app, ui, layout, geo, t, painter, dim_body, paper, hidden, view, content_origin, ppp, max_tex, grid_story } = pass; let mut visible_tile_keys = HashSet::new(); let mut rendered = 0; let mut rects = Vec::with_capacity(geo.rects.len()); @@ -942,14 +942,6 @@ fn paint_pages(pass: PagePaint<'_>) -> PagePaintOutput { if band_top <= 0.0 && band_bot >= sr.height() { painter.clone() } else { painter.with_clip_rect(painter.clip_rect().intersect(band)) }; // Shadow and paper. painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow); - let paper = if dark_page { - Color32::from_gray(wordcraft_render::DARK_PAPER) - } else if sepia_page { - let s = wordcraft_render::SEPIA_PAPER; - Color32::from_rgb(s.0, s.1, s.2) - } else { - Color32::WHITE - }; painter.rect_filled(visual_sr, 0.0, paper); if tiled { // Render before painting to keep the frame sharp immediately after an edit or @@ -1319,7 +1311,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let scroll_target = update_caret_scroll(app, &view); let motion = scroll_motion(app, ui, full, &view); let scroll_area = configure_scroll_area(app, ui, &view, motion); - let CanvasView { area, hruler, vruler, dark_page, sepia_page, geo, dim_body, grid_story, caret, .. } = view; + let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view; let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); let mut ime_geometry: Option<(Rect, Rect)> = None; @@ -1346,8 +1338,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { t: &t, painter: &painter, dim_body, - dark_page, - sepia_page, + paper, hidden, view: page_view, content_origin, diff --git a/crates/ui-egui/src/canvas/pages.rs b/crates/ui-egui/src/canvas/pages.rs index d4a3483d83..3ae0850f75 100644 --- a/crates/ui-egui/src/canvas/pages.rs +++ b/crates/ui-egui/src/canvas/pages.rs @@ -221,6 +221,14 @@ pub(crate) fn preview_sel(app: &WordApp) -> Option<&wordcraft_engine::Selection> app.canvas.table_preview_layout.as_ref().map(|(_, _, sel, _)| sel) } +/// Whether the pages are drawn dark: View › Switch Modes, or in Read Mode the Page color's +/// Inverse (Switch Modes is ignored there). Drawing over the pages (ink, drag previews) follows it. +pub(crate) fn dark_page(app: &WordApp) -> bool { + use wordcraft_engine::cmd::view::ReadPageColor; + let v = &app.session.view; + if v.read_mode { v.read_page_color == ReadPageColor::Inverse } else { v.dark_mode } +} + /// Render options for this view's page rasters: marks, markup, dark page, header/body dimming. pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_render::RenderOptions { let mut opts = screen_render_options(); @@ -231,14 +239,13 @@ pub(super) fn page_render_options(app: &WordApp, dim_body: bool) -> wordcraft_re // Ink Replay draws the ink itself, stroke by stroke, over pages without it. opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay; opts.display.revisions = app.session.prefs.markup.clone(); - opts.dark = app.session.view.dark_mode; + opts.dark = dark_page(app); opts.dark_paper = wordcraft_render::DARK_PAPER; - // In Read Mode the Page color sets the paper and the dark page, and Switch Modes is ignored. + // In Read Mode the Page color sets the paper. if app.session.view.read_mode { use wordcraft_engine::cmd::view::ReadPageColor; let color = app.session.view.read_page_color; opts.read_paper = Some(if color == ReadPageColor::Sepia { wordcraft_render::SEPIA_PAPER } else { wordcraft_doc::props::Rgb::WHITE }); - opts.dark = color == ReadPageColor::Inverse; } opts.display.dim_header = !dim_body; opts.display.dim_body = dim_body; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 5989477636..942ec593ac 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -392,9 +392,12 @@ fn read_mode_page_color_sets_the_reading_paper() { a.run("view.printLayout", json!({})).unwrap(); assert_eq!(paper(&a), Rgb(0x10, 0x20, 0x40), "leaving Read Mode brings the page color back"); assert!(!page_render_options(&a, false).dark); - // With Switch Modes on, Read Mode's paper still follows the Page color: None is white and Sepia is sepia. + // With Switch Modes on, Read Mode's paper still follows the Page color, which is kept from + // the last entry: Inverse is dark, None is white and Sepia is sepia. a.session.view.dark_mode = true; a.run("view.readMode", json!({})).unwrap(); + assert!(page_render_options(&a, false).dark, "Inverse is kept"); + a.run("view.readPageColor", json!({"value": "none"})).unwrap(); assert!(!page_render_options(&a, false).dark); assert_eq!(paper(&a), Rgb::WHITE); a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); @@ -403,6 +406,33 @@ fn read_mode_page_color_sets_the_reading_paper() { assert!(page_render_options(&a, false).dark, "Print Layout keeps Switch Modes"); } +/// What the canvas draws over the pages (ink, object drag previews, table drag arrows) and the +/// paper drawn under a page before its raster follow Read Mode's Page color, not Switch Modes. +#[test] +fn drawing_over_the_pages_follows_the_read_mode_page_color() { + use wordcraft_doc::props::Rgb; + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello WordCraft")), Default::default()); + a.run("design.pageColor", json!({"color": "#102040"})).unwrap(); + let blank = |a: &WordApp| page_render_options(a, false).blank_paper(); + let grey = Rgb(wordcraft_render::DARK_PAPER, wordcraft_render::DARK_PAPER, wordcraft_render::DARK_PAPER); + a.session.view.dark_mode = true; + assert!(dark_page(&a), "Switch Modes in Print Layout"); + assert_eq!(blank(&a), grey); + a.run("view.readMode", json!({})).unwrap(); + assert!(!dark_page(&a), "None in Read Mode, with Switch Modes on"); + assert_eq!(blank(&a), Rgb::WHITE); + a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); + assert!(!dark_page(&a)); + assert_eq!(blank(&a), wordcraft_render::SEPIA_PAPER); + a.session.view.dark_mode = false; + a.run("view.readPageColor", json!({"value": "inverse"})).unwrap(); + assert!(dark_page(&a), "Inverse in Read Mode, with Switch Modes off"); + assert_eq!(blank(&a), grey); + a.run("view.printLayout", json!({})).unwrap(); + assert!(!dark_page(&a)); + assert_eq!(blank(&a), Rgb::WHITE, "the paper under the raster, not the page color"); +} + /// #299: the caret's colour is worked out only when the caret is drawn: with text selected /// there is no caret, so the page's draw list isn't built for it. #[test] diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index e145407637..7ceadc0c85 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -670,29 +670,35 @@ fn custom(ui: &mut Ui, d: &mut ColorsDialog) { ui.add_space(8.0); }); ui.add_space(8.0); - crate::rtl::grid(ui, egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| { - ui.label(tl!("Color model:")); - craft_ui::ComboBox::from_id_salt("colors_model") - .selected_text(match d.model { - Model::Rgb => "RGB", - Model::Hsl => "HSL", - }) - .width(90.0) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.model, Model::Rgb, "RGB"); - ui.selectable_value(&mut d.model, Model::Hsl, "HSL"); + crate::rtl::Table::new("colors_custom").columns(2).spacing(vec2(10.0, 5.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Color model:"))); + r.cell(|ui| { + craft_ui::ComboBox::from_id_salt("colors_model") + .selected_text(match d.model { + Model::Rgb => "RGB", + Model::Hsl => "HSL", + }) + .width(90.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.model, Model::Rgb, "RGB"); + ui.selectable_value(&mut d.model, Model::Hsl, "HSL"); + }); }); - ui.end_row(); + }); match d.model { Model::Rgb => { let mut c = [d.color.0, d.color.1, d.color.2]; let mut changed = false; for (i, label) in ["Red:", "Green:", "Blue:"].into_iter().enumerate() { - ui.label(tl!(label)); - if let Some(v) = c.get_mut(i) { - changed |= ui.add(egui::DragValue::new(v).range(0..=255).speed(1.0)).changed(); - } - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!(label))); + r.cell(|ui| { + if let Some(v) = c.get_mut(i) { + changed |= ui.add(egui::DragValue::new(v).range(0..=255).speed(1.0)).changed(); + } + }); + }); } if changed { d.set_rgb(Rgb(c[0], c[1], c[2])); @@ -704,31 +710,37 @@ fn custom(ui: &mut Ui, d: &mut ColorsDialog) { let mut v = [to255(d.hsl.0 / 360.0), to255(d.hsl.1), to255(d.hsl.2)]; let mut changed = false; for (i, label) in ["Hue:", "Sat:", "Lum:"].into_iter().enumerate() { - ui.label(tl!(label)); - if let Some(x) = v.get_mut(i) { - changed |= ui.add(egui::DragValue::new(x).range(0..=255).speed(1.0)).changed(); - } - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!(label))); + r.cell(|ui| { + if let Some(x) = v.get_mut(i) { + changed |= ui.add(egui::DragValue::new(x).range(0..=255).speed(1.0)).changed(); + } + }); + }); } if changed { d.set_hsl(v[0] as f32 / 255.0 * 360.0, v[1] as f32 / 255.0, v[2] as f32 / 255.0); } } } - ui.label(tl!("Hex:")); - ui.horizontal(|ui| { - ui.label("#"); - let mut text = d.hex.clone(); - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(80.0).char_limit(16)); - if r.changed() { - d.hex = text.clone(); - d.set_text(&text); - } - if r.lost_focus() { - d.hex = d.color.hex(); - } + t.row(|r| { + r.cell(|ui| ui.label(tl!("Hex:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.label("#"); + let mut text = d.hex.clone(); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(80.0).char_limit(16)); + if edit.changed() { + d.hex = text.clone(); + d.set_text(&text); + } + if edit.lost_focus() { + d.hex = d.color.hex(); + } + }); + }); }); - ui.end_row(); }); } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 4fe8b262a5..d206a454c2 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -422,10 +422,13 @@ pub fn models_ui(ui: &mut egui::Ui) { } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); let h = row_height(ui); - let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"]; - // Company, Model and Version read as text; the counts line up on the right. - let right = [false, false, false, true, true, true]; - let rows: Vec<[String; 6]> = MODELS + // A horizontal scroll area stays left-to-right; reverse the columns in a mirrored interface so + // Company still starts at the reading edge. + let mut titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"]; + // Company, Model and Version read as text; the counts line up on the right (left in RTL). + let mut right = [false, false, false, true, true, true]; + let mut grow = MODEL_GROW; + let mut rows: Vec<[String; 6]> = MODELS .iter() .map(|m| { [ @@ -438,6 +441,19 @@ pub fn models_ui(ui: &mut egui::Ui) { ] }) .collect(); + let rtl = crate::rtl::on(); + // The reversed columns keep their own widths and scroll position: egui remembers both by id. + let id = if rtl { "credits_models_rtl" } else { "credits_models" }; + if rtl { + titles.reverse(); + // Mirrored alignment: the text columns sit at the reading edge (right), the counts on the left. + right.reverse(); + right = right.map(|r| !r); + grow.reverse(); + for cells in &mut rows { + cells.reverse(); + } + } let widths: Vec = titles .iter() .enumerate() @@ -446,8 +462,9 @@ pub fn models_ui(ui: &mut egui::Ui) { crate::rtl::island(ui, |ui| { ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| { ui.set_max_height(360.0); - egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { - fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + // When the table overflows, a mirrored one opens at its right (reading) edge, where Company is. + egui::ScrollArea::horizontal().id_salt(id).auto_shrink([false, false]).stick_to_right(rtl).show(ui, |ui| { + fitted_table(ui, id, &widths, &grow) .header(h + 4.0, |mut header| { for (title, r) in titles.into_iter().zip(right) { header.col(|ui| { diff --git a/crates/ui-egui/src/dialogs/bookmarks.rs b/crates/ui-egui/src/dialogs/bookmarks.rs index 8e595634d1..335311dce8 100644 --- a/crates/ui-egui/src/dialogs/bookmarks.rs +++ b/crates/ui-egui/src/dialogs/bookmarks.rs @@ -208,8 +208,8 @@ pub(super) fn macros(app: &mut WordApp, ui: &mut Ui, name: &mut String) -> bool let recording = app.session.recording.as_ref().map(|(n, _)| n.clone()); let t = Tokens::get(ui.ctx()); let (mut run, mut delete, mut record) = (false, false, false); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { let name_label = ui.label(tl!("Macro name:")); ui.add(egui::TextEdit::singleline(name).desired_width(240.0)).labelled_by(name_label.id); egui::Frame::NONE.stroke(egui::Stroke::new(1.0, t.border)).inner_margin(4).show(ui, |ui| { @@ -227,7 +227,7 @@ pub(super) fn macros(app: &mut WordApp, ui: &mut Ui, name: &mut String) -> bool }); }); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(18.0); let listed = names.contains(name); let w = vec2(110.0, 0.0); diff --git a/crates/ui-egui/src/dialogs/dialogs_chart.rs b/crates/ui-egui/src/dialogs/dialogs_chart.rs index 7adf3e03ef..d976263abd 100644 --- a/crates/ui-egui/src/dialogs/dialogs_chart.rs +++ b/crates/ui-egui/src/dialogs/dialogs_chart.rs @@ -198,42 +198,45 @@ pub fn type_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartTypeForm) -> bool { let t = Tokens::get(ui.ctx()); let col = accents(app); let mut chosen = None; - crate::rtl::grid(ui, egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)), |ui| { - for (k, kind) in ChartType::ALL.into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(112.0, 92.0), Sense::click()); - let on = f.kind == kind; - ui.painter().rect( - rect, - 4.0, - if on { - t.checked - } else if resp.hovered() { - t.hover - } else { - t.input - }, - Stroke::new(1.0, if on { t.accent } else { t.border }), - egui::StrokeKind::Inside, - ); - let pic = Rect::from_min_size(rect.min + vec2(18.0, 6.0), vec2(76.0, 60.0)); - preview(ui.painter(), pic, kind, col, t.text_dim); - ui.painter().text( - pos2(rect.center().x, rect.bottom() - 13.0), - egui::Align2::CENTER_CENTER, - tl!(kind.label()), - crate::theme::regular(11.0), - t.text, - ); - let resp = resp.on_hover_text(tl!(kind.label())); - if resp.clicked() { - f.kind = kind; - } - if resp.double_clicked() { - chosen = Some(kind); - } - if k % 3 == 2 { - ui.end_row(); - } + crate::rtl::Table::new("chart_types").columns(0).spacing(vec2(8.0, 8.0)).show(ui, |table| { + for kinds in ChartType::ALL.chunks(3) { + table.row(|r| { + for kind in kinds.iter().copied() { + r.cell(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(112.0, 92.0), Sense::click()); + let on = f.kind == kind; + ui.painter().rect( + rect, + 4.0, + if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + t.input + }, + Stroke::new(1.0, if on { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + let pic = Rect::from_min_size(rect.min + vec2(18.0, 6.0), vec2(76.0, 60.0)); + preview(ui.painter(), pic, kind, col, t.text_dim); + ui.painter().text( + pos2(rect.center().x, rect.bottom() - 13.0), + egui::Align2::CENTER_CENTER, + tl!(kind.label()), + crate::theme::regular(11.0), + t.text, + ); + let resp = resp.on_hover_text(tl!(kind.label())); + if resp.clicked() { + f.kind = kind; + } + if resp.double_clicked() { + chosen = Some(kind); + } + }); + } + }); } }); let (ok, cancel) = crate::dialogs::buttons(ui, tl!("OK")); diff --git a/crates/ui-egui/src/dialogs/dialogs_controls.rs b/crates/ui-egui/src/dialogs/dialogs_controls.rs index 2da354e452..f1f0259610 100644 --- a/crates/ui-egui/src/dialogs/dialogs_controls.rs +++ b/crates/ui-egui/src/dialogs/dialogs_controls.rs @@ -113,17 +113,20 @@ impl ControlForm { /// Draws the dialog; returns true to close it. pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool { ui.set_width(420.0); - crate::rtl::grid(ui, egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]), |ui| { - ui.label(tl!("Title")); - ui.add(egui::TextEdit::singleline(&mut f.title).desired_width(280.0)); - ui.end_row(); - ui.label(tl!("Tag")); - ui.add(egui::TextEdit::singleline(&mut f.tag).desired_width(280.0)); - ui.end_row(); + crate::rtl::Table::new("cc_props").columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Title"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.title).desired_width(280.0))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Tag"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.tag).desired_width(280.0))); + }); if !matches!(f.kind.as_str(), "checkBox" | "picture" | "repeatingSection" | "repeatingSectionItem") { - ui.label(tl!("Placeholder text")); - ui.add(egui::TextEdit::singleline(&mut f.placeholder).desired_width(280.0)); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Placeholder text"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.placeholder).desired_width(280.0))); + }); } }); ui.add_space(6.0); @@ -184,17 +187,21 @@ fn entries(ui: &mut Ui, f: &mut ControlForm) { ui.add_space(6.0); ui.label(egui::RichText::new(tl!("List entries")).font(semibold(12.0))); let mut remove = None; - crate::rtl::grid(ui, egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]), |ui| { - ui.label(egui::RichText::new(tl!("Display text")).small()); - ui.label(egui::RichText::new(tl!("Value")).small()); - ui.end_row(); + crate::rtl::Table::new("cc_entries").columns(3).spacing(egui::vec2(6.0, 4.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(egui::RichText::new(tl!("Display text")).small())); + r.cell(|ui| ui.label(egui::RichText::new(tl!("Value")).small())); + }); for (i, e) in f.items.iter_mut().enumerate() { - ui.add(egui::TextEdit::singleline(&mut e.display).desired_width(170.0)); - ui.add(egui::TextEdit::singleline(&mut e.value).desired_width(140.0)); - if ui.small_button(tl!("Remove")).clicked() { - remove = Some(i); - } - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut e.display).desired_width(170.0))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut e.value).desired_width(140.0))); + r.cell(|ui| { + if ui.small_button(tl!("Remove")).clicked() { + remove = Some(i); + } + }); + }); } }); if let Some(i) = remove diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs index 7d5295e35b..771d3a0c18 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs @@ -62,20 +62,29 @@ pub(super) fn drop_cap_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DropCapForm) - let preview = app.previews.font_preview_fn(); let unit = crate::options::unit(); ui.add_enabled_ui(f.position != "none", |ui| { - crate::rtl::grid(ui, egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Font:")); - if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview)) - && v != crate::widgets::ADD_FONT - { - f.font = v; - } - ui.end_row(); - ui.label(tl!("Lines to drop:")); - ui.add(egui::DragValue::new(&mut f.lines).range(1..=10)); - ui.end_row(); - ui.label(tl!("Distance from text:")); - ui.add(egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit.pt_per_unit()).measure(unit).max_decimals(2)); - ui.end_row(); + crate::rtl::Table::new("dropcap").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Font:"))); + r.cell(|ui| { + if let Some(v) = crate::widgets::font_combo(ui, "dropcap_font", 190.0, &f.font, &fams, Some(&*preview)) + && v != crate::widgets::ADD_FONT + { + f.font = v; + } + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Lines to drop:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.lines).range(1..=10))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Distance from text:"))); + r.cell(|ui| { + ui.add( + egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit.pt_per_unit()).measure(unit).max_decimals(2), + ) + }); + }); }); }); let (ok, cancel) = buttons(ui, "OK", "Cancel", true); @@ -287,6 +296,9 @@ impl ColumnsForm { /// A preset tile: a small page with its columns. fn preset_tile(ui: &mut Ui, label: &str, widths: &[f32], on: bool, t: &Tokens) -> bool { + if crate::rtl::on() { + return preset_tile_mirrored(ui, label, widths, on, t); + } let resp = ui .vertical(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(44.0, 52.0), Sense::click()); @@ -321,6 +333,42 @@ fn preset_tile(ui: &mut Ui, label: &str, widths: &[f32], on: bool, t: &Tokens) - resp.clicked() } +/// A preset tile in a mirrored interface: a caption longer than the 44 pt tile (it is longer in +/// Arabic) widens the tile's column and is centred under it, so it never runs into the next tile. +fn preset_tile_mirrored(ui: &mut Ui, label: &str, widths: &[f32], on: bool, t: &Tokens) -> bool { + let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), egui::TextStyle::Small.resolve(ui.style()), t.text); + let column = galley.size().x.max(44.0); + let (cell, resp) = ui.allocate_exact_size(vec2(column, 52.0 + 2.0 + galley.size().y), Sense::click()); + let r = Rect::from_min_size(pos2(cell.center().x - 22.0, cell.min.y), vec2(44.0, 52.0)); + let fill = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + Color32::TRANSPARENT + }; + ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); + let page = Rect::from_min_size(r.min + vec2(9.0, 7.0), vec2(26.0, 38.0)); + ui.painter().rect(page, 0.0, t.input, Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let inner = page.shrink(4.0); + let gap = 3.5; + let total: f32 = widths.iter().sum::().max(1.0); + let avail = inner.width() - gap * (widths.len() as f32 - 1.0); + let mut x = inner.min.x; + for w in widths { + let cw = avail * w / total; + let mut y = inner.min.y; + while y + 1.5 <= inner.max.y { + ui.painter().line_segment([pos2(x, y), pos2(x + cw, y)], Stroke::new(1.0, t.text_dim)); + y += 3.0; + } + x += cw + gap; + } + let at = pos2(cell.center().x - galley.size().x / 2.0, r.max.y + 2.0); + ui.painter().galley(at, galley, t.text); + resp.clicked() +} + /// The page preview: the columns (and the line between them) on a page. fn columns_preview(ui: &mut Ui, f: &ColumnsForm, t: &Tokens) { let (r, _) = ui.allocate_exact_size(vec2(96.0, 124.0), Sense::hover()); @@ -384,31 +432,38 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> crate::rtl::vertical(ui, |ui| { let mut changed = None; egui::ScrollArea::vertical().id_salt("columns_widths").max_height(130.0).show(ui, |ui| { - crate::rtl::grid(ui, egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.0)), |ui| { - ui.label(tl!("Col #:")); - ui.label(tl!("Width:")); - ui.label(tl!("Spacing:")); - ui.end_row(); + crate::rtl::Table::new("columns_grid").columns(3).spacing(vec2(12.0, 4.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Col #:"))); + r.cell(|ui| ui.label(tl!("Width:"))); + r.cell(|ui| ui.label(tl!("Spacing:"))); + }); let n = f.cols.len(); let max = f.text_width.max(0.1); for i in 0..n { let editable = i == 0 || !f.equal; - ui.label(format!("{}:", i + 1)); - if let Some(c) = f.cols.get_mut(i) { - let w = egui::DragValue::new(&mut c.0).speed(0.01).range(ColumnsForm::min_w()..=max).measure(unit).max_decimals(2); - if ui.add_enabled(editable, w).changed() { - changed = Some(i); + t.row(|r| { + r.cell(|ui| ui.label(format!("{}:", i + 1))); + if let Some(c) = f.cols.get_mut(i) { + r.cell(|ui| { + let w = + egui::DragValue::new(&mut c.0).speed(0.01).range(ColumnsForm::min_w()..=max).measure(unit).max_decimals(2); + if ui.add_enabled(editable, w).changed() { + changed = Some(i); + } + }); + r.cell(|ui| { + if i + 1 < n { + let s = egui::DragValue::new(&mut c.1).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2); + if ui.add_enabled(editable, s).changed() { + changed = Some(i); + } + } else { + ui.label(""); + } + }); } - if i + 1 < n { - let s = egui::DragValue::new(&mut c.1).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2); - if ui.add_enabled(editable, s).changed() { - changed = Some(i); - } - } else { - ui.label(""); - } - } - ui.end_row(); + }); } }); }); @@ -425,6 +480,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> }); ui.add_space(8.0); crate::rtl::vertical(ui, |ui| { + ui.set_min_width(96.0); ui.label(tl!("Preview")); columns_preview(ui, f, &t); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs index a1982bd079..984b4e1306 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs @@ -427,24 +427,28 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b fn special_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm, insert: &mut Option<(Value, String, String)>, t: &Tokens) -> bool { let mut double = false; egui::ScrollArea::vertical().id_salt("special_chars").max_height(CELL * GRID_ROWS as f32 + 60.0).auto_shrink([false, false]).show(ui, |ui| { - crate::rtl::grid(ui, egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)), |ui| { - ui.label(egui::RichText::new(tl!("Character")).font(semibold(12.0))); - ui.label(""); - ui.label(egui::RichText::new(tl!("Shortcut key:")).font(semibold(12.0))); - ui.end_row(); + crate::rtl::Table::new("special_grid").columns(3).striped(true).spacing(vec2(14.0, 4.0)).show(ui, |table| { + table.row(|r| { + r.cell(|ui| ui.label(egui::RichText::new(tl!("Character")).font(semibold(12.0)))); + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.label(egui::RichText::new(tl!("Shortcut key:")).font(semibold(12.0)))); + }); for (i, (name, c, cmd)) in SPECIAL.iter().enumerate() { - let shown = if c.is_whitespace() || matches!(*c, '\u{00AD}' | '\u{200B}' | '\u{2060}') { String::new() } else { c.to_string() }; - ui.label(egui::RichText::new(shown).font(regular(15.0)).color(t.text)); - let resp = ui.selectable_label(f.special == i, tl!(name)); - if resp.clicked() { - f.special = i; - } - if resp.double_clicked() { - f.special = i; - double = true; - } - ui.label(cmd.map(|id| crate::widgets::shortcut_text(app, id)).unwrap_or_default()); - ui.end_row(); + table.row(|r| { + let shown = if c.is_whitespace() || matches!(*c, '\u{00AD}' | '\u{200B}' | '\u{2060}') { String::new() } else { c.to_string() }; + r.cell(|ui| ui.label(egui::RichText::new(shown).font(regular(15.0)).color(t.text))); + r.cell(|ui| { + let resp = ui.selectable_label(f.special == i, tl!(name)); + if resp.clicked() { + f.special = i; + } + if resp.double_clicked() { + f.special = i; + double = true; + } + }); + r.cell(|ui| ui.label(cmd.map(|id| crate::widgets::shortcut_text(app, id)).unwrap_or_default())); + }); } }); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/page.rs b/crates/ui-egui/src/dialogs/dialogs_layout/page.rs index f3ac2b712b..e49a23acdd 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/page.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/page.rs @@ -147,42 +147,44 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool // typing, the field reports each keystroke as a change). The range bounds what is typed or // dragged only: a value already outside it (a 1 px picture is 0.75 pt) is shown as it is, not // clamped, which egui would report as a change and so apply unasked. - let length = |ui: &mut Ui, label: &str, pt: f32| { - let name = ui.label(tl!(label)); + let length = |r: &mut crate::rtl::Cells, label: &str, pt: f32| { + let name = r.cell(|ui| ui.label(tl!(label))); let was = pt / k; let mut v = was; let field = egui::DragValue::new(&mut v).speed(1.0 / k).range(1.0 / k..=4000.0 / k).clamp_existing_to_range(false).measure(unit).max_decimals(2); - let changed = ui.add(field.update_while_editing(false)).labelled_by(name.id).changed(); + let changed = r.cell(|ui| ui.add(field.update_while_editing(false)).labelled_by(name.id).changed()); (changed && v != was).then_some(v * k) }; - let percent = |ui: &mut Ui, label: &str, was: f32| { - let name = ui.label(tl!(label)); + let percent = |r: &mut crate::rtl::Cells, label: &str, was: f32| { + let name = r.cell(|ui| ui.label(tl!(label))); let mut v = was; let field = egui::DragValue::new(&mut v).speed(1.0).range(1.0..=10_000.0).clamp_existing_to_range(false).suffix(" %").max_decimals(1); - let changed = ui.add(field.update_while_editing(false)).labelled_by(name.id).changed(); + let changed = r.cell(|ui| ui.add(field.update_while_editing(false)).labelled_by(name.id).changed()); (changed && v != was).then_some(v) }; heading(ui, "Size"); - crate::rtl::grid(ui, egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| { - if let Some(h) = length(ui, "Height:", f.height) { - f.set_height(h); - } - if let Some(w) = length(ui, "Width:", f.width) { - f.set_width(w); - } - ui.end_row(); + crate::rtl::Table::new("size_lengths").columns(4).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + if let Some(h) = length(r, "Height:", f.height) { + f.set_height(h); + } + if let Some(w) = length(r, "Width:", f.width) { + f.set_width(w); + } + }); }); heading(ui, "Scale"); let (sw, sh) = f.scale(); - crate::rtl::grid(ui, egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)), |ui| { - if let Some(p) = percent(ui, "Height:", sh) { - f.set_scale_height(p); - } - if let Some(p) = percent(ui, "Width:", sw) { - f.set_scale_width(p); - } - ui.end_row(); + crate::rtl::Table::new("size_scale").columns(4).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + if let Some(p) = percent(r, "Height:", sh) { + f.set_scale_height(p); + } + if let Some(w) = percent(r, "Width:", sw) { + f.set_scale_width(w); + } + }); }); ui.checkbox(&mut f.lock, tl!("Lock aspect ratio")); ui.add_enabled_ui(f.original.is_some(), |ui| ui.checkbox(&mut f.relative, tl!("Relative to original picture size"))); diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/table.rs b/crates/ui-egui/src/dialogs/dialogs_layout/table.rs index be1d04862b..a96b110173 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/table.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/table.rs @@ -253,13 +253,15 @@ fn alert(ui: &mut Ui, text: &Option) { pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsForm) -> bool { // Several cells split one by one keep their rows. let rows_on = f.cells <= 1 || f.merge_first; - crate::rtl::grid(ui, egui::Grid::new("split_cells").num_columns(2), |ui| { - ui.label(tl!("Number of columns:")); - ui.add(egui::DragValue::new(&mut f.columns).range(1..=63)); - ui.end_row(); - ui.label(tl!("Number of rows:")); - ui.add_enabled(rows_on, egui::DragValue::new(&mut f.rows).range(1..=63)); - ui.end_row(); + crate::rtl::Table::new("split_cells").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number of columns:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.columns).range(1..=63))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number of rows:"))); + r.cell(|ui| ui.add_enabled(rows_on, egui::DragValue::new(&mut f.rows).range(1..=63))); + }); }); let mut merge = f.merge_first; if ui.add_enabled_ui(f.several, |ui| ui.checkbox(&mut merge, tl!("Merge cells before split"))).inner.changed() { @@ -379,13 +381,15 @@ impl TextToTableForm { pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) -> bool { let before = (f.separator.clone(), f.other_char.clone(), f.columns); heading(ui, "Table size"); - crate::rtl::grid(ui, egui::Grid::new("text_to_table").num_columns(2), |ui| { - ui.label(tl!("Number of columns:")); - ui.add(egui::DragValue::new(&mut f.columns).range(1..=63)); - ui.end_row(); - ui.label(tl!("Number of rows:")); - ui.label(f.rows.to_string()); - ui.end_row(); + crate::rtl::Table::new("text_to_table").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number of columns:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.columns).range(1..=63))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number of rows:"))); + r.cell(|ui| ui.label(f.rows.to_string())); + }); }); heading(ui, "AutoFit behavior"); ui.horizontal(|ui| { @@ -395,19 +399,23 @@ pub(super) fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTab ui.radio_value(&mut f.autofit, "contents".to_string(), tl!("AutoFit to contents")); ui.radio_value(&mut f.autofit, "window".to_string(), tl!("AutoFit to window")); heading(ui, "Separate text at"); - crate::rtl::grid(ui, egui::Grid::new("text_to_table_sep").num_columns(2), |ui| { - ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraphs")); - ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas")); - ui.end_row(); - ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs")); - ui.horizontal(|ui| { - ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:")); - let r = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0)); - if r.changed() { - f.separator = "other".into(); - } + crate::rtl::Table::new("text_to_table_sep").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraphs"))); + r.cell(|ui| ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas"))); + }); + t.row(|r| { + r.cell(|ui| ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:")); + let edit = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0)); + if edit.changed() { + f.separator = "other".into(); + } + }); + }); }); - ui.end_row(); }); if (f.separator.as_str(), f.other_char.as_str()) != (before.0.as_str(), before.1.as_str()) { f.guess_columns(); diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs index 9fccba415c..f270811391 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs @@ -50,22 +50,31 @@ impl LineNumbersForm { pub(super) fn line_numbers_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LineNumbersForm) -> bool { ui.checkbox(&mut f.on, tl!("Add line numbering")); ui.add_enabled_ui(f.on, |ui| { - crate::rtl::grid(ui, egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Start at:")); - ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767)); - ui.end_row(); - ui.label(tl!("From text:")); - ui.horizontal(|ui| { - ui.checkbox(&mut f.auto_distance, tl!("Auto")); - ui.add_enabled( - !f.auto_distance, - egui::DragValue::new(&mut f.distance).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2), - ); + crate::rtl::Table::new("lnum").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Start at:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("From text:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.checkbox(&mut f.auto_distance, tl!("Auto")); + ui.add_enabled( + !f.auto_distance, + egui::DragValue::new(&mut f.distance) + .speed(0.01) + .range(0.0..=22.0 * 72.0 / unit().pt_per_unit()) + .measure(unit()) + .max_decimals(2), + ); + }); + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Count by:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.count_by).range(1..=100))); }); - ui.end_row(); - ui.label(tl!("Count by:")); - ui.add(egui::DragValue::new(&mut f.count_by).range(1..=100)); - ui.end_row(); }); heading(ui, "Numbering:"); for (v, l) in [("restartPage", "Restart Each Page"), ("restartSection", "Restart Each Section"), ("continuous", "Continuous")] { @@ -112,16 +121,24 @@ impl HyphenationForm { pub(super) fn hyphenation_ui(app: &mut WordApp, ui: &mut Ui, f: &mut HyphenationForm) -> bool { ui.checkbox(&mut f.auto, tl!("Automatically hyphenate document")); ui.checkbox(&mut f.caps, tl!("Hyphenate words in CAPS")); - crate::rtl::grid(ui, egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Hyphenation zone:")); - ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2)); - ui.end_row(); - ui.label(tl!("Limit consecutive hyphens to:")); - let no_limit = tl!("No limit").to_string(); - ui.add( - egui::DragValue::new(&mut f.limit).range(0..=99).custom_formatter(move |n, _| if n < 0.5 { no_limit.clone() } else { format!("{n:.0}") }), - ); - ui.end_row(); + crate::rtl::Table::new("hyph").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Hyphenation zone:"))); + r.cell(|ui| { + ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).measure(unit()).max_decimals(2)) + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Limit consecutive hyphens to:"))); + r.cell(|ui| { + let no_limit = tl!("No limit").to_string(); + ui.add( + egui::DragValue::new(&mut f.limit) + .range(0..=99) + .custom_formatter(move |n, _| if n < 0.5 { no_limit.clone() } else { format!("{n:.0}") }), + ) + }); + }); }); ui.add_space(6.0); let manual = ui.button(tl!("Manual…")).clicked(); diff --git a/crates/ui-egui/src/dialogs/dialogs_lists.rs b/crates/ui-egui/src/dialogs/dialogs_lists.rs index 44b2c1a5d2..b60f5ac7da 100644 --- a/crates/ui-egui/src/dialogs/dialogs_lists.rs +++ b/crates/ui-egui/src/dialogs/dialogs_lists.rs @@ -189,103 +189,130 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool { let styles = f.styles.clone(); let Some(l) = f.levels.get_mut(i) else { return true }; heading(ui, "Number format"); - crate::rtl::grid(ui, egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Enter formatting for number:")); - ui.text_edit_singleline(&mut l.text); - ui.end_row(); - ui.label(tl!("Number style for this level:")); - let current = STYLES.iter().find(|(k, _)| *k == l.format).map(|(_, s)| *s).unwrap_or(""); - craft_ui::ComboBox::from_id_salt("mll_style").selected_text(style_label(current)).show_ui(ui, |ui| { - for (k, s) in STYLES { - if ui.selectable_label(l.format == k, style_label(s)).clicked() { - // A new style puts this level's number in the text if it had none (a bullet - // its character). - if k == "bullet" { - l.text = "•".into(); - } else if l.format == "bullet" || !l.text.contains('%') { - l.text = format!("%{}.", i + 1); - } - l.format = k.to_string(); - } - } + crate::rtl::Table::new("mll_number").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Enter formatting for number:"))); + r.cell(|ui| ui.text_edit_singleline(&mut l.text)); }); - ui.end_row(); - if i > 0 { - ui.label(tl!("Include level number from:")); - craft_ui::ComboBox::from_id_salt("mll_include").selected_text("").show_ui(ui, |ui| { - for k in 0..i { - if ui.selectable_label(false, crate::i18n::fmt(tl!("Level {n}"), &[("n", &(k + 1).to_string())])).clicked() { - l.text.push_str(&format!("%{}", k + 1)); - } - } - }); - ui.end_row(); - } - ui.label(tl!("Start at:")); - ui.add(egui::DragValue::new(&mut l.start).range(0..=32_767)); - ui.end_row(); - if i > 0 { - ui.checkbox(&mut l.restart, tl!("Restart list after:")); - ui.add_enabled_ui(l.restart, |ui| { - let shown = crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.restart_after.clamp(1, i as u8).to_string())]); - craft_ui::ComboBox::from_id_salt("mll_restart").selected_text(shown).show_ui(ui, |ui| { - for k in 1..=i as u8 { - ui.selectable_value(&mut l.restart_after, k, crate::i18n::fmt(tl!("Level {n}"), &[("n", &k.to_string())])); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number style for this level:"))); + r.cell(|ui| { + let current = STYLES.iter().find(|(k, _)| *k == l.format).map(|(_, s)| *s).unwrap_or(""); + craft_ui::ComboBox::from_id_salt("mll_style").selected_text(style_label(current)).show_ui(ui, |ui| { + for (k, s) in STYLES { + if ui.selectable_label(l.format == k, style_label(s)).clicked() { + // A new style puts this level's number in the text if it had none (a bullet + // its character). + if k == "bullet" { + l.text = "•".into(); + } else if l.format == "bullet" || !l.text.contains('%') { + l.text = format!("%{}.", i + 1); + } + l.format = k.to_string(); + } } }); }); - ui.end_row(); + }); + if i > 0 { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Include level number from:"))); + r.cell(|ui| { + craft_ui::ComboBox::from_id_salt("mll_include").selected_text("").show_ui(ui, |ui| { + for k in 0..i { + if ui.selectable_label(false, crate::i18n::fmt(tl!("Level {n}"), &[("n", &(k + 1).to_string())])).clicked() { + l.text.push_str(&format!("%{}", k + 1)); + } + } + }); + }); + }); } - ui.label(tl!("Link level to style:")); - let shown = styles.iter().find(|(id, _)| *id == l.style).map(|(_, n)| n.clone()).unwrap_or_else(|| tl!("(no style)").to_string()); - craft_ui::ComboBox::from_id_salt("mll_link").selected_text(shown).show_ui(ui, |ui| { - ui.selectable_value(&mut l.style, String::new(), tl!("(no style)")); - for (id, name) in &styles { - ui.selectable_value(&mut l.style, id.clone(), name); - } + t.row(|r| { + r.cell(|ui| ui.label(tl!("Start at:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut l.start).range(0..=32_767))); }); - ui.end_row(); - ui.label(tl!("Font:")); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut l.font).desired_width(110.0)); - ui.checkbox(&mut l.bold, tl!("Bold")); - ui.checkbox(&mut l.italic, tl!("Italic")); + if i > 0 { + t.row(|r| { + r.cell(|ui| ui.checkbox(&mut l.restart, tl!("Restart list after:"))); + r.cell(|ui| { + ui.add_enabled_ui(l.restart, |ui| { + let shown = crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.restart_after.clamp(1, i as u8).to_string())]); + craft_ui::ComboBox::from_id_salt("mll_restart").selected_text(shown).show_ui(ui, |ui| { + for k in 1..=i as u8 { + ui.selectable_value(&mut l.restart_after, k, crate::i18n::fmt(tl!("Level {n}"), &[("n", &k.to_string())])); + } + }); + }); + }); + }); + } + t.row(|r| { + r.cell(|ui| ui.label(tl!("Link level to style:"))); + r.cell(|ui| { + let shown = styles.iter().find(|(id, _)| *id == l.style).map(|(_, n)| n.clone()).unwrap_or_else(|| tl!("(no style)").to_string()); + craft_ui::ComboBox::from_id_salt("mll_link").selected_text(shown).show_ui(ui, |ui| { + ui.selectable_value(&mut l.style, String::new(), tl!("(no style)")); + for (id, name) in &styles { + ui.selectable_value(&mut l.style, id.clone(), name); + } + }); + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Font:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut l.font).desired_width(110.0)); + ui.checkbox(&mut l.bold, tl!("Bold")); + ui.checkbox(&mut l.italic, tl!("Italic")); + }); + }); + }); + t.row(|r| { + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.checkbox(&mut l.legal, tl!("Legal style numbering"))); }); - ui.end_row(); - ui.label(""); - ui.checkbox(&mut l.legal, tl!("Legal style numbering")); - ui.end_row(); }); heading(ui, "Position"); - crate::rtl::grid(ui, egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Number alignment:")); - ui.horizontal(|ui| { - for (v, s) in [("left", "Left"), ("center", "Center"), ("right", "Right")] { - ui.radio_value(&mut l.align, v.to_string(), tl!(s)); - } + crate::rtl::Table::new("mll_position").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number alignment:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + for (v, s) in [("left", "Left"), ("center", "Center"), ("right", "Right")] { + ui.radio_value(&mut l.align, v.to_string(), tl!(s)); + } + }); + }); }); - ui.end_row(); - ui.label(tl!("Aligned at:")); - ui.add(len(&mut l.aligned_at, unit)); - ui.end_row(); - ui.label(tl!("Text indent at:")); - ui.add(len(&mut l.indent_at, unit)); - ui.end_row(); - ui.label(tl!("Follow number with:")); - let follow = |k: &str| match k { - "space" => tl!("Space"), - "nothing" => tl!("Nothing"), - _ => tl!("Tab character"), - }; - craft_ui::ComboBox::from_id_salt("mll_follow").selected_text(follow(&l.follow)).show_ui(ui, |ui| { - for k in ["tab", "space", "nothing"] { - ui.selectable_value(&mut l.follow, k.to_string(), follow(k)); - } + t.row(|r| { + r.cell(|ui| ui.label(tl!("Aligned at:"))); + r.cell(|ui| ui.add(len(&mut l.aligned_at, unit))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Text indent at:"))); + r.cell(|ui| ui.add(len(&mut l.indent_at, unit))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Follow number with:"))); + r.cell(|ui| { + let follow = |k: &str| match k { + "space" => tl!("Space"), + "nothing" => tl!("Nothing"), + _ => tl!("Tab character"), + }; + craft_ui::ComboBox::from_id_salt("mll_follow").selected_text(follow(&l.follow)).show_ui(ui, |ui| { + for k in ["tab", "space", "nothing"] { + ui.selectable_value(&mut l.follow, k.to_string(), follow(k)); + } + }); + }); + }); + t.row(|r| { + r.cell(|ui| ui.add_enabled_ui(l.follow == "tab", |ui| ui.checkbox(&mut l.tab_on, tl!("Add tab stop at:")))); + r.cell(|ui| ui.add_enabled(l.follow == "tab" && l.tab_on, len(&mut l.tab_at, unit))); }); - ui.end_row(); - ui.add_enabled_ui(l.follow == "tab", |ui| ui.checkbox(&mut l.tab_on, tl!("Add tab stop at:"))); - ui.add_enabled(l.follow == "tab" && l.tab_on, len(&mut l.tab_at, unit)); - ui.end_row(); }); let (ok, cancel) = buttons(ui, tl!("OK")); if ok && let Err(e) = app.run("list.define", f.params()) { @@ -512,13 +539,15 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); }; heading(ui, "Show"); - crate::rtl::grid(ui, egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| { - ui.checkbox(&mut f.comments, tl!("Comments")); - ui.checkbox(&mut f.ink, tl!("Ink")); - ui.end_row(); - ui.checkbox(&mut f.insertions_deletions, tl!("Insertions and Deletions")); - ui.checkbox(&mut f.formatting, tl!("Formatting")); - ui.end_row(); + crate::rtl::Table::new("tco_show").columns(2).spacing(vec2(16.0, 4.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.checkbox(&mut f.comments, tl!("Comments"))); + r.cell(|ui| ui.checkbox(&mut f.ink, tl!("Ink"))); + }); + t.row(|r| { + r.cell(|ui| ui.checkbox(&mut f.insertions_deletions, tl!("Insertions and Deletions"))); + r.cell(|ui| ui.checkbox(&mut f.formatting, tl!("Formatting"))); + }); }); heading(ui, "Balloons"); for (v, l) in [ @@ -533,35 +562,42 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool ui.radio_value(&mut f.comment_display, v.to_string(), tl!(l)); } heading(ui, "Advanced"); - crate::rtl::grid(ui, egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Insertions:")); - choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS); - color(ui, &theme, &mut f.insert_color, "By author", None); - ui.end_row(); - ui.label(tl!("Deletions:")); - choice(ui, "tco_del", &mut f.delete_mark, DELETE_MARKS); - color(ui, &theme, &mut f.delete_color, "By author", None); - ui.end_row(); - ui.label(tl!("Changed lines:")); - choice( - ui, - "tco_bar", - &mut f.changed_lines, - &[("outside", "Outside border"), ("left", "Left border"), ("right", "Right border"), ("none", "None")], - ); - color(ui, &theme, &mut f.changed_lines_color, "Automatic", Some(wordcraft_layout::display::CHANGE_BAR)); - ui.end_row(); + crate::rtl::Table::new("tco_advanced").columns(3).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Insertions:"))); + r.cell(|ui| choice(ui, "tco_ins", &mut f.insert_mark, INSERT_MARKS)); + r.cell(|ui| color(ui, &theme, &mut f.insert_color, "By author", None)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Deletions:"))); + r.cell(|ui| choice(ui, "tco_del", &mut f.delete_mark, DELETE_MARKS)); + r.cell(|ui| color(ui, &theme, &mut f.delete_color, "By author", None)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Changed lines:"))); + r.cell(|ui| { + choice( + ui, + "tco_bar", + &mut f.changed_lines, + &[("outside", "Outside border"), ("left", "Left border"), ("right", "Right border"), ("none", "None")], + ) + }); + r.cell(|ui| color(ui, &theme, &mut f.changed_lines_color, "Automatic", Some(wordcraft_layout::display::CHANGE_BAR))); + }); }); heading(ui, "Moves"); - crate::rtl::grid(ui, egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Moved from:")); - choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS); - ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None)); - ui.end_row(); - ui.label(tl!("Moved to:")); - choice(ui, "tco_move_to", &mut f.move_to_mark, MOVE_TO_MARKS); - ui.push_id("tco_move_to_color", |ui| color(ui, &theme, &mut f.move_to_color, "By author", None)); - ui.end_row(); + crate::rtl::Table::new("tco_moves").columns(3).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Moved from:"))); + r.cell(|ui| choice(ui, "tco_move_from", &mut f.move_from_mark, DELETE_MARKS)); + r.cell(|ui| ui.push_id("tco_move_from_color", |ui| color(ui, &theme, &mut f.move_from_color, "By author", None))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Moved to:"))); + r.cell(|ui| choice(ui, "tco_move_to", &mut f.move_to_mark, MOVE_TO_MARKS)); + r.cell(|ui| ui.push_id("tco_move_to_color", |ui| color(ui, &theme, &mut f.move_to_color, "By author", None))); + }); }); ui.add_space(4.0); ui.checkbox(&mut f.track_formatting, tl!("Track formatting")); diff --git a/crates/ui-egui/src/dialogs/dialogs_notes.rs b/crates/ui-egui/src/dialogs/dialogs_notes.rs index 9507a1155c..ebf7525f6f 100644 --- a/crates/ui-egui/src/dialogs/dialogs_notes.rs +++ b/crates/ui-egui/src/dialogs/dialogs_notes.rs @@ -118,33 +118,48 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { } heading(ui, "Format"); let k = usize::from(f.endnote); - crate::rtl::grid(ui, egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Number format")); - if let Some(v) = f.fmt.get_mut(k) { - choice(ui, "notes_fmt", v, &FORMATS, false); - } - ui.end_row(); - ui.label(tl!("Custom mark:")); - ui.add(egui::TextEdit::singleline(&mut f.mark).desired_width(60.0).char_limit(10)); - ui.end_row(); - ui.label(tl!("Start at:")); - if let Some(v) = f.start.get_mut(k) { - ui.add(egui::DragValue::new(v).range(1..=32_767).speed(0.2)); - } - ui.end_row(); - ui.label(tl!("Numbering")); - if let Some(v) = f.restart.get_mut(k) { - let each_page: &[(&str, &str)] = if f.endnote { &[] } else { &[("eachPage", "Restart Each Page")] }; - let options: Vec<(&str, &str)> = - [("continuous", "Continuous numbering"), ("eachSect", "Restart Each Section")].into_iter().chain(each_page.iter().copied()).collect(); - choice(ui, "notes_restart", v, &options, true); - } - ui.end_row(); - ui.label(tl!("Apply to:")); - let mut scope = if f.whole { "document" } else { "section" }.to_string(); - choice(ui, "notes_scope", &mut scope, &[("document", "Whole document"), ("section", "This section")], true); - f.whole = scope == "document"; - ui.end_row(); + crate::rtl::Table::new("notes_format").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number format"))); + r.cell(|ui| { + if let Some(v) = f.fmt.get_mut(k) { + choice(ui, "notes_fmt", v, &FORMATS, false); + } + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Custom mark:"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.mark).desired_width(60.0).char_limit(10))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Start at:"))); + r.cell(|ui| { + if let Some(v) = f.start.get_mut(k) { + ui.add(egui::DragValue::new(v).range(1..=32_767).speed(0.2)); + } + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Numbering"))); + r.cell(|ui| { + if let Some(v) = f.restart.get_mut(k) { + let each_page: &[(&str, &str)] = if f.endnote { &[] } else { &[("eachPage", "Restart Each Page")] }; + let options: Vec<(&str, &str)> = [("continuous", "Continuous numbering"), ("eachSect", "Restart Each Section")] + .into_iter() + .chain(each_page.iter().copied()) + .collect(); + choice(ui, "notes_restart", v, &options, true); + } + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Apply to:"))); + r.cell(|ui| { + let mut scope = if f.whole { "document" } else { "section" }.to_string(); + choice(ui, "notes_scope", &mut scope, &[("document", "Whole document"), ("section", "This section")], true); + f.whole = scope == "document"; + }); + }); }); // Insert / Apply / Cancel. let (mut insert, mut apply, mut cancel) = (false, false, false); diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/caption.rs b/crates/ui-egui/src/dialogs/dialogs_refs/caption.rs index 984cad0735..d07076513c 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/caption.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/caption.rs @@ -105,19 +105,31 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> }); ui.add_space(6.0); let mut then = None; - crate::rtl::grid(ui, egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let label_label = ui.label(tl!("Label:")); - let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect(); - let combo = craft_ui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { - for (v, l) in &labels { - ui.selectable_value(&mut f.label, v.clone(), tl!(l)); - } + crate::rtl::Table::new("cap_opts").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let label_label = r.cell(|ui| ui.label(tl!("Label:"))); + r.cell(|ui| { + let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect(); + let combo = craft_ui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| { + for (v, l) in &labels { + ui.selectable_value(&mut f.label, v.clone(), tl!(l)); + } + }); + combo.response.labelled_by(label_label.id); + }); + }); + t.row(|r| { + let position_label = r.cell(|ui| ui.label(tl!("Position:"))); + r.cell(|ui| { + choice( + ui, + "cap_pos", + &mut f.position, + &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")], + &position_label, + ) + }); }); - combo.response.labelled_by(label_label.id); - ui.end_row(); - let position_label = ui.label(tl!("Position:")); - choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")], &position_label); - ui.end_row(); }); ui.checkbox(&mut f.exclude_label, tl!("Leave the label out of the caption")); ui.add_space(4.0); @@ -150,29 +162,38 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> /// Draws the caption numbering format, chapter-number toggle and chapter level and separator /// controls, returning to the caption dialog with the edited or original form. pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next { - crate::rtl::grid(ui, egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let format_label = ui.label(tl!("Format:")); - let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "cap_fmt", &mut f.format, &opts, &format_label); - ui.end_row(); + crate::rtl::Table::new("cap_num").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let format_label = r.cell(|ui| ui.label(tl!("Format:"))); + r.cell(|ui| { + let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "cap_fmt", &mut f.format, &opts, &format_label) + }); + }); }); ui.checkbox(&mut f.chapter, tl!("Add the chapter number")); ui.add_enabled_ui(f.chapter, |ui| { - crate::rtl::grid(ui, egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let chapter_label = ui.label(tl!("Chapters begin at:")); - let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect(); - let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default(); - let combo = craft_ui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| { - for (l, s) in &opts { - ui.selectable_value(&mut f.chapter_level, *l, s); - } + crate::rtl::Table::new("cap_chap").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let chapter_label = r.cell(|ui| ui.label(tl!("Chapters begin at:"))); + r.cell(|ui| { + let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect(); + let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default(); + let combo = craft_ui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| { + for (l, s) in &opts { + ui.selectable_value(&mut f.chapter_level, *l, s); + } + }); + combo.response.labelled_by(chapter_label.id); + }); + }); + t.row(|r| { + let separator_label = r.cell(|ui| ui.label(tl!("Separator:"))); + r.cell(|ui| { + let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "cap_sep", &mut f.separator, &opts, &separator_label) + }); }); - combo.response.labelled_by(chapter_label.id); - ui.end_row(); - let separator_label = ui.label(tl!("Separator:")); - let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "cap_sep", &mut f.separator, &opts, &separator_label); - ui.end_row(); }); }); ui.add_space(4.0); diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs b/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs index f028a84393..e3662cc299 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs @@ -65,16 +65,22 @@ impl CrossRefForm { /// Insert. pub(super) fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { let was = f.to.clone(); - crate::rtl::grid(ui, egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let type_label = ui.label(tl!("Reference type:")); - let types = ref_types(app); - let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); - choice(ui, "xref_type", &mut f.to, &types, &type_label); - ui.end_row(); - let show_label = ui.label(tl!("Insert reference to:")); - let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "xref_show", &mut f.show, &shows, &show_label); - ui.end_row(); + crate::rtl::Table::new("xref_opts").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let type_label = r.cell(|ui| ui.label(tl!("Reference type:"))); + r.cell(|ui| { + let types = ref_types(app); + let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect(); + choice(ui, "xref_type", &mut f.to, &types, &type_label) + }); + }); + t.row(|r| { + let show_label = r.cell(|ui| ui.label(tl!("Insert reference to:"))); + r.cell(|ui| { + let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "xref_show", &mut f.show, &shows, &show_label) + }); + }); }); if f.to != was { *f = CrossRefForm::read(app, &f.to); diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/index.rs b/crates/ui-egui/src/dialogs/dialogs_refs/index.rs index 85eed4dd6c..03d83a3c8b 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/index.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/index.rs @@ -68,28 +68,37 @@ pub(super) fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Nex }; sample(ui, &lines, f.right_align, leader_char(&f.leader)); ui.add_space(6.0); - crate::rtl::grid(ui, egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Type:")); - ui.horizontal(|ui| { - ui.radio_value(&mut f.kind, "indented".to_string(), tl!("Indented")); - ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in")); + crate::rtl::Table::new("idx").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Type:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.radio_value(&mut f.kind, "indented".to_string(), tl!("Indented")); + ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in")); + }); + }); }); - ui.end_row(); - let columns_label = ui.label(tl!("Columns:")); - ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)).labelled_by(columns_label.id); - ui.end_row(); - ui.label(""); - ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin")); - ui.end_row(); - let leader_label = ui.label(tl!("Tab leader:")); - ui.add_enabled_ui(f.right_align, |ui| { - let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); - choice(ui, "idx_leader", &mut f.leader, &opts, &leader_label); + t.row(|r| { + let columns_label = r.cell(|ui| ui.label(tl!("Columns:"))); + r.cell(|ui| ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)).labelled_by(columns_label.id)); + }); + t.row(|r| { + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"))); + }); + t.row(|r| { + let leader_label = r.cell(|ui| ui.label(tl!("Tab leader:"))); + r.cell(|ui| { + ui.add_enabled_ui(f.right_align, |ui| { + let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect(); + choice(ui, "idx_leader", &mut f.leader, &opts, &leader_label); + }); + }); + }); + t.row(|r| { + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.checkbox(&mut f.headings, tl!("Letter headings"))); }); - ui.end_row(); - ui.label(""); - ui.checkbox(&mut f.headings, tl!("Letter headings")); - ui.end_row(); }); ui.add_space(4.0); if ui.button(tl!("Mark Entry…")).clicked() { @@ -153,13 +162,15 @@ impl MarkForm { /// and Mark All running `references.markEntry`. pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next { heading(ui, "Index"); - crate::rtl::grid(ui, egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let entry_label = ui.label(tl!("Main entry:")); - ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id); - ui.end_row(); - let subentry_label = ui.label(tl!("Subentry:")); - ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)).labelled_by(subentry_label.id); - ui.end_row(); + crate::rtl::Table::new("xe").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let entry_label = r.cell(|ui| ui.label(tl!("Main entry:"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id)); + }); + t.row(|r| { + let subentry_label = r.cell(|ui| ui.label(tl!("Subentry:"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)).labelled_by(subentry_label.id)); + }); }); heading(ui, "Options"); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs b/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs index e03ac9354d..1a05e0ddd1 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs @@ -170,36 +170,43 @@ impl SourceForm { /// Draws a source's type and fields, optionally every bibliography field, and adds it as a /// bibliography source or inserts a citation on OK. pub(super) fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next { - crate::rtl::grid(ui, egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)), |ui| { - let kind_label = ui.label(tl!("Source type:")); - let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default(); - let combo = craft_ui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| { - for (k, l, _) in SOURCE_TYPES { - ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l)); - } + crate::rtl::Table::new("src").columns(2).spacing(vec2(10.0, 5.0)).show(ui, |t| { + t.row(|r| { + let kind_label = r.cell(|ui| ui.label(tl!("Source type:"))); + r.cell(|ui| { + let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default(); + let combo = craft_ui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| { + for (k, l, _) in SOURCE_TYPES { + ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l)); + } + }); + combo.response.labelled_by(kind_label.id); + }); }); - combo.response.labelled_by(kind_label.id); - ui.end_row(); let edit = f.edit; let corporate = f.corporate; for (key, label) in f.fields() { // Corporate Author swaps the Author box for one organisation's name (#4848). let (key, label) = if key == "author" && corporate { ("corporate", "Corporate Author") } else { (key, label) }; - let field_label = ui.label(format!("{}:", tl!(label))); - if let Some(v) = field_mut(&mut f.source, key) { - let hint = match key { - "author" => tl!("Last, First; Last, First"), - "tag" => tl!("Made from the author and year when empty"), - _ => "", - }; - ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint)) - .labelled_by(field_label.id); - } - ui.end_row(); + t.row(|r| { + let field_label = r.cell(|ui| ui.label(format!("{}:", tl!(label)))); + r.cell(|ui| { + if let Some(v) = field_mut(&mut f.source, key) { + let hint = match key { + "author" => tl!("Last, First; Last, First"), + "tag" => tl!("Made from the author and year when empty"), + _ => "", + }; + ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint)) + .labelled_by(field_label.id); + } + }); + }); if matches!(key, "author" | "corporate") { - ui.label(""); - ui.checkbox(&mut f.corporate, tl!("Corporate Author")); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.checkbox(&mut f.corporate, tl!("Corporate Author"))); + }); } } }); diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/toc.rs b/crates/ui-egui/src/dialogs/dialogs_refs/toc.rs index feac34f2ca..6495310e95 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/toc.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/toc.rs @@ -145,14 +145,19 @@ pub(super) fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, wa // A solid scroll bar takes its own room beside the list instead of floating over the level boxes. ui.spacing_mut().scroll = egui::style::ScrollStyle::solid(); egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| { - crate::rtl::grid(ui, egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)), |ui| { + crate::rtl::Table::new("toc_styles").columns(2).spacing(vec2(10.0, 3.0)).show(ui, |t| { for (name, level) in f.style_levels.iter_mut() { // Built-in styles read in the interface language; the document keeps the English name. let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name); - let style_label = ui.label(shown); - ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") })) - .labelled_by(style_label.id); - ui.end_row(); + t.row(|r| { + let style_label = r.cell(|ui| ui.label(shown)); + r.cell(|ui| { + ui.add( + egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") }), + ) + .labelled_by(style_label.id) + }); + }); } }); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_review.rs b/crates/ui-egui/src/dialogs/dialogs_review.rs index 62234d495c..b790d71990 100644 --- a/crates/ui-egui/src/dialogs/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs/dialogs_review.rs @@ -197,47 +197,55 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool { if !browse { ui.label(egui::RichText::new(tl!("Comparing with another file needs the desktop app.")).small().weak()); } - crate::rtl::grid(ui, egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)), |ui| { - crate::rtl::vertical(ui, |ui| { - heading(ui, "Original document"); - ui.radio_value(&mut f.original_current, true, tl!("This document")); - ui.horizontal(|ui| { - ui.radio_value(&mut f.original_current, false, ""); - ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.original).desired_width(170.0).hint_text(tl!("Another file"))); - if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() { - pick = Some(false); - } + crate::rtl::Table::new("compare_files").columns(2).spacing(vec2(16.0, 4.0)).top().show(ui, |t| { + t.row(|r| { + r.cell(|ui| { + crate::rtl::vertical(ui, |ui| { + heading(ui, "Original document"); + ui.radio_value(&mut f.original_current, true, tl!("This document")); + ui.horizontal(|ui| { + ui.radio_value(&mut f.original_current, false, ""); + ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.original).desired_width(170.0).hint_text(tl!("Another file"))); + if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() { + pick = Some(false); + } + }); + }); + }); + r.cell(|ui| { + crate::rtl::vertical(ui, |ui| { + heading(ui, "Revised document"); + ui.horizontal(|ui| { + ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.revised).desired_width(170.0)); + if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() { + pick = Some(true); + } + }); + ui.horizontal(|ui| { + ui.label(tl!("Label changes with:")); + ui.add(egui::TextEdit::singleline(&mut f.author).desired_width(120.0)); + }); + }); }); }); - crate::rtl::vertical(ui, |ui| { - heading(ui, "Revised document"); - ui.horizontal(|ui| { - ui.add_enabled(browse, egui::TextEdit::singleline(&mut f.revised).desired_width(170.0)); - if ui.add_enabled(browse, egui::Button::new(tl!("Browse…"))).clicked() { - pick = Some(true); - } - }); - ui.horizontal(|ui| { - ui.label(tl!("Label changes with:")); - ui.add(egui::TextEdit::singleline(&mut f.author).desired_width(120.0)); - }); - }); - ui.end_row(); }); ui.add_space(4.0); - if ui.button(if f.more { tl!("<< Less") } else { tl!("More >>") }).clicked() { + if ui.button(crate::rtl::arrows(if f.more { tl!("<< Less") } else { tl!("More >>") })).clicked() { f.more = !f.more; } if f.more { heading(ui, "Comparison settings"); - crate::rtl::grid(ui, egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)), |ui| { - for (i, (_, label)) in COMPARE_SETTINGS.iter().enumerate() { - if let Some(on) = f.settings.get_mut(i) { - ui.checkbox(on, tl!(label)); - } - if i % 2 == 1 { - ui.end_row(); - } + crate::rtl::Table::new("compare_settings").columns(2).spacing(vec2(16.0, 2.0)).show(ui, |t| { + for (pair, labels) in COMPARE_SETTINGS.chunks(2).enumerate() { + t.row(|r| { + for (j, (_, label)) in labels.iter().enumerate() { + r.cell(|ui| { + if let Some(on) = f.settings.get_mut(pair * 2 + j) { + ui.checkbox(on, tl!(label)); + } + }); + } + }); } }); ui.label( @@ -245,19 +253,24 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool { .small() .weak(), ); - crate::rtl::grid(ui, egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)), |ui| { - crate::rtl::vertical(ui, |ui| { - heading(ui, "Show changes at:"); - ui.radio_value(&mut f.character_level, false, tl!("Word level")); - ui.radio_value(&mut f.character_level, true, tl!("Character level")); + crate::rtl::Table::new("compare_show").columns(2).spacing(vec2(24.0, 2.0)).top().show(ui, |t| { + t.row(|r| { + r.cell(|ui| { + crate::rtl::vertical(ui, |ui| { + heading(ui, "Show changes at:"); + ui.radio_value(&mut f.character_level, false, tl!("Word level")); + ui.radio_value(&mut f.character_level, true, tl!("Character level")); + }); + }); + r.cell(|ui| { + crate::rtl::vertical(ui, |ui| { + heading(ui, "Show changes in:"); + for (v, l) in [("original", "Original document"), ("revised", "Revised document"), ("new", "New document")] { + ui.radio_value(&mut f.show_in, v.to_string(), tl!(l)); + } + }); + }); }); - crate::rtl::vertical(ui, |ui| { - heading(ui, "Show changes in:"); - for (v, l) in [("original", "Original document"), ("revised", "Revised document"), ("new", "New document")] { - ui.radio_value(&mut f.show_in, v.to_string(), tl!(l)); - } - }); - ui.end_row(); }); } if let Some(revised) = pick { @@ -445,13 +458,12 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { ui.checkbox(&mut o.cap_days, tl!("Capitalize names of days")); ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key")); ui.checkbox(&mut o.replace_text, tl!("Replace text as you type")); - crate::rtl::grid(ui, egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)), |ui| { - let replace_label = ui.label(tl!("Replace:")); - let with_label = ui.label(tl!("With:")); - ui.end_row(); - ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)).labelled_by(replace_label.id); - ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)).labelled_by(with_label.id); - ui.end_row(); + crate::rtl::Table::new("ac_boxes").columns(2).spacing(vec2(8.0, 2.0)).show(ui, |t| { + let (replace_label, with_label) = t.row(|r| (r.cell(|ui| ui.label(tl!("Replace:"))), r.cell(|ui| ui.label(tl!("With:"))))); + t.row(|r| { + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)).labelled_by(replace_label.id)); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)).labelled_by(with_label.id)); + }); }); // The list, filtered to what's typed in Replace (as Word scrolls to it). let list = f.options.list(); @@ -462,14 +474,17 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) { // #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)); } - crate::rtl::grid(ui, egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true), |ui| { + crate::rtl::Table::new("ac_list_grid").columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |t| { for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) { let selected = f.from == *a; - if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() { - picked = Some((a.clone(), b.clone())); - } - ui.label(b.as_str()); - ui.end_row(); + t.row(|r| { + r.cell(|ui| { + if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() { + picked = Some((a.clone(), b.clone())); + } + }); + r.cell(|ui| ui.label(b.as_str())); + }); } }); }); @@ -543,11 +558,12 @@ fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) { }); let filter = f.math_filter.trim().trim_start_matches('\\').to_string(); egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| { - crate::rtl::grid(ui, egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(true), |ui| { + crate::rtl::Table::new("ac_math_grid").columns(2).spacing(vec2(16.0, 1.0)).striped(true).show(ui, |t| { for (name, c) in wordcraft_doc::math_symbols::SYMBOLS.iter().filter(|(n, _)| filter.is_empty() || n.starts_with(filter.as_str())) { - ui.label(format!("\\{name}")); - ui.label(c.to_string()); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(format!("\\{name}"))); + r.cell(|ui| ui.label(c.to_string())); + }); } }); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs index 610f2a274d..7223b93485 100644 --- a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs @@ -222,7 +222,7 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { crate::rtl::vertical(ui, |ui| { ui.add_space(h / 2.0 - 10.0); let can_add = sel.group.is_some() && !f.command.is_empty(); - let add = ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))); + let add = ui.add_enabled(can_add, egui::Button::new(crate::rtl::arrows(tl!("Add >>"))).min_size(vec2(96.0, 0.0))); if add.on_disabled_hover_text(tl!("Pick a custom group (New Group makes one)")).clicked() { let mut m = address(&sel); m.remove("command"); @@ -235,7 +235,7 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { } } let can_remove = sel.group.is_some() || tab_custom; - if ui.add_enabled(can_remove, egui::Button::new(tl!("<< Remove")).min_size(vec2(96.0, 0.0))).clicked() + if ui.add_enabled(can_remove, egui::Button::new(crate::rtl::arrows(tl!("<< Remove"))).min_size(vec2(96.0, 0.0))).clicked() && change(app, f, json!({"remove": address(&sel)})).is_some() { f.selected = match (&sel.group, &sel.command) { @@ -357,12 +357,12 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { crate::rtl::vertical(ui, |ui| { ui.add_space(h / 2.0 - 10.0); let can_add = !f.command.is_empty() && !ids.contains(&f.command); - if ui.add_enabled(can_add, egui::Button::new(tl!("Add >>")).min_size(vec2(96.0, 0.0))).clicked() + if ui.add_enabled(can_add, egui::Button::new(crate::rtl::arrows(tl!("Add >>"))).min_size(vec2(96.0, 0.0))).clicked() && change(app, f, json!({"qatAdd": f.command})).is_some() { f.qat_selected = f.command.clone(); } - if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!("<< Remove")).min_size(vec2(96.0, 0.0))).clicked() { + if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(crate::rtl::arrows(tl!("<< Remove"))).min_size(vec2(96.0, 0.0))).clicked() { change(app, f, json!({"qatRemove": f.qat_selected})); } }); diff --git a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs index cc60434553..74ed7e8aac 100644 --- a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs @@ -186,57 +186,61 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool { crate::rtl::vertical(ui, |ui| { ui.set_width(3.0 * 96.0 + 16.0); ui.set_min_height(2.0 * 96.0 + 8.0); - crate::rtl::grid(ui, egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)), |ui| { - for (k, layout) in f.shown().into_iter().enumerate() { - let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 96.0), Sense::click()); - let on = f.layout == layout; - let fill = if on { - t.checked - } else if resp.hovered() { - t.hover - } else { - t.input - }; - ui.painter().rect(rect, 4.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); - preview( - ui.painter(), - Rect::from_min_size(rect.min + vec2(14.0, 4.0), vec2(68.0, 62.0)), - layout, - SmartArtColors::default(), - &theme, - t.text_dim, - ); - // A label wider than the tile (a long translation) breaks at its middle space. - let label = tl!(layout.label()); - let width = |text: &str| ui.ctx().fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(10.5), t.text).size().x); - let lines = if width(label) <= rect.width() - 6.0 { - vec![label.to_string()] - } else { - let split = label - .match_indices(' ') - .map(|(i, _)| (label[..i].to_string(), label[i + 1..].to_string())) - .min_by(|a, b| width(&a.0).max(width(&a.1)).total_cmp(&width(&b.0).max(width(&b.1)))); - split.map_or_else(|| vec![label.to_string()], |(a, b)| vec![a, b]) - }; - for (n, line) in lines.iter().rev().enumerate() { - ui.painter().text( - pos2(rect.center().x, rect.bottom() - 11.0 - 12.5 * n as f32), - egui::Align2::CENTER_CENTER, - line, - regular(10.5), - t.text, - ); - } - let resp = resp.on_hover_text(label); - if resp.clicked() { - f.layout = layout; - } - if resp.double_clicked() { - chosen = Some(layout); - } - if k % 3 == 2 { - ui.end_row(); - } + let shown = f.shown(); + crate::rtl::Table::new("smart_art_layouts").columns(0).spacing(vec2(8.0, 8.0)).show(ui, |table| { + for layouts in shown.chunks(3) { + table.row(|r| { + for layout in layouts.iter().copied() { + r.cell(|ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 96.0), Sense::click()); + let on = f.layout == layout; + let fill = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + t.input + }; + ui.painter().rect(rect, 4.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); + preview( + ui.painter(), + Rect::from_min_size(rect.min + vec2(14.0, 4.0), vec2(68.0, 62.0)), + layout, + SmartArtColors::default(), + &theme, + t.text_dim, + ); + // A label wider than the tile (a long translation) breaks at its middle space. + let label = tl!(layout.label()); + let width = |text: &str| ui.ctx().fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(10.5), t.text).size().x); + let lines = if width(label) <= rect.width() - 6.0 { + vec![label.to_string()] + } else { + let split = label + .match_indices(' ') + .map(|(i, _)| (label[..i].to_string(), label[i + 1..].to_string())) + .min_by(|a, b| width(&a.0).max(width(&a.1)).total_cmp(&width(&b.0).max(width(&b.1)))); + split.map_or_else(|| vec![label.to_string()], |(a, b)| vec![a, b]) + }; + for (n, line) in lines.iter().rev().enumerate() { + ui.painter().text( + pos2(rect.center().x, rect.bottom() - 11.0 - 12.5 * n as f32), + egui::Align2::CENTER_CENTER, + line, + regular(10.5), + t.text, + ); + } + let resp = resp.on_hover_text(label); + if resp.clicked() { + f.layout = layout; + } + if resp.double_clicked() { + chosen = Some(layout); + } + }); + } + }); } }); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_table_style.rs b/crates/ui-egui/src/dialogs/dialogs_table_style.rs index db3987a51b..1b253222e8 100644 --- a/crates/ui-egui/src/dialogs/dialogs_table_style.rs +++ b/crates/ui-egui/src/dialogs/dialogs_table_style.rs @@ -469,32 +469,39 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { .collect(); let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); let mut rebased = false; - crate::rtl::grid(ui, egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let name_label = ui.label(tl!("Name:")); - ui.text_edit_singleline(name).labelled_by(name_label.id); - ui.end_row(); - let base_label = ui.label(tl!("Style based on:")); - let base = craft_ui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { - for (sid, sname) in &styles { - if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { - *based_on = sid.clone(); - rebased = true; - } - } + crate::rtl::Table::new("tstyle").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let name_label = r.cell(|ui| ui.label(tl!("Name:"))); + r.cell(|ui| ui.text_edit_singleline(name).labelled_by(name_label.id)); }); - base.response.labelled_by(base_label.id); - ui.end_row(); - let region_label = ui.label(tl!("Apply formatting to:")); - let part = craft_ui::ComboBox::from_id_salt("tstyle_region") - .width(200.0) - .selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))) - .show_ui(ui, |ui| { - for (i, (_, n)) in PARTS.iter().enumerate() { - ui.selectable_value(region, i, tl!(*n)); - } + t.row(|r| { + let base_label = r.cell(|ui| ui.label(tl!("Style based on:"))); + r.cell(|ui| { + let base = craft_ui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + for (sid, sname) in &styles { + if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { + *based_on = sid.clone(); + rebased = true; + } + } + }); + base.response.labelled_by(base_label.id); }); - part.response.labelled_by(region_label.id); - ui.end_row(); + }); + t.row(|r| { + let region_label = r.cell(|ui| ui.label(tl!("Apply formatting to:"))); + r.cell(|ui| { + let part = craft_ui::ComboBox::from_id_salt("tstyle_region") + .width(200.0) + .selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))) + .show_ui(ui, |ui| { + for (i, (_, n)) in PARTS.iter().enumerate() { + ui.selectable_value(region, i, tl!(*n)); + } + }); + part.response.labelled_by(region_label.id); + }); + }); }); // A new style shows its new base's formatting; a modified one keeps its own. if rebased && id.is_none() { diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 3ea3339757..77344875f9 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -723,20 +723,24 @@ fn link_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); - crate::rtl::grid(ui, egui::Grid::new("lnk").num_columns(2), |ui| { - ui.label(tl!("Text to display:")); - ui.text_edit_singleline(text); - ui.end_row(); + crate::rtl::Table::new("lnk").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Text to display:"))); + r.cell(|ui| ui.text_edit_singleline(text)); + }); if !*place { - ui.label(tl!("Address:")); - let mut out = egui::TextEdit::singleline(url).id(egui::Id::unique("link_address")).show(ui); - if std::mem::take(focus) { - out.response.request_focus(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count())); - out.state.cursor.set_char_range(Some(all)); - out.state.store(ui.ctx(), out.response.id); - } - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Address:"))); + r.cell(|ui| { + let mut out = egui::TextEdit::singleline(url).id(egui::Id::unique("link_address")).show(ui); + if std::mem::take(focus) { + out.response.request_focus(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(url.chars().count())); + out.state.cursor.set_char_range(Some(all)); + out.state.store(ui.ctx(), out.response.id); + } + }); + }); } }); if *place { @@ -805,7 +809,7 @@ fn word_count_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::WordCount { stats } = d else { return false; }; - crate::rtl::grid(ui, egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)), |ui| { + crate::rtl::Table::new("wc").columns(2).spacing(vec2(30.0, 6.0)).show(ui, |t| { for (l, k) in [ ("Pages", "pages"), ("Words", "words"), @@ -814,9 +818,10 @@ fn word_count_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("Paragraphs", "paragraphs"), ("Lines", "lines"), ] { - ui.label(tl!(l)); - ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default()); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!(l))); + r.cell(|ui| ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default())); + }); } }); let mut include = stats.get("includeTextBoxes").and_then(Value::as_bool).unwrap_or(true); @@ -841,7 +846,7 @@ fn zoom_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { // Logarithmic like the status bar slider, and wide enough to aim: the default 100 pt // linear slider jumped from 100% to 480% within a short drag (issue #67). ui.spacing_mut().slider_width = 220.0; - ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%")); + crate::rtl::island(ui, |ui| ui.add(egui::Slider::new(percent, 10.0..=500.0).logarithmic(true).step_by(1.0).suffix("%"))); let mut fit = None; ui.horizontal(|ui| { if ui.button(tl!("Page width")).clicked() { @@ -898,32 +903,41 @@ fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("right", "Right"), ("rightVertical", "Right (vertical)"), ]; - crate::rtl::grid(ui, egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Base text:")); - ui.label(egui::RichText::new(base.as_str()).strong()); - ui.end_row(); - let ruby_label = ui.label(tl!("Ruby text:")); - let r = ui.text_edit_singleline(ruby).labelled_by(ruby_label.id); - // Typing goes to the Ruby text box, not the selected text in the document (#3914). - if std::mem::take(focus) { - r.request_focus(); - } - ui.end_row(); - let align_label = ui.label(tl!("Alignment:")); - let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l); - let combo = craft_ui::ComboBox::from_id_salt("phonetic_align").selected_text(tl!(shown)).show_ui(ui, |ui| { - for (k, l) in ALIGNS { - ui.selectable_value(align, k.to_string(), tl!(l)); - } + crate::rtl::Table::new("phonetic").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Base text:"))); + r.cell(|ui| ui.label(egui::RichText::new(base.as_str()).strong())); + }); + t.row(|r| { + let ruby_label = r.cell(|ui| ui.label(tl!("Ruby text:"))); + r.cell(|ui| { + let field = ui.text_edit_singleline(ruby).labelled_by(ruby_label.id); + // Typing goes to the Ruby text box, not the selected text in the document (#3914). + if std::mem::take(focus) { + field.request_focus(); + } + }); + }); + t.row(|r| { + let align_label = r.cell(|ui| ui.label(tl!("Alignment:"))); + r.cell(|ui| { + let shown = ALIGNS.iter().find(|(k, _)| k == align).map_or("Center", |(_, l)| l); + let combo = craft_ui::ComboBox::from_id_salt("phonetic_align").selected_text(tl!(shown)).show_ui(ui, |ui| { + for (k, l) in ALIGNS { + ui.selectable_value(align, k.to_string(), tl!(l)); + } + }); + combo.response.labelled_by(align_label.id); + }); + }); + t.row(|r| { + let offset_label = r.cell(|ui| ui.label(tl!("Offset (pt):"))); + r.cell(|ui| ui.add(egui::DragValue::new(offset).speed(0.5).range(-100.0..=100.0)).labelled_by(offset_label.id)); + }); + t.row(|r| { + let size_label = r.cell(|ui| ui.label(tl!("Size (pt):"))); + r.cell(|ui| ui.add(egui::DragValue::new(size).speed(0.5).range(1.0..=200.0)).labelled_by(size_label.id)); }); - combo.response.labelled_by(align_label.id); - ui.end_row(); - let offset_label = ui.label(tl!("Offset (pt):")); - ui.add(egui::DragValue::new(offset).speed(0.5).range(-100.0..=100.0)).labelled_by(offset_label.id); - ui.end_row(); - let size_label = ui.label(tl!("Size (pt):")); - ui.add(egui::DragValue::new(size).speed(0.5).range(1.0..=200.0)).labelled_by(size_label.id); - ui.end_row(); }); let mut remove = false; if *existing { @@ -943,13 +957,15 @@ fn new_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::NewStyle { name, based_on, back } = d else { return false; }; - crate::rtl::grid(ui, egui::Grid::new("ns").num_columns(2), |ui| { - ui.label(tl!("Name:")); - ui.text_edit_singleline(name); - ui.end_row(); - ui.label(tl!("Style based on:")); - ui.text_edit_singleline(based_on); - ui.end_row(); + crate::rtl::Table::new("ns").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Name:"))); + r.cell(|ui| ui.text_edit_singleline(name)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Style based on:"))); + r.cell(|ui| ui.text_edit_singleline(based_on)); + }); }); ui.label(egui::RichText::new(tl!("The new style takes the formatting of the current paragraph.")).small().weak()); let (ok, cancel) = buttons(ui, tl!("OK")); @@ -967,34 +983,43 @@ fn modify_style_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::ModifyStyle { back, resume, id, name, font, size, bold, italic, color, before, after } = d else { return false; }; - crate::rtl::grid(ui, egui::Grid::new("ms").num_columns(2), |ui| { - let name_label = ui.label(tl!("Name:")); - ui.text_edit_singleline(name).labelled_by(name_label.id); - ui.end_row(); - ui.label(tl!("Font:")); - let shown = app.previews.font_shown(font); - let fams = app.previews.families(); - let prev = app.previews.font_preview_fn(); - if let Some(f) = crate::widgets::font_combo(ui, "ms_font", 220.0, &shown, &fams, Some(&*prev)) { - if f == crate::widgets::ADD_FONT { - let _ = app.run("font.add", json!({})); - } else { - *font = app.previews.font_stored(&f); - } - } - ui.end_row(); - let size_label = ui.label(tl!("Size:")); - ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)).labelled_by(size_label.id); - ui.end_row(); - let color_label = ui.label(tl!("Color:")); - ui.text_edit_singleline(color).labelled_by(color_label.id); - ui.end_row(); - ui.label(tl!("Space before/after:")); - ui.horizontal(|ui| { - ui.add(egui::DragValue::new(before).range(0.0..=1584.0)).accessible_name(tl!("Space before")); - ui.add(egui::DragValue::new(after).range(0.0..=1584.0)).accessible_name(tl!("Space after")); + crate::rtl::Table::new("ms").columns(2).show(ui, |t| { + t.row(|r| { + let name_label = r.cell(|ui| ui.label(tl!("Name:"))); + r.cell(|ui| ui.text_edit_singleline(name).labelled_by(name_label.id)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Font:"))); + r.cell(|ui| { + let shown = app.previews.font_shown(font); + let fams = app.previews.families(); + let prev = app.previews.font_preview_fn(); + if let Some(f) = crate::widgets::font_combo(ui, "ms_font", 220.0, &shown, &fams, Some(&*prev)) { + if f == crate::widgets::ADD_FONT { + let _ = app.run("font.add", json!({})); + } else { + *font = app.previews.font_stored(&f); + } + } + }); + }); + t.row(|r| { + let size_label = r.cell(|ui| ui.label(tl!("Size:"))); + r.cell(|ui| ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5)).labelled_by(size_label.id)); + }); + t.row(|r| { + let color_label = r.cell(|ui| ui.label(tl!("Color:"))); + r.cell(|ui| ui.text_edit_singleline(color).labelled_by(color_label.id)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Space before/after:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(before).range(0.0..=1584.0)).accessible_name(tl!("Space before")); + ui.add(egui::DragValue::new(after).range(0.0..=1584.0)).accessible_name(tl!("Space after")); + }); + }); }); - ui.end_row(); }); ui.horizontal(|ui| { ui.checkbox(bold, tl!("Bold")); @@ -1297,16 +1322,20 @@ fn encrypt_password_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bo }; ui.label(tl!("Anyone who opens the document will need this password.")); ui.add_space(4.0); - crate::rtl::grid(ui, egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Password:")); - let r = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0)); - if password.is_empty() && confirm.is_empty() && !r.has_focus() { - r.request_focus(); - } - ui.end_row(); - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(confirm.as_mut_string()).password(true).desired_width(220.0)); - ui.end_row(); + crate::rtl::Table::new("encrypt_password").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Password:"))); + r.cell(|ui| { + let field = ui.add(egui::TextEdit::singleline(password.as_mut_string()).password(true).desired_width(220.0)); + if password.is_empty() && confirm.is_empty() && !field.has_focus() { + field.request_focus(); + } + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Confirm password:"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(confirm.as_mut_string()).password(true).desired_width(220.0))); + }); }); ui.label( egui::RichText::new(tl!( @@ -1480,28 +1509,34 @@ fn merge_rule_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { "Insert one text or another, depending on a field's value." })); ui.add_space(4.0); - crate::rtl::grid(ui, egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Field:")); - if fields.is_empty() { - ui.text_edit_singleline(field); - } else { - craft_ui::ComboBox::from_id_salt("merge_rule_field").selected_text(field.as_str()).width(200.0).show_ui(ui, |ui| { - for f in fields.iter() { - ui.selectable_value(field, f.clone(), f); + crate::rtl::Table::new("merge_rule").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Field:"))); + r.cell(|ui| { + if fields.is_empty() { + ui.text_edit_singleline(field); + } else { + craft_ui::ComboBox::from_id_salt("merge_rule_field").selected_text(field.as_str()).width(200.0).show_ui(ui, |ui| { + for f in fields.iter() { + ui.selectable_value(field, f.clone(), f); + } + }); } }); - } - ui.end_row(); - ui.label(tl!("Equals:")); - ui.text_edit_singleline(value); - ui.end_row(); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Equals:"))); + r.cell(|ui| ui.text_edit_singleline(value)); + }); if !skip { - ui.label(tl!("Then insert:")); - ui.text_edit_singleline(then); - ui.end_row(); - ui.label(tl!("Otherwise insert:")); - ui.text_edit_singleline(els); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Then insert:"))); + r.cell(|ui| ui.text_edit_singleline(then)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Otherwise insert:"))); + r.cell(|ui| ui.text_edit_singleline(els)); + }); } }); let (ok, cancel) = buttons(ui, tl!("OK")); @@ -1522,14 +1557,15 @@ fn match_fields_dialog(ui: &mut Ui, d: &mut Dialog) -> bool { } else { ui.label(tl!("The recipient-list field that fills each part of an address block and greeting line.")); ui.add_space(4.0); - crate::rtl::grid(ui, egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true), |ui| { + crate::rtl::Table::new("match_fields").columns(2).spacing(vec2(24.0, 6.0)).striped(true).show(ui, |t| { for (part, key) in ADDRESS_PARTS { - ui.label(tl!(part)); - match address.get(key).and_then(Value::as_str) { - Some(f) => ui.label(egui::RichText::new(f).strong()), - None => ui.label(egui::RichText::new(tl!("(not matched)")).weak()), - }; - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!(part))); + r.cell(|ui| match address.get(key).and_then(Value::as_str) { + Some(f) => ui.label(egui::RichText::new(f).strong()), + None => ui.label(egui::RichText::new(tl!("(not matched)")).weak()), + }); + }); } }); } diff --git a/crates/ui-egui/src/dialogs/forms.rs b/crates/ui-egui/src/dialogs/forms.rs index 000b385e0a..f2dd25fbf0 100644 --- a/crates/ui-egui/src/dialogs/forms.rs +++ b/crates/ui-egui/src/dialogs/forms.rs @@ -390,77 +390,95 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) { ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); }; heading(ui, "Table"); - crate::rtl::grid(ui, egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.checkbox(&mut f.width_on, tl!("Preferred width:")); - ui.add_enabled(f.width_on, len(&mut f.width, 0.1, unit)); - ui.end_row(); - ui.label(tl!("Alignment:")); - ui.horizontal(|ui| { - for (v, l) in [("left", "Left"), ("center", "Center"), ("right", "Right")] { - ui.radio_value(&mut f.align, v.to_string(), tl!(l)); - } + crate::rtl::Table::new("tp_table").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.checkbox(&mut f.width_on, tl!("Preferred width:"))); + r.cell(|ui| ui.add_enabled(f.width_on, len(&mut f.width, 0.1, unit))); }); - ui.end_row(); - ui.label(tl!("Indent from left:")); - ui.add_enabled(f.align == "left", egui::DragValue::new(&mut f.indent).speed(0.01).range(-11.0..=22.0).measure(unit).max_decimals(2)); - ui.end_row(); - ui.label(tl!("Table direction:")); - ui.horizontal(|ui| { - ui.radio_value(&mut f.rtl, false, tl!("Left-to-right")); - ui.radio_value(&mut f.rtl, true, tl!("Right-to-left")); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Alignment:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + for (v, l) in [("left", "Left"), ("center", "Center"), ("right", "Right")] { + ui.radio_value(&mut f.align, v.to_string(), tl!(l)); + } + }); + }); }); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Indent from left:"))); + r.cell(|ui| { + ui.add_enabled(f.align == "left", egui::DragValue::new(&mut f.indent).speed(0.01).range(-11.0..=22.0).measure(unit).max_decimals(2)) + }); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Table direction:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.radio_value(&mut f.rtl, false, tl!("Left-to-right")); + ui.radio_value(&mut f.rtl, true, tl!("Right-to-left")); + }); + }); + }); }); heading(ui, "Row"); - crate::rtl::grid(ui, egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let specify = ui.checkbox(&mut f.row_height_on, tl!("Specify height:")); - ui.horizontal(|ui| { - ui.add_enabled(f.row_height_on, len(&mut f.row_height, 0.02, unit)); - ui.add_enabled_ui(f.row_height_on, |ui| { - let rule = craft_ui::ComboBox::from_id_salt("tp_rule").selected_text(tl!(if f.row_exact { "Exactly" } else { "At least" })).show_ui( - ui, - |ui| { - ui.selectable_value(&mut f.row_exact, false, tl!("At least")); - ui.selectable_value(&mut f.row_exact, true, tl!("Exactly")); - }, - ); - // A screen reader names the rule by the box it belongs to. - rule.response.labelled_by(specify.id); + crate::rtl::Table::new("tp_row").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let specify = r.cell(|ui| ui.checkbox(&mut f.row_height_on, tl!("Specify height:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + ui.add_enabled(f.row_height_on, len(&mut f.row_height, 0.02, unit)); + ui.add_enabled_ui(f.row_height_on, |ui| { + let rule = craft_ui::ComboBox::from_id_salt("tp_rule") + .selected_text(tl!(if f.row_exact { "Exactly" } else { "At least" })) + .show_ui(ui, |ui| { + ui.selectable_value(&mut f.row_exact, false, tl!("At least")); + ui.selectable_value(&mut f.row_exact, true, tl!("Exactly")); + }); + // A screen reader names the rule by the box it belongs to. + rule.response.labelled_by(specify.id); + }); + }); }); }); - ui.end_row(); }); super::paragraph::para_check(ui, &mut f.allow_break, tl!("Allow row to break across pages"), "allowBreak", &mut f.mixed); super::paragraph::para_check(ui, &mut f.header_row, tl!("Repeat as header row on each page"), "headerRow", &mut f.mixed); heading(ui, "Column"); - crate::rtl::grid(ui, egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.label(tl!("Preferred width:")); - ui.add(len(&mut f.column_width, 0.1, unit)); - ui.end_row(); + crate::rtl::Table::new("tp_col").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(tl!("Preferred width:"))); + r.cell(|ui| ui.add(len(&mut f.column_width, 0.1, unit))); + }); }); heading(ui, "Cell"); - crate::rtl::grid(ui, egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:")); - ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit)); - ui.end_row(); - ui.label(tl!("Vertical alignment:")); - ui.horizontal(|ui| { - for (v, l) in [("top", "Top"), ("center", "Center"), ("bottom", "Bottom")] { - ui.radio_value(&mut f.valign, v.to_string(), tl!(l)); - } + crate::rtl::Table::new("tp_cell").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.checkbox(&mut f.cell_width_on, tl!("Preferred width:"))); + r.cell(|ui| ui.add_enabled(f.cell_width_on, len(&mut f.cell_width, 0.1, unit))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Vertical alignment:"))); + r.cell(|ui| { + ui.horizontal(|ui| { + for (v, l) in [("top", "Top"), ("center", "Center"), ("bottom", "Bottom")] { + ui.radio_value(&mut f.valign, v.to_string(), tl!(l)); + } + }); + }); }); - ui.end_row(); }); } pub(super) fn cell_margins_dialog(ui: &mut Ui, f: &mut CellMarginsForm) { let unit = crate::options::unit(); let max = 144.0 / unit.pt_per_unit(); - crate::rtl::grid(ui, egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { + crate::rtl::Table::new("cell_margins").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { for (label, value) in [("Top:", &mut f.top), ("Left:", &mut f.left), ("Bottom:", &mut f.bottom), ("Right:", &mut f.right)] { - ui.label(tl!(label)); - ui.add(egui::DragValue::new(value).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2)); - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(tl!(label))); + r.cell(|ui| ui.add(egui::DragValue::new(value).speed(0.01).range(0.0..=max).measure(unit).max_decimals(2))); + }); } }); } @@ -512,14 +530,18 @@ pub(super) fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPr } }); ui.separator(); - crate::rtl::grid(ui, egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { - let name = ui.label(tl!("Name:")); - ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME)) - .labelled_by(name.id); - ui.end_row(); - let value = ui.label(tl!("Value:")); - ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(300.0)).labelled_by(value.id); - ui.end_row(); + crate::rtl::Table::new("custom_props_form").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { + t.row(|r| { + let name = r.cell(|ui| ui.label(tl!("Name:"))); + r.cell(|ui| { + ui.add(egui::TextEdit::singleline(&mut f.name).desired_width(300.0).char_limit(wordcraft_engine::cmd::file::MAX_CUSTOM_NAME)) + .labelled_by(name.id) + }); + }); + t.row(|r| { + let value = r.cell(|ui| ui.label(tl!("Value:"))); + r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut f.value).desired_width(300.0)).labelled_by(value.id)); + }); }); if !f.message.is_empty() { ui.colored_label(ui.visuals().error_fg_color, f.message.as_str()); diff --git a/crates/ui-egui/src/dialogs/mailings.rs b/crates/ui-egui/src/dialogs/mailings.rs index 99541e25cf..937fe6c589 100644 --- a/crates/ui-egui/src/dialogs/mailings.rs +++ b/crates/ui-egui/src/dialogs/mailings.rs @@ -282,60 +282,66 @@ pub(crate) fn recipient_list( let mut remove_row = None; let mut x_w = X_W; let mut focused = None; - egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| { - crate::rtl::vertical(ui, |ui| { - ui.horizontal(|ui| { - ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); - for (i, f) in fields.iter_mut().enumerate() { - ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name"))) - .accessible_name(format!("{} {}", tl!("Field name"), i + 1)); - let x = ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field")); - x_w = x.rect.width(); - if x.clicked() { - remove_field = Some(i); + crate::rtl::island(ui, |ui| { + egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| { + crate::rtl::vertical(ui, |ui| { + ui.horizontal(|ui| { + ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); + for (i, f) in fields.iter_mut().enumerate() { + ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(f).font(egui::TextStyle::Button).hint_text(tl!("Field name"))) + .accessible_name(format!("{} {}", tl!("Field name"), i + 1)); + let x = + ui.add_enabled(can_remove, egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove field")); + x_w = x.rect.width(); + if x.clicked() { + remove_field = Some(i); + } } - } - }); - ui.separator(); - egui::ScrollArea::vertical().id_salt("recipients_v").max_height(260.0).show_rows(ui, ROW_H, visible.len(), |ui, range| { - for k in range { - let Some(&r) = visible.get(k) else { continue }; - let Some(row) = rows.get_mut(r) else { continue }; - // Striped rows, like a data grid: painted under the row once its size is known. - let stripe = ui.painter().add(egui::Shape::Noop); - let line = ui.horizontal(|ui| { - // The record's number in the merge, a dash when the filter leaves it out. - let (num, kept) = match order.as_ref().map(|o| o.get(r).copied().flatten()) { - None => ((r + 1).to_string(), true), - Some(Some(n)) => (n.to_string(), true), - Some(None) => ("–".to_string(), false), - }; - let num = egui::RichText::new(num).weak(); - let (rect, _) = ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); - ui.put(rect, egui::Label::new(if kept { num } else { num.strikethrough() })); - for (j, c) in row.iter_mut().enumerate() { - // Keyed on the record, not its place in the grid: when the view hides - // or moves rows, the keyboard can't land in another record (#1576). - let field_name = fields.get(j).map_or("", String::as_str); - let cell = ui - .add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j))) - .accessible_name(crate::i18n::fmt(tl!("{field}, record {n}"), &[("field", field_name), ("n", &(r + 1).to_string())])); - if cell.has_focus() { - focused = Some(cell.id); + }); + ui.separator(); + egui::ScrollArea::vertical().id_salt("recipients_v").max_height(260.0).show_rows(ui, ROW_H, visible.len(), |ui, range| { + for k in range { + let Some(&r) = visible.get(k) else { continue }; + let Some(row) = rows.get_mut(r) else { continue }; + // Striped rows, like a data grid: painted under the row once its size is known. + let stripe = ui.painter().add(egui::Shape::Noop); + let line = ui.horizontal(|ui| { + // The record's number in the merge, a dash when the filter leaves it out. + let (num, kept) = match order.as_ref().map(|o| o.get(r).copied().flatten()) { + None => ((r + 1).to_string(), true), + Some(Some(n)) => (n.to_string(), true), + Some(None) => ("–".to_string(), false), + }; + let num = egui::RichText::new(num).weak(); + let (rect, _) = ui.allocate_exact_size(vec2(NUM_W, ROW_H - 4.0), Sense::hover()); + ui.put(rect, egui::Label::new(if kept { num } else { num.strikethrough() })); + for (j, c) in row.iter_mut().enumerate() { + // Keyed on the record, not its place in the grid: when the view hides + // or moves rows, the keyboard can't land in another record (#1576). + let field_name = fields.get(j).map_or("", String::as_str); + let cell = ui + .add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c).id_salt(("recipient_cell", r, j))) + .accessible_name(crate::i18n::fmt( + tl!("{field}, record {n}"), + &[("field", field_name), ("n", &(r + 1).to_string())], + )); + if cell.has_focus() { + focused = Some(cell.id); + } + // Under the field's remove button. + ui.allocate_exact_size(vec2(x_w, X_W), Sense::hover()); } - // Under the field's remove button. - ui.allocate_exact_size(vec2(x_w, X_W), Sense::hover()); + if ui.add(egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove recipient")).clicked() { + remove_row = Some(r); + } + }); + if k % 2 == 1 { + let half = ui.spacing().item_spacing.y / 2.0; + let rect = line.response.rect.expand2(vec2(0.0, half)); + ui.painter().set(stripe, egui::Shape::rect_filled(rect, 2.0, ui.visuals().faint_bg_color)); } - if ui.add(egui::Button::new("×").small().min_size(vec2(X_W, X_W))).on_hover_text(tl!("Remove recipient")).clicked() { - remove_row = Some(r); - } - }); - if k % 2 == 1 { - let half = ui.spacing().item_spacing.y / 2.0; - let rect = line.response.rect.expand2(vec2(0.0, half)); - ui.painter().set(stripe, egui::Shape::rect_filled(rect, 2.0, ui.visuals().faint_bg_color)); } - } + }); }); }); }); @@ -423,32 +429,43 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter }); }; let mut remove = None; - crate::rtl::grid(ui, egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)), |ui| { - ui.label(""); - ui.label(egui::RichText::new(tl!("Field")).weak()); - ui.label(egui::RichText::new(tl!("Comparison")).weak()); - ui.label(egui::RichText::new(tl!("Compare to")).weak()); - ui.end_row(); - for (i, r) in filter.rules.iter_mut().enumerate() { - if i == 0 { - ui.label(""); - } else { - craft_ui::ComboBox::from_id_salt(("rf_join", i)).width(60.0).selected_text(tl!(if r.or { "Or" } else { "And" })).show_ui(ui, |ui| { - ui.selectable_value(&mut r.or, false, tl!("And")); - ui.selectable_value(&mut r.or, true, tl!("Or")); + crate::rtl::Table::new("recipient_filter").columns(5).spacing(vec2(6.0, 4.0)).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label("")); + r.cell(|ui| ui.label(egui::RichText::new(tl!("Field")).weak())); + r.cell(|ui| ui.label(egui::RichText::new(tl!("Comparison")).weak())); + r.cell(|ui| ui.label(egui::RichText::new(tl!("Compare to")).weak())); + }); + for (i, rule) in filter.rules.iter_mut().enumerate() { + t.row(|r| { + r.cell(|ui| { + if i == 0 { + ui.label(""); + } else { + craft_ui::ComboBox::from_id_salt(("rf_join", i)).width(60.0).selected_text(tl!(if rule.or { "Or" } else { "And" })).show_ui( + ui, + |ui| { + ui.selectable_value(&mut rule.or, false, tl!("And")); + ui.selectable_value(&mut rule.or, true, tl!("Or")); + }, + ); + } + }); + r.cell(|ui| field_combo(ui, ("rf_field", i), &mut rule.field, false)); + r.cell(|ui| { + craft_ui::ComboBox::from_id_salt(("rf_op", i)).width(150.0).selected_text(tl!(rule.op.label())).show_ui(ui, |ui| { + for op in FilterOp::ALL { + ui.selectable_value(&mut rule.op, op, tl!(op.label())); + } + }); + }); + r.cell(|ui| ui.add_enabled(rule.op.has_value(), egui::TextEdit::singleline(&mut rule.value).desired_width(130.0))); + r.cell(|ui| { + if ui.add(egui::Button::new("×").small()).on_hover_text(tl!("Remove condition")).clicked() { + remove = Some(i); + } }); - } - field_combo(ui, ("rf_field", i), &mut r.field, false); - craft_ui::ComboBox::from_id_salt(("rf_op", i)).width(150.0).selected_text(tl!(r.op.label())).show_ui(ui, |ui| { - for op in FilterOp::ALL { - ui.selectable_value(&mut r.op, op, tl!(op.label())); - } }); - ui.add_enabled(r.op.has_value(), egui::TextEdit::singleline(&mut r.value).desired_width(130.0)); - if ui.add(egui::Button::new("×").small()).on_hover_text(tl!("Remove condition")).clicked() { - remove = Some(i); - } - ui.end_row(); } }); if let Some(i) = remove.filter(|i| *i < filter.rules.len()) { @@ -465,20 +482,23 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter } }); ui.add_space(4.0); - crate::rtl::grid(ui, egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)), |ui| { + crate::rtl::Table::new("recipient_sort").columns(3).spacing(vec2(6.0, 4.0)).show(ui, |t| { for (i, (field, desc)) in filter.sort.iter_mut().enumerate() { - ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" })); - field_combo(ui, ("rs_field", i), field, true); - ui.add_enabled_ui(!field.is_empty(), |ui| { - craft_ui::ComboBox::from_id_salt(("rs_dir", i)) - .width(110.0) - .selected_text(tl!(if *desc { "Descending" } else { "Ascending" })) - .show_ui(ui, |ui| { - ui.selectable_value(desc, false, tl!("Ascending")); - ui.selectable_value(desc, true, tl!("Descending")); + t.row(|r| { + r.cell(|ui| ui.label(tl!(if i == 0 { "Sort by" } else { "Then by" }))); + r.cell(|ui| field_combo(ui, ("rs_field", i), field, true)); + r.cell(|ui| { + ui.add_enabled_ui(!field.is_empty(), |ui| { + craft_ui::ComboBox::from_id_salt(("rs_dir", i)) + .width(110.0) + .selected_text(tl!(if *desc { "Descending" } else { "Ascending" })) + .show_ui(ui, |ui| { + ui.selectable_value(desc, false, tl!("Ascending")); + ui.selectable_value(desc, true, tl!("Descending")); + }); }); + }); }); - ui.end_row(); } }); } diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index dc25e145fd..bcb74c0879 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -232,7 +232,7 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale); let set = app.session.view.draw.settings(d.tool); let alpha = (d.tool.alpha() * 255.0).round() as u8; - let color = screen_ink(app.session.view.dark_mode, set.color, alpha); + let color = screen_ink(crate::canvas::dark_page(app), set.color, alpha); let pts: Vec = d.pts.iter().map(|p| map(*p)).collect(); let width = (set.width * s).max(1.0); if let [only] = pts.as_slice() { @@ -340,7 +340,7 @@ fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLa let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 }; let total: f32 = k.paths.iter().map(|p| path_len(p)).sum(); let [cr, cg, cb, ca] = k.color.to_srgba_unmultiplied(); - let color = screen_ink(app.session.view.dark_mode, wordcraft_doc::Rgb(cr, cg, cb), ca); + let color = screen_ink(crate::canvas::dark_page(app), wordcraft_doc::Rgb(cr, cg, cb), ca); let mut left = total * frac; let width = (k.width * s).max(1.0); for path in &k.paths { diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 0f2db87764..0338892c8c 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -536,7 +536,7 @@ fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit let obj = app.session.doc.para_at(&o.pos()).and_then(|p| p.object_at(o.off))?; let page = layout.pages.get(o.page)?; let mut opts = crate::canvas::screen_render_options(); - opts.dark = app.session.view.dark_mode; + opts.dark = crate::canvas::dark_page(app); let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display); let b = o.bounds(); let img = wordcraft_render::render_items( diff --git a/crates/ui-egui/src/ribbon/charts.rs b/crates/ui-egui/src/ribbon/charts.rs index 9a668b6bb4..e4b9d42bc5 100644 --- a/crates/ui-egui/src/ribbon/charts.rs +++ b/crates/ui-egui/src/ribbon/charts.rs @@ -28,25 +28,28 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Layouts", None, app, |ui, app| { let t = Tokens::get(ui.ctx()); - crate::rtl::grid(ui, egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)), |ui| { - for (k, layout) in SmartArtLayout::ALL.into_iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click()); - let on = g.layout == layout; - let fill = if on { - t.checked - } else if resp.hovered() { - t.hover - } else { - t.ribbon - }; - ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); - crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim); - if resp.on_hover_text(tl!(layout.label())).clicked() && !on { - let _ = app.run("smartArt.layout", json!({"layout": layout.id()})); - } - if k % 4 == 3 { - ui.end_row(); - } + crate::rtl::Table::new("smart_art_layout_gallery").columns(0).spacing(vec2(2.0, 2.0)).show(ui, |table| { + for layouts in SmartArtLayout::ALL.chunks(4) { + table.row(|row| { + for layout in layouts.iter().copied() { + row.cell(|ui| { + let (r, resp) = ui.allocate_exact_size(vec2(46.0, 31.0), Sense::click()); + let on = g.layout == layout; + let fill = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + t.ribbon + }; + ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, if on { t.accent } else { t.border }), egui::StrokeKind::Inside); + crate::dialogs_smart_art::preview(ui.painter(), r.shrink(2.0), layout, g.colors, &theme, t.text_dim); + if resp.on_hover_text(tl!(layout.label())).clicked() && !on { + let _ = app.run("smartArt.layout", json!({"layout": layout.id()})); + } + }); + } + }); } }); }); diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index b1200faca4..041a61083c 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -136,36 +136,53 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { ui.label(text); }); }; - crate::rtl::grid(ui, egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y), |ui| { - ui.label(egui::RichText::new(tl!("Indent")).small().strong()); - ui.label(""); - spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong()); - ui.end_row(); + crate::rtl::Table::new("layout_para").columns(0).spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.label(egui::RichText::new(tl!("Indent")).small().strong())); + r.cell(|ui| ui.label("")); + r.cell(|ui| spaced(ui, egui::RichText::new(tl!("Spacing")).small().strong())); + }); // Indents in the File › Options unit. let u = crate::options::unit(); let k = u.pt_per_unit(); let mut l = rp.indent_left / k; let mut b = rp.space_before; - ui.label(egui::RichText::new(tl!("Left:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"left": l * k})); - } - spaced(ui, egui::RichText::new(tl!("Before:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"before": b})); - } - ui.end_row(); - let mut r = rp.indent_right / k; + t.row(|r| { + r.cell(|ui| ui.label(egui::RichText::new(tl!("Left:")).small())); + r.cell(|ui| { + if ui + .add_sized(FIELD, egui::DragValue::new(&mut l).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)) + .changed() + { + let _ = app.run("para.indents", json!({"left": l * k})); + } + }); + r.cell(|ui| spaced(ui, egui::RichText::new(tl!("Before:")).small())); + r.cell(|ui| { + if ui.add_sized(FIELD, egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"before": b})); + } + }); + }); + let mut right = rp.indent_right / k; let mut a = rp.space_after; - ui.label(egui::RichText::new(tl!("Right:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut r).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)).changed() { - let _ = app.run("para.indents", json!({"right": r * k})); - } - spaced(ui, egui::RichText::new(tl!("After:")).small()); - if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { - let _ = app.run("para.spacing", json!({"after": a})); - } - ui.end_row(); + t.row(|r| { + r.cell(|ui| ui.label(egui::RichText::new(tl!("Right:")).small())); + r.cell(|ui| { + if ui + .add_sized(FIELD, egui::DragValue::new(&mut right).speed(3.6 / k).range(-792.0 / k..=1584.0 / k).measure(u).max_decimals(2)) + .changed() + { + let _ = app.run("para.indents", json!({"right": right * k})); + } + }); + r.cell(|ui| spaced(ui, egui::RichText::new(tl!("After:")).small())); + r.cell(|ui| { + if ui.add_sized(FIELD, egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(format!(" {}", tl!("pt")))).changed() { + let _ = app.run("para.spacing", json!({"after": a})); + } + }); + }); }); }); group(ui, "Arrange", None, app, |ui, app| { diff --git a/crates/ui-egui/src/ribbon/tabs.rs b/crates/ui-egui/src/ribbon/tabs.rs index 627c8c5889..b3ec95e5af 100644 --- a/crates/ui-egui/src/ribbon/tabs.rs +++ b/crates/ui-egui/src/ribbon/tabs.rs @@ -69,15 +69,15 @@ pub(super) fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { ui.separator(); use wordcraft_engine::cmd::view::ReadPageColor; let colors = [ - (ReadPageColor::None, "None", "none"), - (ReadPageColor::Sepia, "Sepia", "sepia"), - (ReadPageColor::Inverse, "Inverse", "inverse"), + (ReadPageColor::None, tl!("None"), "none"), + (ReadPageColor::Sepia, tl!("Sepia"), "sepia"), + (ReadPageColor::Inverse, tl!("Inverse"), "inverse"), ]; let current = app.session.view.read_page_color; - let name = colors.iter().find(|c| c.0 == current).map_or("None", |c| c.1); - ui.menu_button(format!("{}: {}", tl!("Page color"), tl!(name)), |ui| { + let name = colors.iter().find(|c| c.0 == current).map_or_else(|| tl!("None"), |c| c.1); + ui.menu_button(format!("{}: {}", tl!("Page color"), name), |ui| { for (color, name, value) in colors { - if ui.selectable_label(current == color, tl!(name)).clicked() { + if ui.selectable_label(current == color, name).clicked() { let _ = app.run("view.readPageColor", json!({"value": value})); ui.close(); } diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index f8b87ac3dc..68170406f8 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -64,6 +64,12 @@ pub fn horizontal_top(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerRe if flows(ui) { ui.scope_builder(UiBuilder::new().layout(Layout::right_to_left(Align::Min)), add) } else { ui.horizontal_top(add) } } +/// A button's text whose `>>` or `<<` points the way things move across the window (Add >>, << +/// Remove): in a mirrored interface the two swap, because the list items move to is on the other side. +pub fn arrows(text: &str) -> String { + if on() { text.replace(">>", "\u{1}").replace("<<", ">>").replace('\u{1}', "<<") } else { text.to_string() } +} + /// The layout for a row's items that belong at its far end (the side rows end on): filled from /// that end inwards. pub fn far_side(ui: &Ui, align: Align) -> Layout { @@ -193,26 +199,54 @@ pub struct Table { id: &'static str, columns: usize, spacing: Option, + striped: bool, + top: bool, + min_row_height: Option, } impl Table { pub fn new(id: &'static str) -> Self { - Self { id, columns: 2, spacing: None } + Self { id, columns: 2, spacing: None, striped: false, top: false, min_row_height: None } } + /// The number of columns; 0 leaves an `egui::Grid` without a fixed count. pub fn columns(mut self, columns: usize) -> Self { self.columns = columns; self } + /// Cells align to the top of their row (they are centred in it otherwise, as a mirrored form + /// wants for a label and its field; tall cells such as a column of widgets want the top). + pub fn top(mut self) -> Self { + self.top = true; + self + } + + pub fn min_row_height(mut self, height: f32) -> Self { + self.min_row_height = Some(height); + self + } + pub fn spacing(mut self, spacing: egui::Vec2) -> Self { self.spacing = Some(spacing); self } + /// Shade every other row (only where the table is an `egui::Grid`: a mirrored table has no stripes). + pub fn striped(mut self, striped: bool) -> Self { + self.striped = striped; + self + } + pub fn show(self, ui: &mut Ui, add: impl FnOnce(&mut Rows) -> R) -> R { if !flows(ui) { - let spec = egui::Grid::new(self.id).num_columns(self.columns); + let spec = egui::Grid::new(self.id); + let spec = if self.columns > 0 { spec.num_columns(self.columns) } else { spec }; + let spec = if self.striped { spec.striped(true) } else { spec }; + let spec = match self.min_row_height { + Some(height) => spec.min_row_height(height), + None => spec, + }; let spec = match self.spacing { Some(spacing) => spec.spacing(spacing), None => spec, @@ -223,9 +257,12 @@ impl Table { let prev = ui.data(|d| d.get_temp::>(key)).unwrap_or_default(); let spacing = self.spacing.unwrap_or(ui.spacing().item_spacing); let gap = spacing.x; - let shown = ui.scope(|ui| { + // Rows stack top to bottom from the reading edge, whatever way the area around runs (a ribbon + // group lays its content out in a row). + let shown = ui.scope_builder(UiBuilder::new().layout(Layout::top_down(Align::Max)), |ui| { ui.spacing_mut().item_spacing.y = spacing.y; - let mut rows = Rows { ui, mirror: Some(Mirror { prev: prev.clone(), cur: Vec::new(), gap }) }; + let mirror = Mirror { prev: prev.clone(), cur: Vec::new(), gap, top: self.top, min_row: self.min_row_height }; + let mut rows = Rows { ui, mirror: Some(mirror) }; let out = add(&mut rows); (out, rows.mirror.map(|m| m.cur).unwrap_or_default()) }); @@ -245,6 +282,8 @@ struct Mirror { /// The width each column has now (the widest of its cells). cur: Vec, gap: f32, + top: bool, + min_row: Option, } /// The rows of a [`Table`]. @@ -263,12 +302,15 @@ impl Rows<'_> { out } Some(mirror) => { - self.ui - .horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - add(&mut Cells { ui, mirror: Some(mirror), col: 0, pending: 0.0 }) - }) - .inner + let (top, min_row) = (mirror.top, mirror.min_row); + let body = |ui: &mut Ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if let Some(height) = min_row { + ui.set_min_height(height); + } + add(&mut Cells { ui, mirror: Some(mirror), col: 0, pending: 0.0 }) + }; + if top { self.ui.horizontal_top(body).inner } else { self.ui.horizontal(body).inner } } } } diff --git a/crates/ui-egui/src/table_mouse.rs b/crates/ui-egui/src/table_mouse.rs index 1c42e78b5a..92ff652068 100644 --- a/crates/ui-egui/src/table_mouse.rs +++ b/crates/ui-egui/src/table_mouse.rs @@ -185,7 +185,7 @@ pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, /// dragged, a dashed outline of where the table goes. pub fn paint(app: &WordApp, painter: &egui::Painter, t: &Tokens, layout: &DocLayout, rects: &[Rect], scale: f32) { let hover = painter.ctx().input(|i| i.pointer.hover_pos()); - let dark = app.session.view.dark_mode; + let dark = crate::canvas::dark_page(app); if let Some(d) = &app.canvas.table_drag { match d.target.spot { Spot::Handle => { diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 98c0f4c177..5a57da9414 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -560,6 +560,29 @@ fn alignment_guides_are_a_ui_command_and_survive_a_restart() { assert!(old.alignment_guides, "older settings leave it on"); } +#[test] +fn reading_page_color_restores_for_new_documents_and_windows() { + use wordcraft_engine::cmd::view::ReadPageColor; + let mut a = app(); + a.run("view.readMode", json!({})).unwrap(); + a.run("view.readPageColor", json!({"value": "sepia"})).unwrap(); + let saved = serde_json::to_string(&a.prefs()).unwrap(); + let mut b = app(); + b.apply_prefs(serde_json::from_str(&saved).unwrap()); + assert_eq!(b.session.view.read_page_color, ReadPageColor::Sepia); + b.run("view.readMode", json!({})).unwrap(); + assert_eq!(b.session.view.read_page_color, ReadPageColor::Sepia); + b.run("file.new", json!({})).unwrap(); + b.run("view.readMode", json!({})).unwrap(); + assert_eq!(b.session.view.read_page_color, ReadPageColor::Sepia); + let first_window = b.windows.shown(); + b.new_window(); + assert_eq!(b.session.view.read_page_color, ReadPageColor::Sepia); + b.run("view.readPageColor", json!({"value": "inverse"})).unwrap(); + assert!(b.enter_window(first_window)); + assert_eq!(b.session.view.read_page_color, ReadPageColor::Inverse); +} + /// File › Options › Advanced › Animation: the caret's animation is a UI command, kept between /// runs. #[test] @@ -5231,6 +5254,9 @@ fn check_spelling_survives_restart() { // Settings saved before #1792 have no key: spelling stays on. second.apply_prefs(serde_json::from_str("{}").unwrap()); assert!(second.session.view.proofing); + let conflicting = serde_json::from_value(json!({"checkSpelling": true, "editing": {"checkSpelling": false}})).unwrap(); + second.apply_prefs(conflicting); + assert!(second.session.prefs.check_spelling, "the live spelling setting and its saved copy agree"); } #[test] diff --git a/docs/gaps.md b/docs/gaps.md index e2a10c18ed..d3511cf62b 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-11-04 · **Change:** trivial (#19: ODT element audit; files from other applications checked); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -73,7 +73,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: editing text in a freely rotated text box (its text is drawn turned; free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | -| 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | +| 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); its dialogs, tables and galleries read right to left too; open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), radar charts are drawn (#1890); no combo/3D/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | @@ -145,6 +145,8 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p ## Revision history +> 2026-11-03 · Gap 11: the Arabic dialogs, tables and galleries read right to left (rtl::Table); readiness unchanged. +> > 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. > > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index a99f8bc828..124b41b08f 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-11-03 · **Change:** trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously 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, @@ -22,10 +22,9 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Feature | State | Evidence / gap | Hours | |---|---|---|---| -| 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | -| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — | @@ -308,6 +307,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-22 | trivial | Layout work bounds for hostile documents: linked-box chain widths, repeated header items, `gridAfter`, picture decodes (#3692) | | 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones are drawn unhinted, and a face draws its later glyphs unhinted once at least three of its hinted draws took over 50 ms and those are at least 1/16 of its hinted draws; a colour glyph's gradient stops and clip paths share one budget | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | +| 2026-10-21 | trivial | 行頭禁則 (JIS X 4051 §4): the paragraph's kinsoku also forbids a line starting with a small kana (ぁ…ヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) or the prolonged sound mark ー/ー, at ordinary break opportunities, as JIS X 4051 §4 specifies (inferred from the standard and synthetic tests, not checked in Word); the emergency break for a line too narrow for any of the prohibited characters or ordinary break opportunities is unchanged and can still start with one; kinsoku off and word wrap off are unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | | 2026-10-25 | trivial | A bookmark, comment anchor or other zero-width marker is no line-break opportunity: a word that doesn't fit after one no longer leaves a blank line, and one inside a word no longer splits it (#4193) | @@ -331,6 +331,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-19 | trivial | An object dragged near the bottom of a page lands where it is dropped: its paragraph's place is measured with the object already there, not at the paragraph's top, where it could push the paragraph onto the next page (#3169) | | 2026-10-18 | trivial | A word after a slash (" /word") wraps whole instead of splitting between two letters (#2963) | | 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged | +| 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | | 2026-10-15 | trivial | An at-least table row with less room than its minimum on a page that holds something starts the next page whole, as checked in Word (#1742); the taller-than-a-page case (it then splits from the top of that page) and repeated header rows (they go with it) follow the issue and synthetic tests, not a Word check | | 2026-10-16 | trivial | Table row keep-next uses the first paragraph of the first cell, including inherited flags. Kept row chains start on a fresh page when the following row cannot start below them. Synthetic Word export covers mixed-cell flags, multiple paragraphs and kept prefixes before oversized followers. | | 2026-10-16 | trivial | Include table-cell headings in regenerated TOCs with full target paths | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 648c9462e4..a4fd5cab55 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -62,7 +62,7 @@ shared items below. | Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); font list by Chinese names (宋体, 微软雅黑, #505); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text | no | partial | 3–5 | | Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 | | Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 | -| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) | +| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195; dialogs, tables and galleries read right to left); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) | | French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) | | Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) | | Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 | @@ -273,6 +273,7 @@ WordCraft keeps 2,54 cm). | 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review | | 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review | | 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack | +| 2026-11-03 | trivial | Arabic: dialogs read right to left: label-and-field rows, list and data tables, tile galleries and the Add/Remove arrows follow the reading direction (`rtl::Table`); the Arabic estimate stays until a native speaker reviews it | | 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open | | 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown | | 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 58e20f242e..87dcf1d96e 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (English spelling by dialect from SCOWL 2026, CP2; previously trivial: a tracked heading or border-line AutoFormat replaces another author's formatting change; previously trivial: Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (highlight at a caret leaves the word alone, #4086); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial: tests and notes for the border-line, table, tab-indent and wide-space rules; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -228,7 +228,9 @@ no-break space, a figure space or an ideographic space (`autocorrect_finds_the_w - With Track Changes on, the border-line and table AutoFormats below mark the characters deleted, so Reject All brings them back; characters the same author inserted are removed. The border is a tracked formatting change, at the top of a story too - (`a_tracked_border_line_is_a_deletion_and_a_formatting_change`); the table is inserted + (`a_tracked_border_line_is_a_deletion_and_a_formatting_change`), and at the top it replaces + another author's earlier formatting change on the paragraph + (`a_tracked_border_line_at_the_top_supersedes_another_authors_formatting`); the table is inserted untracked, as Insert › Table inserts one (`a_tracked_table_line_keeps_its_characters_as_a_deletion`). A line holding a tracked deletion stays as it is, so Enter after the deleted characters is a plain Enter. This is @@ -246,18 +248,27 @@ no-break space, a figure space or an ideographic space (`autocorrect_finds_the_w (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the characters back and removes the border; the Enter stays (one undo step) ¹ (`undo_right_after_a_border_line_brings_the_characters_back`). -- Built-in Heading styles (`tools.autocorrect {"headingStyles": true}`, off by default): Enter +- Built-in Heading styles (`tools.autocorrect {"headingStyles": true}`, off by default, + `built_in_heading_styles_are_off_by_default`): Enter twice after a line of five or fewer words that does not end with punctuation makes the line Heading 1; one or two tabs before it make it Heading 2 or 3, and the tabs are removed ¹ (`two_enters_after_a_short_line_make_it_a_heading`). The rest is WordCraft's own rule: the line is a Normal paragraph of the main text (not in a list, a table, a header or a text box) - and ends in a letter or digit; the caret stays in the empty paragraph after the heading; ⌘Z + with no outline level, and ends in a letter or digit + (`a_line_that_is_not_a_short_line_of_body_text_stays_as_it_is`); the caret stays in the + empty paragraph after the heading; ⌘Z right after brings back the tabs and the Normal style, and the second Enter stays (`undo_right_after_a_heading_autoformat_keeps_the_enter`); with Track Changes on, the tabs are marked deleted (or removed, when the same author inserted them) and the style change is a tracked formatting change, and a line holding a tracked deletion stays as it is - (`a_tracked_heading_autoformat_can_be_rejected`); a heading style locked by formatting - restrictions is not applied (`a_locked_heading_style_is_not_applied_as_you_type`). + (`a_tracked_heading_autoformat_can_be_rejected`); the formatting change is this author's and + replaces another author's earlier formatting change on the line, so rejecting it brings back + the line as that author left it + (`a_tracked_heading_autoformat_supersedes_another_authors_formatting`); a heading style + locked by formatting restrictions is not applied + (`a_locked_heading_style_is_not_applied_as_you_type`); in a read-only document, Enter in an + editable region right after a protected short line breaks the paragraph and leaves that line + as it is (`enter_in_an_editable_region_after_a_protected_short_line_breaks_the_paragraph`). - `+---+------+` Enter (plus signs around runs of hyphens, the whole paragraph) → a one-row table across the text width, each column as wide as its share of the hyphens; the caret goes into the first cell. ⌘Z right after brings the characters back. More than 63 columns stay @@ -551,6 +562,8 @@ this correction; native Windows interaction and Microsoft Word remain untested. | 2026-10-29 | trivial | Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged | | 2026-10-28 | trivial | Highlighting at a caret leaves the word alone and sets the colour for what is typed next (#4086). | | 2026-10-28 | trivial | Arabic Backspace removes one combining mark and keeps its base letter (#4757); stored mark order, caret/forward-Delete and other-script behavior retained; original synthetic command/history/protection/save-reopen checks, no native Word comparison; readiness estimates unchanged | +| 2026-10-27 | trivial | Backspace after a floating shape's anchor at a paragraph start joins paragraphs and keeps the shape (#4592) | +| 2026-10-25 | trivial | AutoFormat: a tracked heading, and a tracked border line at the top of a story, replace another author's formatting change on the paragraph instead of keeping it | | 2026-10-25 | trivial | Tests and notes: border lines with formatting markup off, after the author's own formatting change and in a locked content control; Reject All keeps a `+---+` table; Tab in a table cell with tab indents on; Backspace before an indented paragraph with them off; narrow no-break and figure spaces before an AutoCorrect word; a no-break space typed after a word listed as a known gap | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | @@ -615,5 +628,3 @@ this correction; native Windows interaction and Microsoft Word remain untested. | 2026-10-10 | minor | Track Changes rows: tracked Enter, Backspace and Delete on paragraph marks (#244) | | 2026-10-10 | major | First version: lists, AutoCorrect and AutoFormat as you type, observed against Word (#204) | | 2026-10-15 | trivial | Text direction: typed and inserted text takes its direction from its own letters, not the caret's run; spaces typed into reopened Hebrew draw in the Latin font until reopened (#1543) | - -| 2026-10-27 | trivial | Backspace after a floating shape's anchor at a paragraph start joins paragraphs and keeps the shape (#4592) | \ No newline at end of file diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ef620fe396..fab30db9dc 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,7 +50,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | -| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None on each entry (`view.readPageColor`); Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 | +| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None and kept between Read Mode entries, documents and runs (`view.readPageColor`); ink, object drag previews and table drag arrows drawn over the pages follow it; Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the page is the caret's while the caret is on screen, else the page at the top of the window, so scrolling updates it (#3880); the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other; "N fonts not installed" when the document's text is drawn in substitutes that measure differently, a click lists them with the faces drawn instead, the first 20 and then how many more, and Add Font…) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299). On Windows, whose inverting I-beam pointer stays white over the page, the pointer there is drawn the same way: black on light paper, white on dark (#1746) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | @@ -97,6 +97,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared | 2026-11-03 | minor | File › Options › Language: Your languages (English variants in use, installed Language packs, the document's languages) with each one's proofing status, Install, Remove and Set as default; Add a language… opens a searchable picker (dutch, nederlands, nl) sorted by name; the Dictionaries folder and the Dictionary files list moved here from Proofing, which has no Dictionaries section any more (CP4.3) | | 2026-10-28 | trivial | Fonts that aren't installed are flagged: the font box (ribbon, mini toolbar, both Font dialog boxes) shows a warning mark whose hover note names the font drawn instead; the status bar says "N fonts not installed" and lists them with the faces drawn instead (the first 20, then how many more), with Add Font…. The list comes from layout (`ParaLayout::substituted`), so it covers exactly the text shown, in every story, list labels and East Asian and complex-script text included, and a missing roman font drawn in Cambria counts. Metric-compatible substitutes (Carlito for Calibri, Caladea for Cambria, Liberation for Arial, Times New Roman and Courier New, the Palatino look-alikes) aren't flagged, since line breaks don't move, nor symbol fonts drawn as their Unicode equivalents (Word's bullets); headers and footers count only in views that show them, chart and SmartArt labels not yet; the font box reads the same list, so for text already shown the two agree, and it also warns for a missing font no text shows yet when the box shows a font to come: at a collapsed caret, the font the next letter will be typed in, and in the Font dialog, the fonts OK applies (the Asian text font too, over Latin text); Missing Fonts (`font.missing`, from command search or agents; it says what it found in the status line) and `format.state.fontShownAs` for agents | | 2026-10-31 | trivial | Keytips: Alt, F badges the Backstage pages with Word's letters and the letter opens the page; Alt, F, P and Alt, F, V open Print; the badges end with the Backstage (#4960) | +| 2026-10-30 | trivial | Read Mode keeps the selected Page color between entries, documents and runs. The color does not change document formatting. | | 2026-10-31 | trivial | View › Macros opens a Macros dialog (also Alt+F8 and command search): the macros of this session listed with Run, Delete and Record, which reads Stop Recording while a recording runs; the button did nothing before, as `tools.macros` without a macro to run, define or delete only returned the list (#4327) | | 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | | 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged | @@ -114,6 +115,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | +| 2026-10-25 | trivial | Read Mode: the Page color is kept from one entry to the next; ink, object drag previews and table drag arrows follow it instead of Switch Modes | | 2026-10-25 | trivial | Custom Document Properties dialog: File › Info › Properties › Advanced Properties… opens it; the document's custom properties listed with Add, Modify and Delete over a name/value form (text properties only; Word's number/date/yes-no types not offered), OK one undoable `file.properties` step, Cancel discards; modal dialogs 62 → 63 (#2982) | | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) |