diff --git a/README.md b/README.md index 2db5753ef4..1b1bcc2ede 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes | **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities | | **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector | | **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels | -| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) | +| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) | | **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images | The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the diff --git a/ROADMAP.md b/ROADMAP.md index 34ae62d837..378a194f69 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-03 · **Change:** trivial (progress log: a pasted text box story is charged for the formatting it copies); 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-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** 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-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; 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 @@ -108,7 +108,7 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit | Simplified Chinese | zh-hans | partial | ~95% | | Spanish | es | partial | ~95% | | Hindi | hi | none | 0% | -| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% | +| Arabic | ar | partial (catalog + mirrored UI; no kashida; no proofing; native review open) | ~95% | | French | fr | partial (interface complete, native-speaker reviewed; no proofing) | 100% (2,615 entries) | | Portuguese | pt-br (pt-PT none) | partial | ~95% | | Indonesian | id | none | 0% | @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log - 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. - 2026-11-03: Language packs: language-packs-2 is published and WordCraft pins its index: 75 languages, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch (#4145, #2505) and Swedish (Sweden, Finland), which need craft-proof's flag-based compounding (storytold/craft-libs#497, pinned). English (Australia) is checked against SCOWL's en-AU list as well as en-GB's (storytold/craft-libs#496). Mongolian and Thai stay hidden. Readiness unchanged. - 2026-11-02: Language packs (CP4.4): craft-i18n's first pack release, language-packs-1, is published and WordCraft pins its index (sha256): 61 languages install from File › Options › Language, among them Lithuanian (#4707), Romanian (#3438), Catalan (#2895, #4947), French (#1325), Greek (#2561), Slovak (#3806), Serbian (#4968) and Latvian (#4438). Dutch (#4145, #2505), German and the other compounding languages wait on craft-proof engine support. Readiness unchanged. - 2026-11-01: Language packs (CP4.3–CP4.5): languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language, or from a bar a document in that language shows ("Polish proofing isn't installed. Install (4.6 MB)"), through the font store and its single download setting; About › Language data shows every licence; `proof.packs`, `proof.installPack` and `proof.removePack` for agents. Install works once language-packs-1 is published (until then with a test index); the default build then embeds English data only, and keeps today's carry-over data embedded until then (`cargo xtask language`). Lithuanian (#4707) and Romanian (#3438) are available as a Language pack once language-packs-1 is published; the Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs (#4145, #2505, #2895, #4947, #1325, #2561, #3806, #4968, #4438) are defined and wait on craft-proof engine support (FLAG long/num, OCONV, ISO 8859-7, CIRCUMFIX, FULLSTRIP). Readiness unchanged. @@ -235,6 +237,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-11-03 | **Arabic interface (#1195):** `ar` selectable (and every `ar-*` system locale); craft-i18n catalog; mirrored ribbon, panes, File page, dialogs and menus; Arabic UI font fallback; document direction stays independent. No kashida or Arabic proofing yet; Hebrew UI still open. | | 2026-11-01 | **English spelling reads SCOWL 2026 (CP2, #40, #520, #1281, #1045):** en-US, en-GB (also en-AU, en-NZ, en-IE, en-IN…) and en-CA each check against their own SCOWL `rel-2026.02.25` Hunspell dictionary from craft-i18n, embedded and read by craft-proof's affix engine (which now understands `COMPOUNDRULE`, so ordinals like `21st` are words). Suggestions come from the dialect's own dictionary; our American/British policy (`en-variants.txt`) still decides the other dialect's spellings, because Word accepts more than SCOWL's one spelling per dialect (`organize` and `organise` in en-GB). Rare words the 1990s Moby list accepted (`aposiopesis`) are now flagged. The Moby list stays for hyphenation and as a fallback. The build carries 1.5 MB more Language data. | | 2026-11-01 | Proofing moved to the shared libraries, with nothing users see changing: the code is `craft-proof` in storytold/craft-libs (with its history) and the spelling and hyphenation data (the Moby list, our English patterns and word lists, the German, Polish, Portuguese and Italian patterns, `ru-extra`) is craft-i18n's `language-data/`, embedded from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`. Before/after `wordcraft-cli` runs on a proofing document give the same issues, suggestions and hyphenated PDF (CP1). | | 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. | diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 348e18694c..8742c6f639 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -364,6 +364,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result = std::collections::HashMap::new(); + for (i, p) in paras.iter().enumerate() { + ending.entry(p.end_cp).or_insert(i); + } let last = sects.last().map(|s| s.props.clone()); for (si, sec) in sects.iter().enumerate() { let mut props = sec.props.clone(); @@ -374,11 +379,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result usize { + self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len() + } + /// The width percentage cell `i` ends up with, for tests. #[cfg(test)] pub(crate) fn width_pct_of(&self, i: u8) -> Option { @@ -108,6 +114,15 @@ impl RowInfo { } } +/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often +/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark. +fn set(slots: &mut Vec<(u8, T)>, c: u8, v: T) { + match slots.iter_mut().find(|(k, _)| *k == c) { + Some(slot) => slot.1 = v, + None => slots.push((c, v)), + } +} + pub(crate) fn decode(papx: &[u8]) -> RowInfo { let mut r = RowInfo::default(); let mut gap_half: Option = None; @@ -144,8 +159,10 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { T_DXA_GAP_HALF => gap_half = s16(&prl), T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand), T_MERGE => { + // Rows apply only the first MAX_COLS merges. if let [a, b, ..] = prl.operand && b > a + && r.merges.len() < MAX_COLS { r.merges.push((*a, *b)); } @@ -158,7 +175,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { 1 => VMerge::Continue, _ => VMerge::None, }; - if m != VMerge::None { + // The first override for a cell wins. + if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) { r.vmerge.push((*itc, m)); } } @@ -168,7 +186,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { && let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[]) { for c in *a..(*b).min(MAX_COLS as u8) { - r.shading.push((c, Some(rgb))); + set(&mut r.shading, c, Some(rgb)); } } } @@ -176,7 +194,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { let cells = prl.operand.get(1..).unwrap_or(&[]); for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() { if let Some(rgb) = shd80_rgb(b) { - r.shading.push((i as u8, Some(rgb))); + set(&mut r.shading, i as u8, Some(rgb)); } } } @@ -196,7 +214,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) { continue; } - r.shading.push(((first + i) as u8, shd_rgb(b))); + set(&mut r.shading, (first + i) as u8, shd_rgb(b)); } } T_CELL_WIDTH => { @@ -207,12 +225,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { if *fts == 3 { let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0); for c in *a..(*b).min(MAX_COLS as u8) { - r.widths.push((c, w)); + set(&mut r.widths, c, w); } } else if *fts == 2 { let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0); for c in *a..(*b).min(MAX_COLS as u8) { - r.width_pcts.push((c, pct)); + set(&mut r.width_pcts, c, pct); } } } @@ -224,8 +242,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo { 2 => VAlign::Bottom, _ => VAlign::Top, }; - for c in *a..*b { - r.valign.push((c, v)); + for c in *a..(*b).min(MAX_COLS as u8) { + set(&mut r.valign, c, v); } } } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index bc8c27fe86..6354316069 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1018,6 +1018,32 @@ fn two_sections_box_the_first() { assert!(!doc.last_section.title_page); } +#[test] +fn sections_ending_off_a_paragraph_mark_attach_nowhere() { + // Hostile section ends that miss every paragraph mark are skipped; the one that ends a + // paragraph still boxes it. Paragraphs are found by their end, not scanned per section. + let mut sections: Vec<(u32, Vec)> = (1..6).map(|cp| (cp, Vec::new())).collect(); + sections.push((6, grpprl(&[(0x300A, &[0x01])]))); + sections.push((12, Vec::new())); + let spec = StructSpec { + text: "first\rsecond\r", + header_stories: vec![], + sections, + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())], + 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 paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect(); + assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page); + assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere"); +} + #[test] fn header_story_becomes_part() { let spec = StructSpec { @@ -2375,6 +2401,23 @@ fn second_table_gets_its_own_grid() { assert_eq!(tables[1].props.indent, None, "indent reset"); } +#[test] +fn repeated_cell_sprms_keep_one_override_per_cell() { + // A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one + // slot per cell, the last value winning, instead of a copy per repetition. + let mut sprms = Vec::new(); + for w in 0..200u16 { + let [w0, w1] = (w * 20).to_le_bytes(); + sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1])); + sprms.push(sprm_cb(0xD62C, &[0, 255, 1])); + sprms.push([0x24, 0x56, 0, 2].to_vec()); + } + let row = crate::table::decode(&sprms.concat()); + assert_eq!(row.width_of(0), Some(199.0), "the last width wins"); + assert_eq!(row.width_of(62), Some(199.0)); + assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides()); +} + #[test] fn sprm_t_cell_width_twips_and_percentage() { // [MS-DOC] §2.9.290 sprmTCellWidth (0xD635): diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 6f2ced2261..8efb2ce3ce 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -56,7 +56,9 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec), String> { let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - if wasm && !scripts.split(',').any(|s| s.trim() == "Geor") { + // Arabic interface faces are small, so the web build keeps them too. + let small_ui_face = scripts.split(',').any(|s| matches!(s.trim(), "Arab" | "Geor")); + if wasm && !small_ui_face { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 3c70572c31..b99e03b8ff 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -24,7 +24,10 @@ pub use kurbo::BezPath; pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; -pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, ui_uncovered_cjk}; +pub use ui_fallback::{ + SystemUiFont, is_cjk, system_arabic_ui_font, system_cjk_ui_font, system_cjk_ui_font_for, system_hant_ui_font, ui_needs_system_cjk, + ui_uncovered_cjk, +}; /// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; /// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). @@ -98,6 +101,18 @@ pub fn ui_cjk_fonts_for(prefer_hans: bool, prefer_korean: bool) -> Vec<&'static } } +/// The craft-fonts faces for Arabic script (`"Arab"`), in manifest order: the document fallback +/// for Arabic text. Empty without craft-fonts. +pub fn arabic_fonts() -> impl Iterator { + CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Arab")) +} + +/// The craft-fonts faces for Arabic script (`"Arab"`), Regular styles only: the interface's +/// fallback for an Arabic interface and for Arabic file names. Empty without craft-fonts. +pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> { + arabic_fonts().filter(|f| f.style == "Regular").collect() +} + /// The bundled serif used when a document asks for nothing better. pub const DEFAULT_FAMILY: &str = "Source Serif 4"; @@ -514,6 +529,31 @@ mod tests { assert!(fb.family.contains("Mincho"), "{fb:?}"); } + /// Arabic text in a document needs no system font: the craft-fonts Arabic face is the + /// fallback (the browser build has no other), and its letters join when shaped. + #[test] + fn craft_fonts_arabic_face_is_the_document_fallback_for_arabic() { + if arabic_fonts().next().is_none() { + eprintln!("skipped: built without a craft-fonts Arabic face (set CRAFT_FONTS_DIR to a checkout)"); + return; + } + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let latin = db.face(DEFAULT_FAMILY, "Regular"); + for ch in "ابتثجحخدذرزسشصضطظعغفقكلمنهويءآأإةى،؛؟٠١٢٣٤٥٦٧٨٩ًٌٍَُِّْ".chars() { + assert!(!latin.covers(ch), "the bundled serif has no Arabic"); + let fb = db.fallback_for(ch, latin.id()).unwrap_or_else(|| panic!("no fallback for {ch}")); + assert!(fb.family.contains("Arabic"), "{ch}: {}", fb.family); + } + let face = db.fallback_for('ع', latin.id()).unwrap(); + // Joined forms: the same letter gets a different glyph alone and inside a word. + let alone = shape(&face, "ع", &[], |c| c); + let joined = shape(&face, "ععع", &[], |c| c); + assert_eq!((alone.len(), joined.len()), (1, 3)); + assert!(joined.iter().all(|g| g.gid != 0)); + assert!(joined.iter().any(|g| g.gid != alone[0].gid), "letters take their joining forms"); + } + #[test] fn east_asian_run_uses_one_face_covering_the_whole_run() { let db = FontDb::with_font_dirs(Vec::new()); diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs index 63dca56161..fafe6459d8 100644 --- a/crates/fonts/src/ui_fallback.rs +++ b/crates/fonts/src/ui_fallback.rs @@ -218,6 +218,36 @@ pub fn system_cjk_ui_font_for(hans: bool, korean: bool, needed: &[char]) -> Opti find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans, korean), needed, Region::from_hans(hans)) } +/// Arabic font files to look for, relative to a font folder, best first. +fn arabic_candidates() -> &'static [&'static str] { + if cfg!(windows) { + &["segoeui.ttf", "tahoma.ttf", "arial.ttf"] + } else if cfg!(target_os = "macos") { + &["SFArabic.ttf", "GeezaPro.ttc", "Supplemental/GeezaPro.ttc", "Supplemental/Arial Unicode.ttf", "Supplemental/Arial.ttf"] + } else { + &[ + "noto/NotoSansArabic-Regular.ttf", + "truetype/noto/NotoSansArabic-Regular.ttf", + "google-noto/NotoSansArabic-Regular.ttf", + "google-noto-sans-arabic-vf-fonts/NotoSansArabic[wght].ttf", + "noto/NotoNaskhArabic-Regular.ttf", + "truetype/noto/NotoNaskhArabic-Regular.ttf", + "google-noto/NotoNaskhArabic-Regular.ttf", + "vazirmatn/Vazirmatn-Regular.ttf", + "truetype/dejavu/DejaVuSans.ttf", + "dejavu/DejaVuSans.ttf", + "TTF/DejaVuSans.ttf", + ] + } +} + +/// An installed font with Arabic letters, for an Arabic interface in a build whose craft-fonts +/// input has no Arabic face. Reads at most one font file per candidate; never panics on damaged +/// files. +pub fn system_arabic_ui_font() -> Option { + find_ui_font(&crate::system_font_dirs(), arabic_candidates(), &['ع'], Region::Other) +} + /// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font`), /// the `AssetData` folder of every `.asset` in it: macOS ships PingFang there, e.g. /// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData/PingFang.ttc` @@ -424,6 +454,9 @@ mod tests { // No file maps every needed character: the one mapping the most is used. let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], Region::Hans).unwrap(); assert_eq!(found.family, "Source Sans 3"); + // The bundled Latin face has no Arabic letters, so it is never taken for an Arabic interface. + assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['ع'], Region::Other).is_none()); + assert!(!arabic_candidates().is_empty()); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/layout/src/para/proof.rs b/crates/layout/src/para/proof.rs index b9ffa911c8..0b97941930 100644 --- a/crates/layout/src/para/proof.rs +++ b/crates/layout/src/para/proof.rs @@ -46,8 +46,37 @@ pub fn paragraph_issues( } let text = proof_text(p); let spans: Vec<(std::ops::Range, wordcraft_proof::ProofLang)> = runs.iter().map(|r| (r.range.clone(), r.lang)).collect(); - let skip = |a: usize, b: usize| runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end); - wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item).into_iter().filter(|i| !skip(i.start, i.end)).collect() + let skipped = skipped_ranges(runs); + let issues = wordcraft_proof::check_text_with(&text, &spans, spelling, grammar, list_item); + if skipped.is_empty() { + return issues; + } + issues.into_iter().filter(|i| !overlaps(&skipped, i.start, i.end)).collect() +} + +/// The byte ranges of the runs left unchecked, in order, those that overlap merged: their +/// starts and ends both rise, so [`overlaps`] finds an issue's by binary search instead of +/// looking at every run for every issue. +fn skipped_ranges(runs: &[ProofRun]) -> Vec<(usize, usize)> { + let mut ranges: Vec<(usize, usize)> = runs.iter().filter(|r| r.skip).map(|r| (r.range.start, r.range.end)).collect(); + ranges.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(ranges.len()); + for (s, e) in ranges { + crate::work::steps(1); + match merged.last_mut() { + Some(last) if s < last.1 => last.1 = last.1.max(e), + _ => merged.push((s, e)), + } + } + merged +} + +/// Whether bytes `a..b` overlap one of `skipped` ([`skipped_ranges`]): the first range ending +/// after `a` is the only one that can, as every later one starts no earlier. +fn overlaps(skipped: &[(usize, usize)], a: usize, b: usize) -> bool { + crate::work::steps(1); + let k = skipped.partition_point(|&(_, e)| e <= a); + skipped.get(k).is_some_and(|&(s, _)| s < b) } /// Whether the paragraph is a list item: its own numbering, or its style's, names a list (not 0). @@ -101,3 +130,26 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + + fn run(range: std::ops::Range, skip: bool) -> ProofRun { + ProofRun { range, lang: Default::default(), skip } + } + + /// The lookup finds what a look at every run finds, overlapping and empty runs included. + #[test] + fn skipped_lookup_matches_a_scan_of_every_run() { + let runs = [run(0..4, false), run(4..9, true), run(5..6, true), run(9..9, true), run(9..12, false), run(12..20, true), run(14..30, true)]; + let skipped = skipped_ranges(&runs); + for a in 0..32 { + for b in a..32 { + let scan = runs.iter().any(|r| r.skip && r.range.start < b && a < r.range.end); + assert_eq!(overlaps(&skipped, a, b), scan, "{a}..{b}"); + } + } + assert!(skipped_ranges(&[run(0..5, false)]).is_empty()); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index cf2670e5ad..36e40dc98e 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -116,7 +116,9 @@ impl WordApp { fonts_korean: false, fonts_hant: false, fonts_system_cjk: false, + fonts_system_arabic: false, want_system_cjk: false, + want_system_arabic: false, recover_fonts_for: None, recent_fonts_for: Vec::new(), fonts_frames: 0, @@ -223,6 +225,7 @@ impl WordApp { crate::language_packs::tick(self); let lang = i18n::Lang::from_pref(&self.ui.language); i18n::set_current(lang); + crate::rtl::sync(ctx); // New documents are written in the interface's language when WordCraft can proof it // (a Polish interface: Polish spelling, grammar and hyphenation from the first word). // An English interface writes the region's English (English (UK) on a British system, diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index 7f320ca371..fc0370e164 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -155,11 +155,15 @@ impl WordApp { /// face has changed since the last install. pub(super) fn install_ui_fonts(&mut self, ctx: &egui::Context, lang: i18n::Lang) { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); + // An Arabic interface, or a screen that shows Arabic names (language picker / packs) in + // another interface language, asks for an installed Arabic font when none is embedded. + let system_arabic = lang.rtl() || self.want_system_arabic; if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || lang.prefers_hant() != self.fonts_hant || system_cjk != self.fonts_system_cjk + || system_arabic != self.fonts_system_arabic { // Every window's borrowed faces: the fonts are the app's. let mut faces = self.canvas.ime_faces.clone(); @@ -170,11 +174,20 @@ impl WordApp { } } } - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), lang.prefers_hant(), system_cjk, &faces); + theme::install_fonts_language_arabic( + ctx, + lang.prefers_hans(), + lang.prefers_korean(), + lang.prefers_hant(), + system_cjk, + system_arabic, + &faces, + ); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); self.fonts_hant = lang.prefers_hant(); self.fonts_system_cjk = system_cjk; + self.fonts_system_arabic = system_arabic; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. // egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 2d606c7064..5541598d23 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -10,7 +10,7 @@ impl WordApp { /// whatever handed it over (the command line, a macOS open event, a drop). Returns the message. pub fn report_open_failure(&mut self, path: &str, error: &str) -> String { log::warn!("{path}: {error}"); - let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", path), ("error", error)]); + let msg = crate::i18n::fmt(crate::tl!("Couldn't open {path}: {error}"), &[("path", &crate::i18n::isolate_path(path)), ("error", error)]); self.status(msg.clone()); msg } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 7d5a27f0f4..6f6cfe5e1d 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1,6 +1,6 @@ //! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Close, Options, About. -use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; +use egui::{Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; use crate::theme::{Tokens, medium, regular, semibold}; @@ -65,12 +65,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { app.ui.backstage_page = "home".into(); } let mac = app.integrated_titlebar; - egui::Panel::left("backstage_nav") + crate::rtl::start_panel("backstage_nav") .exact_size(200.0) // Fixed width, as in Word: without this egui shows a resize handle that does nothing (#502). .resizable(false) .frame(egui::Frame::NONE.fill(t.nav).inner_margin(egui::Margin { left: 0, right: 0, top: if mac { 0 } else { 12 }, bottom: 12 })) .show(ui, |ui| { + // Right to left the navigation is on the right, away from the macOS traffic lights. + let rtl = crate::rtl::on(); let back = if mac { // macOS (#222): the Backstage covers the title bar, so its top band is the title // bar: the back button sits beside the traffic lights, centred with them and as far @@ -80,18 +82,34 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } // The chevron's stroke starts at the content edge. - let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left); + let c = if rtl { + pos2(band.max.x - 26.0, band.center().y) + } else { + back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left) + }; let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click()); - icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); + icons::paint( + ui.painter(), + Rect::from_center_size(c, vec2(18.0, 18.0)), + if rtl { "chevronRight" } else { "chevronLeft" }, + egui::Color32::WHITE, + egui::Color32::WHITE, + ); resp } else { // The hit area is scoped to the chevron, not the whole 200px-wide row (#3041): // the row reads as plain sidebar background next to it, so clicking there must // not also close the File page, same as the macOS chevron fix (#2972) below. let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover()); - let c = pos2(r.min.x + 26.0, r.center().y); + let c = pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y); let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click()); - icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE); + icons::paint( + ui.painter(), + Rect::from_center_size(c, vec2(18.0, 18.0)), + if rtl { "chevronRight" } else { "chevronLeft" }, + egui::Color32::WHITE, + egui::Color32::WHITE, + ); resp }; // Escape in the built-in file browser closes only the browser. @@ -112,10 +130,16 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } let ink = if on { egui::Color32::WHITE } else { egui::Color32::from_white_alpha(110) }; - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 31.0, r.center().y), vec2(18.0, 18.0)), icon, ink, ink); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 31.0), r.center().y), vec2(18.0, 18.0)), + icon, + ink, + ink, + ); ui.painter().text( - pos2(r.min.x + 52.0, r.center().y), - Align2::LEFT_CENTER, + pos2(crate::rtl::lead_x(rtl, r, 52.0), r.center().y), + crate::rtl::lead_center(rtl), tl!(label), if active { semibold(14.0) } else { medium(14.0) }, ink, @@ -137,7 +161,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if resp.hovered() { ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26)); } - ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, tl!(label), regular(13.0), egui::Color32::WHITE); + let at = pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y); + ui.painter().text(at, crate::rtl::lead_center(rtl), tl!(label), regular(13.0), egui::Color32::WHITE); if resp.clicked() { if id == "discord" { let _ = app.run("ui.discord", json!({})); @@ -151,19 +176,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| { // The Print page fills the pane and scrolls its settings and its preview itself (#1156). if app.ui.backstage_page == "print" { - export_page(app, ui); + crate::rtl::root(ui, |ui| export_page(app, ui)); return; } - egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() { - "home" => home_page(app, ui), - "new" => new_page(app, ui), - "open" => open_page(app, ui), - "saveAs" => save_as_page(app, ui), - "info" => info_page(app, ui), - "recover" => recovery_page(app, ui), - "print" | "export" => export_page(app, ui), - "options" => crate::options::page(app, ui), - _ => home_page(app, ui), + egui::ScrollArea::vertical().show(ui, |ui| { + crate::rtl::root(ui, |ui| match app.ui.backstage_page.as_str() { + "home" => home_page(app, ui), + "new" => new_page(app, ui), + "open" => open_page(app, ui), + "saveAs" => save_as_page(app, ui), + "info" => info_page(app, ui), + "recover" => recovery_page(app, ui), + "print" | "export" => export_page(app, ui), + "options" => crate::options::page(app, ui), + _ => home_page(app, ui), + }) }); }); } @@ -187,7 +214,7 @@ fn recovery_page(app: &mut WordApp, ui: &mut Ui) { ui.label(tl!("No recovery copies.")); } let busy = app.session.recovery_busy(); - egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("recovery_documents").min_col_width(180.0).spacing(vec2(24.0, 16.0)), |ui| { if !documents.is_empty() { ui.strong(tl!("Document")); ui.strong(tl!("Recovery copy")); @@ -269,7 +296,7 @@ fn heading(ui: &mut Ui, s: &str) { /// region-aware page preview, and creates a document from the template on click. fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) { let t = Tokens::get(ui.ctx()); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { // The thumbnail and its caption are one button named by the caption, so a screen reader // announces "Blank document, button" rather than unnamed text (#3176); the caption opens // the template too, as in Word. @@ -513,7 +540,7 @@ fn matching_personal(query: &str, list: &[PersonalTemplate]) -> Vec>(); for (p, name) in shown.iter().zip(&names) { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click()); + // The row shows a name and a path that may not fit: the path in full is its tooltip. + let resp = resp.on_hover_text(crate::i18n::isolate_path(p)); // Two documents with the same file name are told apart by their path, as on screen. let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() }; resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken)); if resp.hovered() { ui.painter().rect_filled(r, 4.0, t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent); - ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text); - ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim); + // Names and paths are set apart (a right-to-left line would scramble their marks and + // folders) and the row starts at the side reading starts from. + let rtl = crate::rtl::on(); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 18.0), r.center().y), vec2(22.0, 22.0)), + "printLayout", + t.icon, + t.accent, + ); + let side = crate::rtl::lead_center(rtl); + ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 13.0), side, crate::i18n::isolate(name), medium(13.0), t.text); + ui.painter().text(pos2(crate::rtl::lead_x(rtl, r, 40.0), r.min.y + 29.0), side, crate::i18n::isolate_path(p), regular(11.0), t.text_dim); if resp.clicked() { let failed = open_recent(app, p).err(); ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default())); @@ -664,73 +703,108 @@ fn save_as_page(app: &mut WordApp, ui: &mut Ui) { fn info_page(app: &mut WordApp, ui: &mut Ui) { heading(ui, "Info"); let info = app.session.run("file.info", &json!({})).unwrap_or_default(); + // Properties at the reading start, so at the right in a mirrored interface, Statistics after. + let (first, second) = if crate::rtl::on() { (1, 0) } else { (0, 1) }; ui.columns(2, |cols| { - let ui = &mut cols[0]; - ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0))); - // The fields edit a copy of the document's properties that is read afresh every frame, - // so each change goes into the document as it is typed (#260): waiting for the field to - // lose focus dropped the keystrokes, and leaving File with Escape never loses it at all. - // The keystrokes of one visit to a field are a single undo step. - let mut props = app.session.doc.core.clone(); - let mut edited = None; - let mut focused = None; - egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] { - let label = ui.label(tl!(l)); - let r = ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id); - if r.changed() { + crate::rtl::root(&mut cols[first], |ui| info_properties(app, ui, &info)); + crate::rtl::root(&mut cols[second], |ui| info_statistics(ui, &info)); + }); +} + +fn info_properties(app: &mut WordApp, ui: &mut Ui, info: &serde_json::Value) { + ui.label(egui::RichText::new(tl!("Properties")).font(semibold(15.0))); + // The fields edit a copy of the document's properties that is read afresh every frame, + // so each change goes into the document as it is typed (#260): waiting for the field to + // lose focus dropped the keystrokes, and leaving File with Escape never loses it at all. + // The keystrokes of one visit to a field are a single undo step. + let mut props = app.session.doc.core.clone(); + let mut edited = None; + let mut focused = None; + crate::rtl::Table::new("props").columns(2).spacing(vec2(12.0, 6.0)).show(ui, |t| { + for (l, v) in [ + ("Title", &mut props.title), + ("Subject", &mut props.subject), + ("Author", &mut props.creator), + ("Keywords", &mut props.keywords), + ("Category", &mut props.category), + ] { + t.row(|r| { + let label = r.cell(|ui| ui.label(tl!(l))); + let field = r.cell(|ui| ui.add(egui::TextEdit::singleline(v).id_salt(("info-prop", l))).labelled_by(label.id)); + if field.changed() { edited = Some(l); } - if r.has_focus() { + if field.has_focus() { focused = Some(l); } - ui.end_row(); - } - }); - if let Some(field) = edited { - if app.info_editing == Some((field, app.session.rev())) { - app.session.join_next_undo(); - } - let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok(); - app.info_editing = ok.then(|| (field, app.session.rev())); + }); } - if focused != app.info_editing.map(|(f, _)| f) { - app.info_editing = None; + }); + if let Some(field) = edited { + if app.info_editing == Some((field, app.session.rev())) { + app.session.join_next_undo(); } - // Advanced Properties › Custom (#2982): view and edit the document's custom properties. - ui.add_space(6.0); - if ui - .button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5))) - .on_hover_text(tl!("View and edit the document's custom properties.")) - .clicked() - { - let _ = app.run("ui.dialog", json!({"name": "customProperties"})); + let ok = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category})).is_ok(); + app.info_editing = ok.then(|| (field, app.session.rev())); + } + if focused != app.info_editing.map(|(f, _)| f) { + app.info_editing = None; + } + // Advanced Properties › Custom (#2982): view and edit the document's custom properties. + ui.add_space(6.0); + if ui + .button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5))) + .on_hover_text(tl!("View and edit the document's custom properties.")) + .clicked() + { + let _ = app.run("ui.dialog", json!({"name": "customProperties"})); + } + // Protect Document › Encrypt with Password (#55). + ui.add_space(18.0); + ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0))); + let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); + ui.label(if encrypted { + tl!("A password is required to open this document.") + } else { + tl!("Anyone can open this document. Encrypt it with a password to keep it private.") + }); + ui.horizontal(|ui| { + let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") }; + if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() { + let _ = app.run("file.encrypt", json!({})); } - // Protect Document › Encrypt with Password (#55). - ui.add_space(18.0); - ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0))); - let encrypted = info.get("encrypted").and_then(|v| v.as_bool()).unwrap_or(false); - ui.label(if encrypted { - tl!("A password is required to open this document.") - } else { - tl!("Anyone can open this document. Encrypt it with a password to keep it private.") - }); - ui.horizontal(|ui| { - let label = if encrypted { tl!("Change Password…") } else { tl!("Encrypt with Password…") }; - if ui.button(egui::RichText::new(label).font(medium(13.5))).clicked() { - let _ = app.run("file.encrypt", json!({})); - } - if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() { - let _ = app.run("file.encrypt", json!({"password": null})); - } - }); - let ui = &mut cols[1]; - ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0))); - for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] { - ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default())); + if encrypted && ui.button(egui::RichText::new(tl!("Remove Password")).font(medium(13.5))).clicked() { + let _ = app.run("file.encrypt", json!({"password": null})); + } + }); +} + +fn info_statistics(ui: &mut Ui, info: &serde_json::Value) { + ui.label(egui::RichText::new(tl!("Statistics")).font(semibold(15.0))); + for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] { + ui.label(format!("{}: {}", tl!(l), info.get(k).map(|v| v.to_string()).unwrap_or_default())); + } + let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet")); + // A path reads left to right whatever its folders are called, so it is a label of its own + // beside the words around it (one right-to-left line would swap its folders), and a long + // one is cut short, the whole path on hover, rather than wrapped to strand its last part. + let shown = crate::i18n::isolate_path(path); + let template = tl!("Location: {path}"); + let (before, after) = template.split_once("{path}").unwrap_or((template, "")); + // The columns start left to right, so the row's own direction is set: the words before the + // path come first at the right edge, the path after them. + let row = if crate::rtl::on() { egui::Layout::right_to_left(egui::Align::Min) } else { egui::Layout::left_to_right(egui::Align::Min) }; + ui.scope_builder(egui::UiBuilder::new().layout(row), |ui| { + if !before.trim().is_empty() { + ui.label(before.trim_end()); + } + let font = egui::TextStyle::Body.resolve(ui.style()); + let room = ui.available_width() - if after.trim().is_empty() { 0.0 } else { 40.0 }; + let fitted = crate::theme::fit_text(ui.painter(), &shown, &font, 0.0, ui.visuals().text_color(), room); + ui.label(fitted).on_hover_text(&shown); + if !after.trim().is_empty() { + ui.label(after.trim_start()); } - let path = info.get("path").and_then(|v| v.as_str()).unwrap_or(tl!("Not saved yet")); - ui.label(crate::i18n::fmt(tl!("Location: {path}"), &[("path", path)])); }); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e773a682b7..af351b6bd5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1199,7 +1199,7 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA if open { app.escape_free = false; } - let menu = egui::Popup::menu(&resp).open_bool(&mut shown); + let menu = crate::rtl::menu(&resp).open_bool(&mut shown); let menu = menu.at_position(app.canvas.context_menu_at.unwrap_or(out.inner_rect.left_top())); let with_toolbar = crate::mini_toolbar::context_menu_visible(app); let menu = if with_toolbar { menu.frame(egui::Frame::NONE).close_behavior(egui::PopupCloseBehavior::IgnoreClicks) } else { menu }; diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs index 04235290f0..7f279a1062 100644 --- a/crates/ui-egui/src/canvas/context_menu.rs +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -56,7 +56,7 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) { let item = |ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: serde_json::Value| { let sc = crate::widgets::shortcut_text(app, id); let on = crate::widgets::enabled(app, id); - if ui.add_enabled(on, egui::Button::new(tl!(label)).shortcut_text(sc)).clicked() { + if ui.add_enabled(on, crate::rtl::menu_item(ui, tl!(label), &sc, 0.0)).clicked() { let _ = app.run(id, params); ui.close(); } @@ -95,17 +95,19 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) { ui.separator(); if let Some(syn) = app.canvas.context_synonyms.clone().and_then(|v| v.as_array().cloned()).filter(|a| !a.is_empty()) { ui.menu_button(tl!("Synonyms"), |ui| { - for w in syn.iter().filter_map(|x| x.as_str()) { - if ui.button(w).clicked() { - let _ = app.run("select.word", json!({})); - let (a, b) = app.session.sel.ordered(); - let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0); - let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b }; - app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end }; - let _ = app.run("text.insert", json!({"text": w, "raw": true})); - ui.close(); + crate::rtl::root(ui, |ui| { + for w in syn.iter().filter_map(|x| x.as_str()) { + if ui.button(w).clicked() { + let _ = app.run("select.word", json!({})); + let (a, b) = app.session.sel.ordered(); + let trimmed = app.session.doc.para_at(&a).and_then(|p| p.text.get(a.off..b.off)).map(|t| t.trim_end().len()).unwrap_or(0); + let end = wordcraft_doc::Pos { off: a.off + trimmed, ..b }; + app.session.sel = wordcraft_engine::Selection { anchor: a, focus: end }; + let _ = app.run("text.insert", json!({"text": w, "raw": true})); + ui.close(); + } } - } + }) }); } item(ui, app, "Font…", "ui.dialog", json!({"name": "font"})); @@ -154,10 +156,12 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) { let pick = table_pick(&app.session); ui.separator(); ui.menu_button(tl!("Insert"), |ui| { - item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({})); - item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({})); - item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({})); - item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({})); + crate::rtl::root(ui, |ui| { + item(ui, app, "Insert Columns to the Left", "table.insertColumnLeft", json!({})); + item(ui, app, "Insert Columns to the Right", "table.insertColumnRight", json!({})); + item(ui, app, "Insert Rows Above", "table.insertRowAbove", json!({})); + item(ui, app, "Insert Rows Below", "table.insertRowBelow", json!({})); + }) }); // Every Delete is there whatever is selected, as on Word's Delete button: with the caret // in a cell, Delete Table is how the table goes (#1198). @@ -179,9 +183,11 @@ pub(super) fn context_menu(app: &mut WordApp, ui: &mut Ui) { } if pick == TablePick::Table { ui.menu_button(tl!("AutoFit"), |ui| { - item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); - item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); - item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); + crate::rtl::root(ui, |ui| { + item(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"})); + item(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"})); + item(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"})); + }) }); } item(ui, app, "Text Direction", "table.textDirection", json!({})); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2f0801f631..ac6fc94d8d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -12,7 +12,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat match (unreadable, missing) { (Some((file, reason)), _) => Some(crate::i18n::fmt( tl!("The spelling dictionary for {language} couldn't be read, so it isn't checked. File: {file}. Reason: {reason}"), - &[("language", lang), ("file", &file.to_string_lossy()), ("reason", reason)], + &[("language", lang), ("file", &crate::i18n::isolate_path(&file.to_string_lossy())), ("reason", reason)], )), (None, Some(name)) => Some(crate::i18n::fmt( tl!( @@ -93,9 +93,9 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) { } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), "more", t.icon, t.accent); let resp = resp.on_hover_text(tl!("Customize Quick Access Toolbar")); - egui::Popup::menu(&resp).show(|ui| { + crate::rtl::menu(&resp).show(|ui| { ui.set_min_width(220.0); - qat_menu(ui, app); + crate::rtl::root(ui, |ui| qat_menu(ui, app)); }); } @@ -144,7 +144,7 @@ fn undo_list_button(ui: &mut Ui, app: &mut WordApp, enabled: bool) { let c = if enabled { t.icon } else { t.text_disabled }; icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(9.0, 9.0)), "dropdown", c, c); let resp = resp.on_hover_text(tl!("Undo History")); - egui::Popup::menu(&resp).show(|ui| undo_list(ui, app)); + crate::rtl::menu(&resp).show(|ui| undo_list(ui, app)); } /// The Undo list (#701): recent steps, newest first. Hovering a row highlights it and every newer @@ -275,6 +275,12 @@ impl MacTitleBar { } } +/// Where the AutoSave switch's knob sits in its track `r`: on at the right and off at the left, +/// mirrored in a right-to-left interface (on at the left). +fn autosave_knob_x(rtl: bool, on: bool, r: Rect) -> f32 { + crate::rtl::lead_x(rtl != on, r, 8.0) +} + /// Draws the title bar with window dragging, AutoSave, quick access, account, community and search /// controls, and the centred clickable document title. pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { @@ -299,83 +305,97 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } - let mut qat_end = full.min.x; - let mut right_start = full.max.x; - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - // AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't - // cover the document (#196, #176); clicking it then offers Save As where that helps. - let block = app.autosave_block(); - let on = app.autosaves(); - let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim }; - // The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome - // text is not document text to select (double-clicking it copied "AutoSave"). - let text = ui.add( - egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color)).selectable(false).sense(Sense::click()), - ); - let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); - let (fill, stroke, knob_fill) = match (&block, on) { - (Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)), - (None, true) => (t.accent, t.accent, t.on_accent), - (None, false) => (t.input, t.border_strong, t.text_dim), - }; - ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); - let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 }; - ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill); - let tip = block - .as_ref() - .map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason()); - let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand); - let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand); - if text.clicked() || resp.clicked() { - match block { - None => { - let _ = app.run("file.autosave", json!({})); + // The free stretch between the two clusters, for the centred title. Right to left the + // clusters swap sides: AutoSave and the Quick Access Toolbar start at the right. + let rtl = crate::rtl::on(); + let mut qat_end = if rtl { full.max.x } else { full.min.x }; + let mut right_start = if rtl { full.min.x } else { full.max.x }; + crate::rtl::root(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + // AutoSave switch: greyed out, with the reason as its tooltip, when AutoSave can't + // cover the document (#196, #176); clicking it then offers Save As where that helps. + let block = app.autosave_block(); + let on = app.autosaves(); + let color = if block.is_some() { t.text_dim.gamma_multiply(0.6) } else { t.text_dim }; + // The text is part of the switch (#3370): clicking it toggles AutoSave, and chrome + // text is not document text to select (double-clicking it copied "AutoSave"). + let text = ui.add( + egui::Label::new(egui::RichText::new(tl!("AutoSave")).font(regular(11.5)).color(color)) + .selectable(false) + .sense(Sense::click()), + ); + let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click()); + let (fill, stroke, knob_fill) = match (&block, on) { + (Some(_), _) => (t.input.gamma_multiply(0.5), t.border.gamma_multiply(0.7), t.text_dim.gamma_multiply(0.45)), + (None, true) => (t.accent, t.accent, t.on_accent), + (None, false) => (t.input, t.border_strong, t.text_dim), + }; + ui.painter().rect(r, 8.0, fill, Stroke::new(1.0, stroke), egui::StrokeKind::Inside); + let knob = autosave_knob_x(rtl, on, r); + ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, knob_fill); + let tip = block + .as_ref() + .map_or_else(|| tl!("AutoSave saves every change to the file (needs a saved document)").to_string(), |b| b.reason()); + let text = text.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand); + let resp = resp.on_hover_text(tip.as_str()).on_hover_cursor(egui::CursorIcon::PointingHand); + if text.clicked() || resp.clicked() { + match block { + None => { + let _ = app.run("file.autosave", json!({})); + } + // AutoSave comes on once Save As has saved (maybe on a later frame, #94). + Some(b) if b.save_as_helps() => { + let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave); + } + Some(b) => app.status(b.reason()), } - // AutoSave comes on once Save As has saved (maybe on a later frame, #94). - Some(b) if b.save_as_helps() => { - let _ = app.save_as(crate::file_dialogs::AfterSave::AutoSave); - } - Some(b) => app.status(b.reason()), - } - } - ui.add_space(8.0); - // Hidden (#1945): neither the buttons nor the … menu; the title centres as before. - if app.ui.quick_access_toolbar { - quick_access(ui, app); - } - qat_end = ui.min_rect().max.x; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Account / community. - let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); - ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account"))); - let initials: String = app.session.user_initials().chars().take(2).collect(); - ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); - if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() { - let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"})); } ui.add_space(8.0); - if app.ui.show_discord { - let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click()); - ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent }); - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)), - "discord", - t.on_accent, - t.on_accent, - ); - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, tl!("Discord"), medium(11.5), t.on_accent); - if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() { - let _ = app.run("ui.discord", json!({})); + // Hidden (#1945): neither the buttons nor the … menu; the title centres as before. + if app.ui.quick_access_toolbar { + quick_access(ui, app); + } + qat_end = if rtl { ui.min_rect().min.x } else { ui.min_rect().max.x }; + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { + // Account / community. + let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click()); + ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account"))); + let initials: String = app.session.user_initials().chars().take(2).collect(); + ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent); + if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() { + let _ = app.run("ui.backstage", json!({"value": true, "page": "options", "pane": "general"})); } ui.add_space(8.0); - } - search_box(app, ui, control_h); - right_start = ui.min_rect().min.x; - }); + if app.ui.show_discord { + let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click()); + ui.painter().rect_filled(r, 11.0, if resp.hovered() { t.accent_text } else { t.accent }); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 13.0), r.center().y), vec2(14.0, 14.0)), + "discord", + t.on_accent, + t.on_accent, + ); + ui.painter().text( + pos2(crate::rtl::lead_x(rtl, r, 24.0), r.center().y), + crate::rtl::lead_center(rtl), + tl!("Discord"), + medium(11.5), + t.on_accent, + ); + if resp.on_hover_text(tl!("Join the ArtCraft community on Discord")).clicked() { + let _ = app.run("ui.discord", json!({})); + } + ui.add_space(8.0); + } + search_box(app, ui, control_h); + right_start = if rtl { ui.min_rect().max.x } else { ui.min_rect().min.x }; + }); + }) }); + let (qat_end, right_start) = if rtl { (right_start, qat_end) } else { (qat_end, right_start) }; // Centred title. let title = if !app.has_document() { // No document open (#1129): the app's name alone. @@ -383,7 +403,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { } else { format!( "{} — {}", - app.title_stem(), + crate::i18n::isolate(&app.title_stem()), if app.session.dirty { tl!("Edited") } else if app.session.path.is_some() { @@ -430,17 +450,24 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) { /// of Full Screen (#1678). fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) { let t = Tokens::get(ui.ctx()); + let rtl = crate::rtl::on(); let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click()); let ph = tl!("Search commands and help"); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph)); ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent); + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 16.0), r.center().y), vec2(16.0, 16.0)), + "search", + t.text_dim, + t.accent, + ); let pfont = TypeRung::Control.regular(); let ptrack = TypeRung::Control.tracking(); paint_text( ui.painter(), - pos2(r.min.x + 30.0, r.center().y - TypeRung::Control.line_height() / 2.0), - Align2::LEFT_TOP, + pos2(crate::rtl::lead_x(rtl, r, 30.0), r.center().y - TypeRung::Control.line_height() / 2.0), + crate::rtl::lead_top(rtl), ph, &pfont, ptrack, @@ -555,187 +582,203 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { if !app.has_document() { return; } - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing = vec2(14.0, 0.0); - let l = app.session.layout(); - let page = if let Some(p) = app.canvas.viewed_page { - // The page in view (#3880), as the scrollbar's tip names it. - p.min(l.pages.len().saturating_sub(1)) + 1 - } else if app.session.selects_whole_story() { - app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1 - } else { - l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1) - }; - let st = |ui: &mut Ui, s: &str| { - let font = TypeRung::Caption.regular(); - let track = TypeRung::Caption.tracking(); - let w = text_width(ui.painter(), s, &font, track, t.text_dim); - let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click()); - paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s)); - resp - }; - if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())])) - .on_hover_text(tl!("Go To (⌘⌥G)")) - .clicked() - { - let _ = app.run("ui.dialog", json!({"name": "goto"})); - } - let words = app.cached_word_count(); - // A selected picture/shape/text box isn't a text selection: show the total. - let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() { - crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())]) - } else { - let sw = wordcraft_doc::count_words(&app.session.selected_text()); - crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())]) - }; - if st(ui, &wtxt).clicked() { - let _ = app.run("ui.dialog", json!({"name": "wordCount"})); - } - if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) { - let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) { - Some(PartKind::Footnote) => tl!("Editing footnote"), - Some(PartKind::Endnote) => tl!("Editing endnote"), - _ => tl!("Editing header/footer"), + let rtl = crate::rtl::on(); + crate::rtl::root(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 0.0); + let l = app.session.layout(); + let page = if let Some(p) = app.canvas.viewed_page { + // The page in view (#3880), as the scrollbar's tip names it. + p.min(l.pages.len().saturating_sub(1)) + 1 + } else if app.session.selects_whole_story() { + app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1 + } else { + l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1) }; - st(ui, label); - } - // The language at the caret; a click sets the proofing language (Review › Language). - let lang = crate::ribbon::caret_language(app); - let missing = crate::ribbon::caret_missing_dictionary(app); - let unreadable = crate::ribbon::caret_unreadable_dictionary(app); - let shown = if unreadable.is_some() { - crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)]) - } else if missing.is_some() { - crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)]) - } else { - lang.clone() - }; - let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string()); - let resp = st(ui, &shown).on_hover_text(tip); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(200.0); - crate::ribbon::language_menu(ui, app); - }); - if app.session.doc.settings.track_changes { - st(ui, tl!("Track Changes: On")); - } - if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() { - let _ = app.run("text.overtype", json!({"value": false})); - } - // The message is drawn where the row is now; the controls on the right are laid - // out first, so the message can be shortened to the space they leave (#2402): a - // long one (the path of a file that isn't there) must not be drawn over the - // Focus and view buttons. - let msg_at = ui.cursor().min; - let mut controls_left = f32::INFINITY; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 0.0); - let reading = app.session.view.read_mode; - let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round(); - let ztxt = format!("{z}%"); - let zfont = TypeRung::Caption.regular(); - let ztrack = TypeRung::Caption.tracking(); - let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim); - // A fixed width for three digits plus a small reserve, so the controls to the - // left don't shift as the percentage gains or loses digits (#1975). - let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0; - let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click()); - paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim); - if zresp.clicked() && !reading { - let _ = app.run("ui.dialog", json!({ "name": "zoom" })); + let st = |ui: &mut Ui, s: &str| { + let font = TypeRung::Caption.regular(); + let track = TypeRung::Caption.tracking(); + let w = text_width(ui.painter(), s, &font, track, t.text_dim); + let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click()); + paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s)); + resp + }; + if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())])) + .on_hover_text(tl!("Go To (⌘⌥G)")) + .clicked() + { + let _ = app.run("ui.dialog", json!({"name": "goto"})); } - if small_icon(ui, "plus", tl!("Zoom In")) { - if reading { - let _ = app.run("view.readTextSize", json!({"value": (z + 10.0).min(150.0)})); - } else { - let _ = app.run("view.zoomIn", json!({})); - } + let words = app.cached_word_count(); + // A selected picture/shape/text box isn't a text selection: show the total. + let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() { + crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())]) + } else { + let sw = wordcraft_doc::count_words(&app.session.selected_text()); + crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())]) + }; + if st(ui, &wtxt).clicked() { + let _ = app.run("ui.dialog", json!({"name": "wordCount"})); } - // Reading zoom changes only reading presentation, never the normal view zoom. - let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT }; - let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false); - ui.spacing_mut().slider_width = 110.0; - let response = ui.add(slider); - response.widget_info(|| { - egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") }) + if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) { + let label = match crate::canvas::part_kind(app, app.session.sel.focus.story) { + Some(PartKind::Footnote) => tl!("Editing footnote"), + Some(PartKind::Endnote) => tl!("Editing endnote"), + _ => tl!("Editing header/footer"), + }; + st(ui, label); + } + // The language at the caret; a click sets the proofing language (Review › Language). + let lang = crate::ribbon::caret_language(app); + let missing = crate::ribbon::caret_missing_dictionary(app); + let unreadable = crate::ribbon::caret_unreadable_dictionary(app); + let shown = if unreadable.is_some() { + crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)]) + } else if missing.is_some() { + crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)]) + } else { + lang.clone() + }; + let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string()); + let resp = st(ui, &shown).on_hover_text(tip); + crate::rtl::menu(&resp).show(|ui| { + ui.set_min_width(200.0); + crate::rtl::root(ui, |ui| crate::ribbon::language_menu(ui, app)); }); - if response.changed() { - let command = if reading { "view.readTextSize" } else { "view.zoom" }; - let _ = app.run(command, json!({"value": (v * 100.0).round()})); + if app.session.doc.settings.track_changes { + st(ui, tl!("Track Changes: On")); } - if small_icon(ui, "minus", tl!("Zoom Out")) { - if reading { - let _ = app.run("view.readTextSize", json!({"value": (z - 10.0).max(75.0)})); - } else { - let _ = app.run("view.zoomOut", json!({})); + if app.session.prefs.overtype && st(ui, tl!("Overtype")).on_hover_text(tl!("Click to switch back to inserting")).clicked() { + let _ = app.run("text.overtype", json!({"value": false})); + } + // The message is drawn where the row is now; the controls on the right are laid + // out first, so the message can be shortened to the space they leave (#2402): a + // long one (the path of a file that isn't there) must not be drawn over the + // Focus and view buttons. + // Right to left the row runs the other way: the message starts at the right edge of + // what is left, and the controls (a cluster at the left) end at their right edge. + let msg_at = ui.cursor().min; + let msg_edge = ui.available_rect_before_wrap().max.x; + let mut controls_left = if rtl { f32::NEG_INFINITY } else { f32::INFINITY }; + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + let reading = app.session.view.read_mode; + let z = (if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT } * 100.0).round(); + let ztxt = format!("{z}%"); + let zfont = TypeRung::Caption.regular(); + let ztrack = TypeRung::Caption.tracking(); + let zw = text_width(ui.painter(), &ztxt, &zfont, ztrack, t.text_dim); + // A fixed width for three digits plus a small reserve, so the controls to the + // left don't shift as the percentage gains or loses digits (#1975). + let zslot = text_width(ui.painter(), "000%", &zfont, ztrack, t.text_dim) + 4.0; + let (zr, zresp) = ui.allocate_exact_size(vec2(zw.max(zslot) + 1.0, TypeRung::Caption.line_height()), Sense::click()); + paint_text(ui.painter(), pos2(zr.max.x - 1.0, zr.min.y), Align2::RIGHT_TOP, &ztxt, &zfont, ztrack, t.text_dim); + if zresp.clicked() && !reading { + let _ = app.run("ui.dialog", json!({ "name": "zoom" })); } - } - ui.add_space(10.0); - let mode = app.session.view.mode; - let read = app.session.view.read_mode; - for (icon, id, name, on) in [ - ("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web), - ("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read), - ("readMode", "view.readMode", tl!("Read Mode"), read), - ] { - let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); - if on { - ui.painter().rect_filled(r, 3.0, t.checked); - } else if resp.hovered() { + // The slider grows to the right in every language, so the buttons stay at its + // ends: plus on the right. This cluster fills from the far end inwards. + let zoom_step = |app: &mut WordApp, plus: bool| { + if reading { + let value = if plus { (z + 10.0).min(150.0) } else { (z - 10.0).max(75.0) }; + let _ = app.run("view.readTextSize", json!({"value": value})); + } else { + let _ = app.run(if plus { "view.zoomIn" } else { "view.zoomOut" }, json!({})); + } + }; + let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true)); + let (near, far) = if rtl { (inner, outer) } else { (outer, inner) }; + if small_icon(ui, near.0, tl!(near.1)) { + zoom_step(app, near.2); + } + // Reading zoom changes only reading presentation, never the normal view zoom. + let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT }; + let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false); + ui.spacing_mut().slider_width = 110.0; + let response = ui.add(slider); + response.widget_info(|| { + egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") }) + }); + if response.changed() { + let command = if reading { "view.readTextSize" } else { "view.zoom" }; + let _ = app.run(command, json!({"value": (v * 100.0).round()})); + } + if small_icon(ui, far.0, tl!(far.1)) { + zoom_step(app, far.2); + } + ui.add_space(10.0); + let mode = app.session.view.mode; + let read = app.session.view.read_mode; + for (icon, id, name, on) in [ + ("webLayout", "view.webLayout", tl!("Web Layout"), mode == wordcraft_layout::ViewMode::Web), + ("printLayout", "view.printLayout", tl!("Print Layout"), mode == wordcraft_layout::ViewMode::Print && !read), + ("readMode", "view.readMode", tl!("Read Mode"), read), + ] { + let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click()); + if on { + ui.painter().rect_filled(r, 3.0, t.checked); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name)); + if resp.on_hover_text(name).clicked() { + let _ = app.run(id, json!({})); + } + } + ui.add_space(8.0); + let focus = tl!("Focus"); + let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x); + let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click()); + // The Focus button is the last of the controls, so it is the leftmost one: the + // message has to stop before it (#2402). + controls_left = if rtl { r.max.x } else { r.min.x }; + if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name)); - if resp.on_hover_text(name).clicked() { - let _ = app.run(id, json!({})); + let icon_at = pos2(crate::rtl::lead_left(rtl, r, 2.0, 16.0), r.min.y + 2.0); + icons::paint(ui.painter(), Rect::from_min_size(icon_at, vec2(16.0, 16.0)), "focus", t.icon, t.accent); + let ffont = TypeRung::Caption.regular(); + let ftrack = TypeRung::Caption.tracking(); + paint_text( + ui.painter(), + pos2(crate::rtl::lead_x(rtl, r, 22.0), r.center().y - TypeRung::Caption.line_height() / 2.0), + crate::rtl::lead_top(rtl), + focus, + &ffont, + ftrack, + t.text_dim, + ); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus)); + if resp.clicked() { + let _ = app.run("view.focus", json!({})); + } + }); + if let Some((msg, at)) = &app.status_msg + && crate::now_ms() - at < 6000.0 + { + let font = TypeRung::Caption.regular(); + let track = TypeRung::Caption.tracking(); + // The room the message has: what is left between here and the controls. + let room = if rtl { + msg_edge - controls_left - ui.spacing().item_spacing.x + } else { + controls_left - ui.spacing().item_spacing.x - msg_at.x + }; + let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room); + if !msg_shown.is_empty() { + let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text); + let at = if rtl { pos2(msg_edge - w - 1.0, msg_at.y) } else { msg_at }; + let rect = Rect::from_min_size(at, vec2(w + 1.0, TypeRung::Caption.line_height())); + // The whole message is in the tooltip; the bar only has room for the + // beginning of it (#2402). + let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone()); + paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text); + ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height())); } } - ui.add_space(8.0); - let focus = tl!("Focus"); - let focus_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(focus.to_string(), regular(11.5), t.text_dim).size().x); - let (r, resp) = ui.allocate_exact_size(vec2((focus_w + 26.0).max(56.0), 20.0), Sense::click()); - // The Focus button is the last of the controls, so it is the leftmost one: the - // message has to stop before it (#2402). - controls_left = r.min.x; - if resp.hovered() { - ui.painter().rect_filled(r, 3.0, t.hover); - } - icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent); - let ffont = TypeRung::Caption.regular(); - let ftrack = TypeRung::Caption.tracking(); - paint_text( - ui.painter(), - pos2(r.min.x + 22.0, r.center().y - TypeRung::Caption.line_height() / 2.0), - Align2::LEFT_TOP, - focus, - &ffont, - ftrack, - t.text_dim, - ); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus)); - if resp.clicked() { - let _ = app.run("view.focus", json!({})); - } - }); - if let Some((msg, at)) = &app.status_msg - && crate::now_ms() - at < 6000.0 - { - let font = TypeRung::Caption.regular(); - let track = TypeRung::Caption.tracking(); - // The room the message has: what is left between here and the controls. - let room = controls_left - ui.spacing().item_spacing.x - msg_at.x; - let msg_shown = fit_text(ui.painter(), msg, &font, track, t.accent_text, room); - if !msg_shown.is_empty() { - let w = text_width(ui.painter(), &msg_shown, &font, track, t.accent_text); - let rect = Rect::from_min_size(msg_at, vec2(w + 1.0, TypeRung::Caption.line_height())); - // The whole message is in the tooltip; the bar only has room for the - // beginning of it (#2402). - let _ = ui.interact(rect, status_message_id(), Sense::hover()).on_hover_text(msg.clone()); - paint_text(ui.painter(), rect.min, Align2::LEFT_TOP, &msg_shown, &font, track, t.accent_text); - ui.allocate_space(vec2(w + 1.0, TypeRung::Caption.line_height())); - } - } + }) }); }); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index f3b3791a47..b6018ad17f 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -307,3 +307,14 @@ fn clicking_the_autosave_label_toggles_autosave() { assert!(h.state().autosaves(), "and clicking it again turns it back on"); let _ = std::fs::remove_dir_all(dir); } + +/// The AutoSave knob is at the right when the switch is on and at the left when it is off; a +/// right-to-left interface mirrors that. +#[test] +fn the_autosave_knob_sits_right_when_on_and_mirrors_right_to_left() { + let track = Rect::from_min_max(egui::pos2(100.0, 0.0), egui::pos2(130.0, 16.0)); + assert_eq!(autosave_knob_x(false, true, track), 122.0, "left to right, on: right"); + assert_eq!(autosave_knob_x(false, false, track), 108.0, "left to right, off: left"); + assert_eq!(autosave_knob_x(true, true, track), 108.0, "right to left, on: left"); + assert_eq!(autosave_knob_x(true, false, track), 122.0, "right to left, off: right"); +} diff --git a/crates/ui-egui/src/colors.rs b/crates/ui-egui/src/colors.rs index 4685fbcbb9..e145407637 100644 --- a/crates/ui-egui/src/colors.rs +++ b/crates/ui-egui/src/colors.rs @@ -497,14 +497,14 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { }); ui.separator(); ui.horizontal(|ui| { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(330.0); match d.tab { ColorsTab::Standard => standard(ui, d), ColorsTab::Custom => custom(ui, d), } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(110.0); preview(ui, d, &t); }); @@ -512,7 +512,7 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option { let mut out = None; ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!("OK"))), tl!("Cancel")); if cancelb.clicked() { out = Some(false); @@ -670,7 +670,7 @@ fn custom(ui: &mut Ui, d: &mut ColorsDialog) { ui.add_space(8.0); }); ui.add_space(8.0); - egui::Grid::new("colors_custom").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { + 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 { diff --git a/crates/ui-egui/src/content_controls.rs b/crates/ui-egui/src/content_controls.rs index b482244590..da18c646f4 100644 --- a/crates/ui-egui/src/content_controls.rs +++ b/crates/ui-egui/src/content_controls.rs @@ -230,7 +230,7 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option { // 1970-01-01 was a Thursday: Monday-first column of the 1st. let lead = (first + 3).rem_euclid(7) as u32; let days = wordcraft_engine::cmd::controls::days_in_month(y, m); - egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("cc_calendar").spacing(vec2(2.0, 2.0)), |ui| { for cell in 0..(lead + days) { if cell < lead { ui.label(""); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 19de605281..4a5217addc 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -249,27 +249,29 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ); ui.label(RichText::new(summary).small().weak()); ui.separator(); - egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { - if list.is_empty() { - ui.label(tl!("No contributor data was built into this copy.")); - } else if v.table { - table(ui, &list, &mut v); - } else { - ui.horizontal_wrapped(|ui| { - for (i, c) in list.iter().enumerate() { - if i > 0 { - ui.label(RichText::new("·").weak()); + crate::rtl::island(ui, |ui| { + egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { + if list.is_empty() { + ui.label(tl!("No contributor data was built into this copy.")); + } else if v.table { + table(ui, &list, &mut v); + } else { + ui.horizontal_wrapped(|ui| { + for (i, c) in list.iter().enumerate() { + if i > 0 { + ui.label(RichText::new("·").weak()); + } + ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - } - }); - } + }); + } + }) }); ui.data_mut(|d| d.insert_temp(id, v)); } fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(k.label().0)).clicked() { @@ -305,22 +307,24 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(tl!(h)).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); + crate::rtl::island(ui, |ui| { + egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |ui| { + for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { + ui.label(RichText::new(tl!(h)).strong()); + } ui.end_row(); - } - }); + for m in MODELS { + ui.label(m.company); + ui.label(m.model); + ui.label(m.version); + ui.label(group(m.commits)); + ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); + ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); + ui.end_row(); + } + }); + }) }); } diff --git a/crates/ui-egui/src/dialogs/bookmarks.rs b/crates/ui-egui/src/dialogs/bookmarks.rs index 5b5c110c66..8e595634d1 100644 --- a/crates/ui-egui/src/dialogs/bookmarks.rs +++ b/crates/ui-egui/src/dialogs/bookmarks.rs @@ -31,8 +31,8 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc let marks = bookmark_names(&app.session.doc, *by_location, *hidden); let t = Tokens::get(ui.ctx()); let (mut add, mut delete, mut go_to) = (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!("Bookmark 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| { @@ -51,7 +51,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc }); }); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(18.0); let listed = marks.iter().any(|n| n == name.trim()); let w = vec2(90.0, 0.0); @@ -61,9 +61,9 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc }); }); ui.add_space(6.0); - ui.horizontal_top(|ui| { + crate::rtl::horizontal_top(ui, |ui| { ui.label(tl!("Sort by:")); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.radio_value(by_location, false, tl!("Name")); ui.radio_value(by_location, true, tl!("Location")); }); @@ -84,7 +84,7 @@ pub(super) fn bookmark(app: &mut WordApp, ui: &mut Ui, name: &mut String, by_loc ui.add_space(8.0); let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { close |= ui.button(if *deleted { tl!("Close") } else { tl!("Cancel") }).clicked(); }); }); diff --git a/crates/ui-egui/src/dialogs/chrome.rs b/crates/ui-egui/src/dialogs/chrome.rs index 4c112d16b7..821f308d03 100644 --- a/crates/ui-egui/src/dialogs/chrome.rs +++ b/crates/ui-egui/src/dialogs/chrome.rs @@ -137,7 +137,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { let window = crate::window_close::window(ctx, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)); let shown = place_dialog(ctx, id, window).show(ctx, |ui| { ui.set_min_width(340.0); - close = body(app, ui, &mut d); + close = crate::rtl::root(ui, |ui| body(app, ui, &mut d)); }); if let Some(shown) = &shown { if let Dialog::SaveChanges { name, message, .. } = &d { @@ -187,7 +187,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { let mut r = (false, false); ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { // Enter and Escape belong to the Colors dialog while it is open above this one. let keys = !crate::colors::is_open(ui.ctx()); let (okb, cancelb) = ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, ok)), tl!("Cancel")); @@ -206,7 +206,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { pub(super) fn close_button(ui: &mut Ui) -> bool { ui.add_space(8.0); let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked(); }); close diff --git a/crates/ui-egui/src/dialogs/dialogs_chart.rs b/crates/ui-egui/src/dialogs/dialogs_chart.rs index a0f03a529a..7adf3e03ef 100644 --- a/crates/ui-egui/src/dialogs/dialogs_chart.rs +++ b/crates/ui-egui/src/dialogs/dialogs_chart.rs @@ -198,7 +198,7 @@ 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; - egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)).show(ui, |ui| { + 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; @@ -256,7 +256,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool { let mut remove_row = None; let (can_remove_series, can_remove_row) = (f.names.len() > 1, f.categories.len() > 1); egui::ScrollArea::horizontal().id_salt("chart_data_h").max_width(760.0).show(ui, |ui| { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.horizontal(|ui| { ui.add_sized([CELL_W, ROW_H - 4.0], egui::Label::new(egui::RichText::new(tl!("Categories")).strong())); ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover()); diff --git a/crates/ui-egui/src/dialogs/dialogs_controls.rs b/crates/ui-egui/src/dialogs/dialogs_controls.rs index 136d3be469..2da354e452 100644 --- a/crates/ui-egui/src/dialogs/dialogs_controls.rs +++ b/crates/ui-egui/src/dialogs/dialogs_controls.rs @@ -113,7 +113,7 @@ 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); - egui::Grid::new("cc_props").num_columns(2).spacing([10.0, 6.0]).show(ui, |ui| { + 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(); @@ -163,7 +163,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: &mut ControlForm) -> bool { let mut done = false; ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { // #1906: no forced label color; the theme button text reads on the theme fill. let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.button(tl!("OK")), tl!("Cancel")); if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { @@ -184,7 +184,7 @@ 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; - egui::Grid::new("cc_entries").num_columns(3).spacing([6.0, 4.0]).show(ui, |ui| { + 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(); diff --git a/crates/ui-egui/src/dialogs/dialogs_insert.rs b/crates/ui-egui/src/dialogs/dialogs_insert.rs index 8ab16eb06a..8485e85b9d 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert.rs @@ -99,7 +99,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str, cancel: &str, ok_enabled: bool) -> let mut r = (false, false); ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!(cancel)); if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs index cc92e9e412..7d5295e35b 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/columns.rs @@ -62,7 +62,7 @@ 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| { - egui::Grid::new("dropcap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |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 @@ -380,11 +380,11 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> }); ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Width and spacing")).font(semibold(12.5))); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { let mut changed = None; egui::ScrollArea::vertical().id_salt("columns_widths").max_height(130.0).show(ui, |ui| { - egui::Grid::new("columns_grid").num_columns(3).spacing(vec2(12.0, 4.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:")); @@ -416,7 +416,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> f.rebalance(i); } let mut equal = f.equal; - if ui.add_enabled(f.count > 1, egui::Checkbox::new(&mut equal, tl!("Equal column width"))).changed() { + if ui.add_enabled_ui(f.count > 1, |ui| ui.checkbox(&mut equal, tl!("Equal column width"))).inner.changed() { f.equal = equal; if equal { f.even_out(); @@ -424,7 +424,7 @@ pub(super) fn columns_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ColumnsForm) -> } }); ui.add_space(8.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Preview")); columns_preview(ui, f, &t); }); diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/field.rs b/crates/ui-egui/src/dialogs/dialogs_insert/field.rs index e2a30990d8..c482ebbdff 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/field.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/field.rs @@ -264,9 +264,9 @@ fn merge_field_name(code: &str) -> String { pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> bool { let t = Tokens::get(ui.ctx()); ui.set_width(620.0); - ui.horizontal_top(|ui| { + crate::rtl::horizontal_top(ui, |ui| { // Left: category and field names. - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(200.0); ui.label(egui::RichText::new(tl!("Pick a field from the list")).font(semibold(12.5))); ui.label(tl!("Categories:")); @@ -293,7 +293,7 @@ pub(super) fn field_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FieldForm) -> boo }); ui.add_space(14.0); // Right: the field's options, or its code. - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Field properties")).font(semibold(12.5))); if let Some((_, _, desc)) = FIELDS.iter().find(|(n, _, _)| *n == f.name) { ui.label(egui::RichText::new(format!("{} {}", tl!("Description:"), tl!(desc))).small().color(t.text_dim)); diff --git a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs index 2f4ff2b2a3..a1982bd079 100644 --- a/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs +++ b/crates/ui-egui/src/dialogs/dialogs_insert/symbol.rs @@ -388,7 +388,7 @@ pub(super) fn symbol_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SymbolForm) -> b ui.horizontal(|ui| { let name = f.selected.map(|c| format!("U+{:04X}", c as u32)).unwrap_or_default(); ui.label(egui::RichText::new(name).font(semibold(12.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { ui.label(tl!("from Unicode (hex)")); let resp = ui.add(egui::TextEdit::singleline(&mut f.code).desired_width(70.0).font(egui::TextStyle::Monospace)); if resp.changed() { @@ -427,7 +427,7 @@ 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| { - egui::Grid::new("special_grid").num_columns(3).striped(true).spacing(vec2(14.0, 4.0)).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))); diff --git a/crates/ui-egui/src/dialogs/dialogs_keyboard.rs b/crates/ui-egui/src/dialogs/dialogs_keyboard.rs index f2578fae54..dd71f43f2e 100644 --- a/crates/ui-egui/src/dialogs/dialogs_keyboard.rs +++ b/crates/ui-egui/src/dialogs/dialogs_keyboard.rs @@ -200,8 +200,8 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) if cmds.is_empty() { ui.label(egui::RichText::new(tl!("No matching commands")).weak()); } - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Categories:")); if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) { f.category = c; @@ -210,7 +210,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) f.current.clear(); } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Commands:")); if let Some(c) = list(ui, "kb_commands", vec2(right, 170.0), &cmds, &f.command) { f.command = c; @@ -225,15 +225,15 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) f.current = keys.first().cloned().unwrap_or_default(); } let mut close = false; - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Keys for this command:")); let rows: Vec<(String, String)> = keys.iter().map(|k| (k.clone(), key_text(k))).collect(); if let Some(k) = list(ui, "kb_current", vec2(left, 70.0), &rows, &f.current) { f.current = k; } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("New shortcut:")); close = capture(ui, f); ui.add_space(4.0); @@ -280,7 +280,7 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm) if ui.add_enabled(!app.session.keymap.is_empty(), egui::Button::new(tl!("Reset All…"))).clicked() { f.confirm_reset = true; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { if ui.button(tl!("Close")).clicked() { close = true; } diff --git a/crates/ui-egui/src/dialogs/dialogs_layout.rs b/crates/ui-egui/src/dialogs/dialogs_layout.rs index 49b4d91bd2..a665745b57 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout.rs @@ -143,7 +143,7 @@ fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) { let mut r = (false, false); ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let (okb, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!(ok))), tl!("Cancel")); if cancelb.clicked() || crate::dialogs::cancel_key_pressed(ui) { diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/page.rs b/crates/ui-egui/src/dialogs/dialogs_layout/page.rs index 7bb52b88ba..f3ac2b712b 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/page.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/page.rs @@ -164,7 +164,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool (changed && v != was).then_some(v) }; heading(ui, "Size"); - egui::Grid::new("size_lengths").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); } @@ -175,7 +175,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool }); heading(ui, "Scale"); let (sw, sh) = f.scale(); - egui::Grid::new("size_scale").num_columns(4).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); } @@ -185,7 +185,7 @@ pub(super) fn size_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SizeForm) -> bool ui.end_row(); }); ui.checkbox(&mut f.lock, tl!("Lock aspect ratio")); - ui.add_enabled(f.original.is_some(), egui::Checkbox::new(&mut f.relative, tl!("Relative to original picture size"))); + ui.add_enabled_ui(f.original.is_some(), |ui| ui.checkbox(&mut f.relative, tl!("Relative to original picture size"))); if let Some((w, h)) = f.uncropped { heading(ui, "Original size"); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/print.rs b/crates/ui-egui/src/dialogs/dialogs_layout/print.rs index 2387da3be5..f7ba7cb063 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/print.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/print.rs @@ -49,7 +49,7 @@ pub(super) fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) ui.add_space(8.0); let (mut add, mut new, mut cancel) = (false, false, false); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked(); add = ui.add_enabled(ready, crate::widgets::primary_button(ui, tl!("Add to Document"))).clicked(); diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/table.rs b/crates/ui-egui/src/dialogs/dialogs_layout/table.rs index 634097470b..be1d04862b 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/table.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/table.rs @@ -126,7 +126,7 @@ pub(super) fn sort_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SortForm) -> bool ui.selectable_value(&mut f.column, i, label); } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.radio_value(&mut f.descending, false, tl!("Ascending")); ui.radio_value(&mut f.descending, true, tl!("Descending")); }); @@ -253,7 +253,7 @@ 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; - egui::Grid::new("split_cells").num_columns(2).show(ui, |ui| { + 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(); @@ -262,7 +262,7 @@ pub(super) fn split_cells_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SplitCellsF ui.end_row(); }); let mut merge = f.merge_first; - if ui.add_enabled(f.several, egui::Checkbox::new(&mut merge, tl!("Merge cells before split"))).changed() { + if ui.add_enabled_ui(f.several, |ui| ui.checkbox(&mut merge, tl!("Merge cells before split"))).inner.changed() { f.set_merge(merge); } alert(ui, &f.error); @@ -379,7 +379,7 @@ 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"); - egui::Grid::new("text_to_table").num_columns(2).show(ui, |ui| { + 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(); @@ -395,7 +395,7 @@ 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"); - egui::Grid::new("text_to_table_sep").num_columns(2).show(ui, |ui| { + 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(); diff --git a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs index 30a56318e4..0331e34355 100644 --- a/crates/ui-egui/src/dialogs/dialogs_layout/text.rs +++ b/crates/ui-egui/src/dialogs/dialogs_layout/text.rs @@ -50,7 +50,7 @@ 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| { - egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |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(); @@ -112,7 +112,7 @@ 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")); - egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -236,7 +236,7 @@ pub(super) fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> b ui.add_space(8.0); let mut close = false; ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } diff --git a/crates/ui-egui/src/dialogs/dialogs_lists.rs b/crates/ui-egui/src/dialogs/dialogs_lists.rs index b3acb0e755..44b2c1a5d2 100644 --- a/crates/ui-egui/src/dialogs/dialogs_lists.rs +++ b/crates/ui-egui/src/dialogs/dialogs_lists.rs @@ -171,8 +171,8 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool { ui.add_space(4.0); ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); }; - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Click level to modify:")); for i in 0..LEVELS { if ui.selectable_label(f.level == i, format!("{}", i + 1)).clicked() { @@ -180,7 +180,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool { } } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Preview")).small().weak()); preview(ui, f); }); @@ -189,7 +189,7 @@ 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"); - egui::Grid::new("mll_number").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -257,7 +257,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool { ui.end_row(); }); heading(ui, "Position"); - egui::Grid::new("mll_position").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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")] { @@ -283,7 +283,7 @@ pub fn define_list(app: &mut WordApp, ui: &mut Ui, f: &mut ListForm) -> bool { } }); ui.end_row(); - ui.add_enabled(l.follow == "tab", egui::Checkbox::new(&mut l.tab_on, tl!("Add tab stop at:"))); + 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(); }); @@ -455,11 +455,13 @@ fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_lab ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); let label = if c.is_some() { value.clone() } else { tl!(none_label).to_string() }; ui.menu_button(label, |ui| { - if ui.button(tl!(none_label)).clicked() { - value.clear(); - ui.close(); - } - crate::colors::field_menu(ui, theme, id, value); + crate::rtl::root(ui, |ui| { + if ui.button(tl!(none_label)).clicked() { + value.clear(); + ui.close(); + } + crate::colors::field_menu(ui, theme, id, value); + }) }); }); } @@ -510,7 +512,7 @@ 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"); - egui::Grid::new("tco_show").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| { + 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(); @@ -531,7 +533,7 @@ 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"); - egui::Grid::new("tco_advanced").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); @@ -551,7 +553,7 @@ pub fn track_options(app: &mut WordApp, ui: &mut Ui, f: &mut TrackForm) -> bool ui.end_row(); }); heading(ui, "Moves"); - egui::Grid::new("tco_moves").num_columns(3).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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)); diff --git a/crates/ui-egui/src/dialogs/dialogs_notes.rs b/crates/ui-egui/src/dialogs/dialogs_notes.rs index 627906f35b..9507a1155c 100644 --- a/crates/ui-egui/src/dialogs/dialogs_notes.rs +++ b/crates/ui-egui/src/dialogs/dialogs_notes.rs @@ -86,7 +86,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { }; heading(ui, "Location"); let mut convert = None; - egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { ui.radio_value(&mut f.endnote, false, tl!("Footnotes")); ui.add_enabled_ui(!f.endnote, |ui| { choice(ui, "notes_fpos", &mut f.pos[0], &[("pageBottom", "Bottom of Page"), ("beneathText", "Below text")], true); @@ -99,12 +99,15 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { ui.end_row(); ui.label(""); ui.menu_button(format!("{}…", tl!("Convert")), |ui| { - for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")] { - if ui.button(tl!(l)).clicked() { - convert = Some(to); - ui.close(); + crate::rtl::root(ui, |ui| { + for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")] + { + if ui.button(tl!(l)).clicked() { + convert = Some(to); + ui.close(); + } } - } + }) }); ui.end_row(); }); @@ -115,7 +118,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { } heading(ui, "Format"); let k = usize::from(f.endnote); - egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); @@ -147,7 +150,7 @@ pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { let (mut insert, mut apply, mut cancel) = (false, false, false); ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" }; let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); if cfg!(target_os = "macos") { diff --git a/crates/ui-egui/src/dialogs/dialogs_para.rs b/crates/ui-egui/src/dialogs/dialogs_para.rs index 8694bd2eb0..f92bd12646 100644 --- a/crates/ui-egui/src/dialogs/dialogs_para.rs +++ b/crates/ui-egui/src/dialogs/dialogs_para.rs @@ -196,7 +196,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { let u = unit(); let k = u.pt_per_unit(); ui.horizontal(|ui| { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.horizontal(|ui| { if ui.button(tl!("New")).clicked() { f.new_stop(); @@ -283,7 +283,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { f.set_leader(i, l); pick = Some(i); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { ui.add_space(10.0); // clear of the scrollbar if ui.add(egui::Button::new("×").small().frame(false)).on_hover_text(tl!("Clear")).clicked() { remove = Some(i); @@ -320,7 +320,7 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool { } }); ui.add_space(12.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_min_width(130.0); let default_label = ui.label(tl!("Default tab stops:")); ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2)) @@ -617,8 +617,8 @@ impl From for Target { /// Setting, style, colour and width on the left; the preview with its side toggles on the right. fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option, target: Target) { let between = target == Target::Paragraph; - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Setting:")).font(semibold(12.5))); for (s, label) in [(Setting::None, "None"), (Setting::Box, "Box"), (Setting::Shadow, "Shadow"), (Setting::ThreeD, "3-D"), (Setting::Custom, "Custom")] @@ -629,7 +629,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option }); ui.add_space(6.0); let mut then = None; - egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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| { @@ -150,7 +150,7 @@ 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 { - egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); @@ -158,7 +158,7 @@ pub(super) fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) }); ui.checkbox(&mut f.chapter, tl!("Add the chapter number")); ui.add_enabled_ui(f.chapter, |ui| { - egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |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(); 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 e0a28c8204..f028a84393 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/cross_ref.rs @@ -65,7 +65,7 @@ impl CrossRefForm { /// Insert. pub(super) fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next { let was = f.to.clone(); - egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/index.rs b/crates/ui-egui/src/dialogs/dialogs_refs/index.rs index 8b976f1ba9..85eed4dd6c 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/index.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/index.rs @@ -68,7 +68,7 @@ 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); - egui::Grid::new("idx").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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")); @@ -153,7 +153,7 @@ 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"); - egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -191,7 +191,7 @@ pub(super) fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next let mut close = false; ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { if ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } diff --git a/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs b/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs index 2762f7ea54..e03ac9354d 100644 --- a/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs +++ b/crates/ui-egui/src/dialogs/dialogs_refs/sources.rs @@ -170,7 +170,7 @@ 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 { - egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| { + 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| { @@ -286,7 +286,7 @@ pub(super) fn sources_ui(app: &mut WordApp, ui: &mut Ui, selected: &mut Option (bool, bool) { let mut r = (false, false); ui.add_space(8.0); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let (okb, cancelb) = crate::dialogs::ok_cancel_responses( ui, |ui| ui.add_enabled(ok_enabled, crate::widgets::primary_button(ui, tl!("OK"))), @@ -197,8 +197,8 @@ 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()); } - egui::Grid::new("compare_files").num_columns(2).spacing(vec2(16.0, 4.0)).show(ui, |ui| { - ui.vertical(|ui| { + 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| { @@ -209,7 +209,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool { } }); }); - ui.vertical(|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)); @@ -230,7 +230,7 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool { } if f.more { heading(ui, "Comparison settings"); - egui::Grid::new("compare_settings").num_columns(2).spacing(vec2(16.0, 2.0)).show(ui, |ui| { + 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)); @@ -245,13 +245,13 @@ fn compare_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CompareForm) -> bool { .small() .weak(), ); - egui::Grid::new("compare_show").num_columns(2).spacing(vec2(24.0, 2.0)).show(ui, |ui| { - ui.vertical(|ui| { + 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")); }); - ui.vertical(|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)); @@ -445,7 +445,7 @@ 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")); - egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| { + 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(); @@ -462,7 +462,7 @@ 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)); } - egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("ac_list_grid").num_columns(2).spacing(vec2(8.0, 1.0)).striped(true), |ui| { for (a, b, _) in list.iter().filter(|(a, _, _)| typed.is_empty() || a.to_lowercase().starts_with(&typed)).take(500) { let selected = f.from == *a; if ui.add_sized(vec2(110.0, 16.0), egui::Button::selectable(selected, a.as_str())).clicked() { @@ -543,7 +543,7 @@ 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| { - egui::Grid::new("ac_math_grid").num_columns(2).spacing(vec2(16.0, 1.0)).striped(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| { 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()); @@ -662,7 +662,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { let inside = app.session.doc.perm_ranges().iter().any(|r| r.everyone() && r.start <= a && b <= r.end && r.start.story == a.story); let mut everyone = inside; let can = inside || a != b; - if ui.add_enabled(can, egui::Checkbox::new(&mut everyone, tl!("Everyone"))).changed() { + if ui.add_enabled_ui(can, |ui| ui.checkbox(&mut everyone, tl!("Everyone"))).inner.changed() { let p = if everyone { json!({"exception": "add"}) } else { json!({"exception": "remove"}) }; if let Err(e) = app.run("review.restrict", p) { st.message = e; diff --git a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs index 094e1b2775..610f2a274d 100644 --- a/crates/ui-egui/src/dialogs/dialogs_ribbon.rs +++ b/crates/ui-egui/src/dialogs/dialogs_ribbon.rs @@ -108,10 +108,10 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b if cmds.is_empty() { ui.label(egui::RichText::new(tl!("No matching commands")).weak()); } - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5))); - ui.horizontal_top(|ui| { + crate::rtl::horizontal_top(ui, |ui| { if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) { f.category = c; f.filter.clear(); @@ -163,7 +163,7 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b if ui.add_enabled(custom, egui::Button::new(tl!("Reset…"))).clicked() { f.confirm_reset = true; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { if ui.button(tl!("Close")).clicked() { close = true; } @@ -219,7 +219,7 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { let sel = f.selected.clone(); let tab_custom = app.session.ribbon.tabs().iter().any(|t| t.name == sel.tab && t.custom); // Add >> / << Remove. - ui.vertical(|ui| { + 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))); @@ -245,11 +245,11 @@ fn ribbon_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { } }); ui.add_space(4.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Ribbon tabs")).font(semibold(12.5))); - ui.horizontal_top(|ui| { + crate::rtl::horizontal_top(ui, |ui| { tree(app, ui, f, &rows, h); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(h / 2.0 - 10.0); for (text, by) in [("Up", -1), ("Down", 1)] { if ui.add(egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() { @@ -354,7 +354,7 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { if !ids.contains(&f.qat_selected) { f.qat_selected = ids.first().cloned().unwrap_or_default(); } - ui.vertical(|ui| { + 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() @@ -367,14 +367,14 @@ fn qat_page(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm, h: f32) { } }); ui.add_space(4.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Quick Access Toolbar")).font(semibold(12.5))); - ui.horizontal_top(|ui| { + crate::rtl::horizontal_top(ui, |ui| { let rows: Vec<(String, String)> = ids.iter().map(|id| (id.clone(), label_of(app, id))).collect(); if let Some(id) = list(ui, "rb_qat", vec2(250.0, h), &rows, &f.qat_selected) { f.qat_selected = id; } - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(h / 2.0 - 10.0); for (text, by) in [("Up", -1), ("Down", 1)] { if ui.add_enabled(!f.qat_selected.is_empty(), egui::Button::new(tl!(text)).min_size(vec2(56.0, 0.0))).clicked() { diff --git a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs index 6032d26328..cc60434553 100644 --- a/crates/ui-egui/src/dialogs/dialogs_smart_art.rs +++ b/crates/ui-egui/src/dialogs/dialogs_smart_art.rs @@ -159,7 +159,7 @@ const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0; fn picker_row(ui: &mut Ui, columns: impl FnOnce(&mut Ui)) { ui.scope(|ui| { ui.set_max_height(PICKER_HEIGHT); - ui.horizontal_top(columns); + crate::rtl::horizontal_top(ui, columns); }); } @@ -169,7 +169,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool { let theme = app.session.doc.settings.theme_colors.clone(); let mut chosen = None; picker_row(ui, |ui| { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(120.0); for cat in CATEGORIES { if ui.selectable_label(f.category == cat, tl!(cat)).clicked() { @@ -183,10 +183,10 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool { } }); ui.separator(); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(3.0 * 96.0 + 16.0); ui.set_min_height(2.0 * 96.0 + 8.0); - egui::Grid::new("smart_art_layouts").spacing(vec2(8.0, 8.0)).show(ui, |ui| { + 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; @@ -241,7 +241,7 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool { }); }); ui.separator(); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(200.0); let (rect, _) = ui.allocate_exact_size(vec2(200.0, 150.0), Sense::hover()); ui.painter().rect(rect, 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/dialogs/dialogs_table_style.rs b/crates/ui-egui/src/dialogs/dialogs_table_style.rs index 1cc1afd56b..db3987a51b 100644 --- a/crates/ui-egui/src/dialogs/dialogs_table_style.rs +++ b/crates/ui-egui/src/dialogs/dialogs_table_style.rs @@ -469,7 +469,7 @@ 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; - egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -567,7 +567,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool { }); ui.add_space(12.0); ui.label(tl!("Alignment:")); - egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("tstyle_align").spacing(vec2(1.0, 1.0)), |ui| { for (i, ((n, v, h), tip)) in CELL_ALIGNS.iter().zip(ALIGN_TIPS).enumerate() { let on = r.align == *n; if align_button(ui, (*v, *h), on, tl!(tip)).clicked() { diff --git a/crates/ui-egui/src/dialogs/dialogs_templates.rs b/crates/ui-egui/src/dialogs/dialogs_templates.rs index 1d94ba3db8..e1d5c1b207 100644 --- a/crates/ui-egui/src/dialogs/dialogs_templates.rs +++ b/crates/ui-egui/src/dialogs/dialogs_templates.rs @@ -181,8 +181,8 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool let mut rename = false; let mut browse = false; let left_builtin = mine.iter().find(|x| s(x, "id") == f.left).is_none_or(|x| x.get("builtIn").and_then(Value::as_bool).unwrap_or(true)); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(egui::RichText::new(tl!("Document")).font(semibold(12.5))); list_frame(ui, "organizer_mine", &mut |ui| { for st in &mine { @@ -194,14 +194,16 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool } }); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.add_space(60.0); let w = vec2(96.0, 0.0); - copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("◀ {}", tl!("Copy"))).min_size(w)).clicked(); + // Point at the Document column (destination of copyFrom): left in LTR, right in RTL. + let copy_arrow = if crate::rtl::flows(ui) { "▶" } else { "◀" }; + copy = ui.add_enabled(!f.right.is_empty(), egui::Button::new(format!("{copy_arrow} {}", tl!("Copy"))).min_size(w)).clicked(); delete = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Delete")).min_size(w)).clicked(); rename = ui.add_enabled(!left_builtin, egui::Button::new(tl!("Rename…")).min_size(w)).clicked(); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { let name = std::path::Path::new(&f.file).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "—".into()); ui.label(egui::RichText::new(name).font(semibold(12.5))).on_hover_text(f.file.as_str()); list_frame(ui, "organizer_other", &mut |ui| { @@ -254,7 +256,7 @@ pub fn organizer(app: &mut WordApp, ui: &mut Ui, f: &mut OrganizerForm) -> bool ui.add_space(8.0); let mut close = ui.input(|i| i.key_pressed(egui::Key::Escape)); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { close |= ui.add(crate::widgets::primary_button(ui, tl!("Close"))).clicked(); }); }); diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index ad87c8e2fc..921f8d2699 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -137,81 +137,109 @@ fn font_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { if *strike { fmt.strikethrough = egui::Stroke::new(1.0, preview_color); } - egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + crate::rtl::Table::new("fontdlg").columns(2).spacing(vec2(10.0, 6.0)).show(ui, |t| { // Word's two font boxes when East Asian text is in use: the Latin text font and // the Asian text font, each with its own list (type to find a font). let prev = app.previews.font_preview_fn(); if *asian { - ui.label(tl!("Asian text font:")); - let fams = app.previews.east_asian_families(); - if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) { - if f == crate::widgets::ADD_FONT { - let _ = app.run("font.add", json!({})); - } else if f == crate::widgets::LOAD_SYSTEM_FONTS { - let _ = app.run("font.loadSystem", json!({})); - } else { - *font_ea = f; + t.row(|r| { + r.cell(|ui| ui.label(tl!("Asian text font:"))); + r.cell(|ui| { + let fams = app.previews.east_asian_families(); + if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) { + if f == crate::widgets::ADD_FONT { + let _ = app.run("font.add", json!({})); + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + let _ = app.run("font.loadSystem", json!({})); + } else { + *font_ea = f; + } + } + }); + }); + } + t.row(|r| { + r.cell(|ui| ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") })); + r.cell(|ui| { + let fams = app.previews.families(); + if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) { + if f == crate::widgets::ADD_FONT { + let _ = app.run("font.add", json!({})); + } else if f == crate::widgets::LOAD_SYSTEM_FONTS { + let _ = app.run("font.loadSystem", json!({})); + } else { + *font = f; + } } - } - ui.end_row(); - } - ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") }); - let fams = app.previews.families(); - if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) { - if f == crate::widgets::ADD_FONT { - let _ = app.run("font.add", json!({})); - } else if f == crate::widgets::LOAD_SYSTEM_FONTS { - let _ = app.run("font.loadSystem", json!({})); - } else { - *font = f; - } - } - ui.end_row(); - let size_label = ui.label(tl!("Size:")); - ui.text_edit_singleline(size).labelled_by(size_label.id); - ui.end_row(); - ui.label(tl!("Font color:")); - let theme = app.session.doc.settings.theme_colors.clone(); - color_field(ui, &theme, color, "Automatic", "auto"); - ui.end_row(); - let spacing_label = ui.label(tl!("Spacing (pt):")); - ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id); - ui.end_row(); - let label = tl!("Kerning for fonts:"); - match kern { - Some(on) => { - ui.checkbox(on, label); - } - None => { - if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() { - *kern = Some(true); + }); + }); + t.row(|r| { + let size_label = r.cell(|ui| ui.label(tl!("Size:"))); + r.cell(|ui| ui.text_edit_singleline(size).labelled_by(size_label.id)); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Font color:"))); + r.cell(|ui| { + let theme = app.session.doc.settings.theme_colors.clone(); + color_field(ui, &theme, color, "Automatic", "auto"); + }); + }); + t.row(|r| { + let spacing_label = r.cell(|ui| ui.label(tl!("Spacing (pt):"))); + r.cell(|ui| ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0)).labelled_by(spacing_label.id)); + }); + t.row(|r| { + let label = tl!("Kerning for fonts:"); + r.cell(|ui| match kern { + Some(on) => { + ui.checkbox(on, label); } - } - } - ui.horizontal(|ui| { - let points = ui.add_enabled(*kern == Some(true), egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1)); - let points_label = ui.label(tl!("Points and above")); - points.labelled_by(points_label.id); + None => { + if ui.add(egui::Checkbox::new(&mut false, label).indeterminate(true)).clicked() { + *kern = Some(true); + } + } + }); + r.cell(|ui| { + ui.horizontal(|ui| { + let enabled = *kern == Some(true); + let field = egui::DragValue::new(kern_at).speed(0.5).range(1.0..=1638.0).max_decimals(1); + // The number comes first in the reading order, then "points and above"; a left-to-right + // row in a right-to-left interface runs the other way, so there the label is first. + if crate::rtl::on() && !crate::rtl::flows(ui) { + let points_label = ui.label(tl!("Points and above")); + ui.add_enabled(enabled, field).labelled_by(points_label.id); + } else { + let points = ui.add_enabled(enabled, field); + let points_label = ui.label(tl!("Points and above")); + points.labelled_by(points_label.id); + } + }); + }); }); - ui.end_row(); }); ui.separator(); ui.label(egui::RichText::new(tl!("Effects")).font(semibold(12.5))); - egui::Grid::new("fx").num_columns(2).show(ui, |ui| { - ui.checkbox(bold, tl!("Bold")); - ui.checkbox(italic, tl!("Italic")); - ui.end_row(); - ui.checkbox(underline, tl!("Underline")); - ui.checkbox(strike, tl!("Strikethrough")); - ui.end_row(); - ui.checkbox(sup, tl!("Superscript")); - ui.checkbox(sub, tl!("Subscript")); - ui.end_row(); - ui.checkbox(small_caps, tl!("Small caps")); - ui.checkbox(caps, tl!("All caps")); - ui.end_row(); - ui.checkbox(hidden, tl!("Hidden")); - ui.end_row(); + crate::rtl::Table::new("fx").columns(2).show(ui, |t| { + t.row(|r| { + r.cell(|ui| ui.checkbox(bold, tl!("Bold"))); + r.cell(|ui| ui.checkbox(italic, tl!("Italic"))); + }); + t.row(|r| { + r.cell(|ui| ui.checkbox(underline, tl!("Underline"))); + r.cell(|ui| ui.checkbox(strike, tl!("Strikethrough"))); + }); + t.row(|r| { + r.cell(|ui| ui.checkbox(sup, tl!("Superscript"))); + r.cell(|ui| ui.checkbox(sub, tl!("Subscript"))); + }); + t.row(|r| { + r.cell(|ui| ui.checkbox(small_caps, tl!("Small caps"))); + r.cell(|ui| ui.checkbox(caps, tl!("All caps"))); + }); + t.row(|r| { + r.cell(|ui| ui.checkbox(hidden, tl!("Hidden"))); + }); }); // Live preview of the chosen font/size/colour/effects. ui.separator(); @@ -286,27 +314,31 @@ fn paragraph_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.label(egui::RichText::new(tl!("Indentation")).font(semibold(12.5))); // Mirrored indents are the inside and outside ones (they swap on even pages). let (l_label, r_label) = if opts[0] { ("Inside:", "Outside:") } else { ("Left:", "Right:") }; - egui::Grid::new("ind").num_columns(4).show(ui, |ui| { - let left_label = ui.label(tl!(l_label)); - para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label); - let right_label = ui.label(tl!(r_label)); - para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label); - ui.end_row(); - let first_label = ui.label(tl!("First line:")); - para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label); - ui.end_row(); + crate::rtl::Table::new("ind").columns(4).show(ui, |t| { + t.row(|r| { + let left_label = r.cell(|ui| ui.label(tl!(l_label))); + r.cell(|ui| para_number(ui, left, ParaNumber::Length, "left", mixed, &left_label)); + let right_label = r.cell(|ui| ui.label(tl!(r_label))); + r.cell(|ui| para_number(ui, right, ParaNumber::Length, "right", mixed, &right_label)); + }); + t.row(|r| { + let first_label = r.cell(|ui| ui.label(tl!("First line:"))); + r.cell(|ui| para_number(ui, first, ParaNumber::Length, "indentFirst", mixed, &first_label)); + }); }); para_check(ui, &mut opts[0], tl!("Mirror indents"), "mirrorIndents", mixed); ui.label(egui::RichText::new(tl!("Spacing")).font(semibold(12.5))); - egui::Grid::new("sp").num_columns(4).show(ui, |ui| { - let before_label = ui.label(tl!("Before:")); - para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label); - let line_label = ui.label(tl!("Line spacing:")); - para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label); - ui.end_row(); - let after_label = ui.label(tl!("After:")); - para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label); - ui.end_row(); + crate::rtl::Table::new("sp").columns(4).show(ui, |t| { + t.row(|r| { + let before_label = r.cell(|ui| ui.label(tl!("Before:"))); + r.cell(|ui| para_number(ui, before, ParaNumber::Points, "spaceBefore", mixed, &before_label)); + let line_label = r.cell(|ui| ui.label(tl!("Line spacing:"))); + r.cell(|ui| para_number(ui, line, ParaNumber::Lines, "lineSpacing", mixed, &line_label)); + }); + t.row(|r| { + let after_label = r.cell(|ui| ui.label(tl!("After:"))); + r.cell(|ui| para_number(ui, after, ParaNumber::Points, "spaceAfter", mixed, &after_label)); + }); }); para_check(ui, &mut opts[1], tl!("Don't add space between paragraphs of the same style"), "contextualSpacing", mixed); } @@ -387,8 +419,8 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { // Word greys out Match case and the word options with wildcards (they always match case). let plain = !extra.wildcards; ui.horizontal(|ui| { - ui.add_enabled(plain, egui::Checkbox::new(match_case, tl!("Match case"))); - ui.add_enabled(plain, egui::Checkbox::new(whole_word, tl!("Whole words"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(match_case, tl!("Match case"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(whole_word, tl!("Whole words"))); ui.checkbox(&mut extra.wildcards, tl!("Use wildcards")); }); let arrow = if extra.more { "▾" } else { "▸" }; @@ -397,12 +429,12 @@ fn find_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } if extra.more { ui.horizontal(|ui| { - ui.add_enabled(plain, egui::Checkbox::new(&mut extra.prefix, tl!("Match prefix"))); - ui.add_enabled(plain, egui::Checkbox::new(&mut extra.suffix, tl!("Match suffix"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.prefix, tl!("Match prefix"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.suffix, tl!("Match suffix"))); }); ui.horizontal(|ui| { - ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_punct, tl!("Ignore punctuation characters"))); - ui.add_enabled(plain, egui::Checkbox::new(&mut extra.ignore_space, tl!("Ignore white-space characters"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_punct, tl!("Ignore punctuation characters"))); + ui.add_enabled_ui(plain, |ui| ui.checkbox(&mut extra.ignore_space, tl!("Ignore white-space characters"))); }); ui.horizontal(|ui| { ui.checkbox(regex, tl!("Regular expressions")); @@ -478,15 +510,15 @@ fn enclose_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); ui.add_space(6.0); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Text:")); ui.add(egui::TextEdit::singleline(text).desired_width(110.0).char_limit(2)); ui.add_space(6.0); enclose_preview(ui, style, text, shape); }); ui.add_space(12.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Enclosure")); for (v, l) in [("circle", "Circle"), ("square", "Square"), ("triangle", "Triangle"), ("diamond", "Diamond")] { ui.add_enabled_ui(style != "none", |ui| ui.selectable_value(shape, v.to_string(), tl!(l))); @@ -535,13 +567,15 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::InsertTable { rows, cols } = d else { return false; }; - egui::Grid::new("it").num_columns(2).show(ui, |ui| { - ui.label(tl!("Number of columns:")); - ui.add(egui::DragValue::new(cols).range(1..=63)); - ui.end_row(); - ui.label(tl!("Number of rows:")); - ui.add(egui::DragValue::new(rows).range(1..=1000)); - ui.end_row(); + crate::rtl::Table::new("it").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(cols).range(1..=63))); + }); + t.row(|r| { + r.cell(|ui| ui.label(tl!("Number of rows:"))); + r.cell(|ui| ui.add(egui::DragValue::new(rows).range(1..=1000))); + }); }); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { @@ -550,6 +584,11 @@ fn insert_table_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } +/// A margin's number box, in the unit the interface measures in. +fn margin(value: &mut f32) -> egui::DragValue<'_> { + egui::DragValue::new(value).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2) +} + fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::PageSetup { top, @@ -573,21 +612,19 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { return false; }; ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5))); - egui::Grid::new("ps").num_columns(4).show(ui, |ui| { - let top_label = ui.label(tl!("Top:")); - ui.add(egui::DragValue::new(top).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) - .labelled_by(top_label.id); - let bottom_label = ui.label(tl!("Bottom:")); - ui.add(egui::DragValue::new(bottom).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) - .labelled_by(bottom_label.id); - ui.end_row(); - let left_label = ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") }); - ui.add(egui::DragValue::new(left).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) - .labelled_by(left_label.id); - let right_label = ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") }); - ui.add(egui::DragValue::new(right).speed(3.6 / crate::options::unit().pt_per_unit()).measure(crate::options::unit()).max_decimals(2)) - .labelled_by(right_label.id); - ui.end_row(); + crate::rtl::Table::new("ps").columns(4).show(ui, |t| { + t.row(|r| { + let top_label = r.cell(|ui| ui.label(tl!("Top:"))); + r.cell(|ui| ui.add(margin(top)).labelled_by(top_label.id)); + let bottom_label = r.cell(|ui| ui.label(tl!("Bottom:"))); + r.cell(|ui| ui.add(margin(bottom)).labelled_by(bottom_label.id)); + }); + t.row(|r| { + let left_label = r.cell(|ui| ui.label(if *mirror { tl!("Inside:") } else { tl!("Left:") })); + r.cell(|ui| ui.add(margin(left)).labelled_by(left_label.id)); + let right_label = r.cell(|ui| ui.label(if *mirror { tl!("Outside:") } else { tl!("Right:") })); + r.cell(|ui| ui.add(margin(right)).labelled_by(right_label.id)); + }); }); ui.label(egui::RichText::new(tl!("Orientation")).font(semibold(12.5))); ui.horizontal(|ui| { @@ -604,12 +641,13 @@ fn page_setup_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.radio_value(&mut grid.0, 2, tl!("Lines and characters")); ui.radio_value(&mut grid.0, 3, tl!("Text snaps to characters")); }); - egui::Grid::new("ps_grid").num_columns(4).show(ui, |ui| { - let chars_label = ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:"))); - ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id); - let lines_label = ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:"))); - ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id); - ui.end_row(); + crate::rtl::Table::new("ps_grid").columns(4).show(ui, |t| { + t.row(|r| { + let chars_label = r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::Label::new(tl!("Characters per line:")))); + r.cell(|ui| ui.add_enabled(grid.0 >= 2, egui::DragValue::new(&mut grid.2).range(1..=1000)).labelled_by(chars_label.id)); + let lines_label = r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::Label::new(tl!("Lines per page:")))); + r.cell(|ui| ui.add_enabled(grid.0 >= 1, egui::DragValue::new(&mut grid.1).range(1..=1000)).labelled_by(lines_label.id)); + }); }); ui.horizontal(|ui| { ui.label(tl!("Section direction:")); @@ -680,7 +718,7 @@ fn link_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); - egui::Grid::new("lnk").num_columns(2).show(ui, |ui| { + 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(); @@ -762,7 +800,7 @@ fn word_count_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::WordCount { stats } = d else { return false; }; - egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)), |ui| { for (l, k) in [ ("Pages", "pages"), ("Words", "words"), @@ -855,7 +893,7 @@ fn phonetic_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("right", "Right"), ("rightVertical", "Right (vertical)"), ]; - egui::Grid::new("phonetic").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -900,7 +938,7 @@ 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; }; - egui::Grid::new("ns").num_columns(2).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("ns").num_columns(2), |ui| { ui.label(tl!("Name:")); ui.text_edit_singleline(name); ui.end_row(); @@ -924,7 +962,7 @@ 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; }; - egui::Grid::new("ms").num_columns(2).show(ui, |ui| { + 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(); @@ -1144,7 +1182,7 @@ fn save_changes_dialog(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ui.add_space(8.0); let buttons = ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let cancel = ui.button(tl!("Cancel")); let dont_save = ui.button(tl!("Don't Save")); let save = ui.add(crate::widgets::primary_button(ui, tl!("Save"))); @@ -1254,7 +1292,7 @@ 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); - egui::Grid::new("encrypt_password").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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() { @@ -1437,7 +1475,7 @@ 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); - egui::Grid::new("merge_rule").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); @@ -1479,7 +1517,7 @@ 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); - egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("match_fields").num_columns(2).spacing(vec2(24.0, 6.0)).striped(true), |ui| { for (part, key) in ADDRESS_PARTS { ui.label(tl!(part)); match address.get(key).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index db0fa60776..16185c2172 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -177,8 +177,8 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar } else { Vec::new() }; - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| { ui.label(tl!("Go to what:")); for (w, label) in [(GoToWhat::Page, "Page"), (GoToWhat::Bookmark, "Bookmark")] { if ui.selectable_label(*what == w, tl!(label)).clicked() && *what != w { @@ -189,7 +189,7 @@ pub(super) fn goto_body(app: &mut WordApp, ui: &mut Ui, what: &mut GoToWhat, tar } }); ui.add_space(16.0); - ui.vertical(|ui| match what { + crate::rtl::vertical(ui, |ui| match what { GoToWhat::Page => { let label = ui.label(tl!("Enter page number:")); let r = ui.add(egui::TextEdit::singleline(target).desired_width(220.0)).labelled_by(label.id); diff --git a/crates/ui-egui/src/dialogs/forms.rs b/crates/ui-egui/src/dialogs/forms.rs index f622072ba9..000b385e0a 100644 --- a/crates/ui-egui/src/dialogs/forms.rs +++ b/crates/ui-egui/src/dialogs/forms.rs @@ -343,7 +343,7 @@ pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) { }); let mut new_hover = (0, 0); let mut clicked = None; - egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)), |ui| { for r in 1..=8 { for c in 1..=10 { let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); @@ -390,7 +390,7 @@ 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"); - egui::Grid::new("tp_table").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -412,7 +412,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) { ui.end_row(); }); heading(ui, "Row"); - egui::Grid::new("tp_row").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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)); @@ -433,13 +433,13 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) { 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"); - egui::Grid::new("tp_col").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); }); heading(ui, "Cell"); - egui::Grid::new("tp_cell").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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(); @@ -456,7 +456,7 @@ pub(super) fn table_properties(ui: &mut Ui, f: &mut TableForm) { 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(); - egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("cell_margins").num_columns(2).spacing(vec2(10.0, 6.0)), |ui| { 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)); @@ -512,7 +512,7 @@ pub(super) fn custom_properties(app: &mut WordApp, ui: &mut Ui, f: &mut CustomPr } }); ui.separator(); - egui::Grid::new("custom_props_form").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + 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); diff --git a/crates/ui-egui/src/dialogs/mailings.rs b/crates/ui-egui/src/dialogs/mailings.rs index bc444e6864..99541e25cf 100644 --- a/crates/ui-egui/src/dialogs/mailings.rs +++ b/crates/ui-egui/src/dialogs/mailings.rs @@ -261,8 +261,7 @@ pub(crate) fn recipient_list( if !editing { filter.editing = None; } - let merged_only = egui::Checkbox::new(&mut filter.only_merged, tl!("Show only records that will be merged")); - ui.add_enabled(order.is_some(), merged_only); + ui.add_enabled_ui(order.is_some(), |ui| ui.checkbox(&mut filter.only_merged, tl!("Show only records that will be merged"))); let visible = ui .horizontal(|ui| { ui.label(tl!("Find in list:")); @@ -284,7 +283,7 @@ pub(crate) fn recipient_list( let mut x_w = X_W; let mut focused = None; egui::ScrollArea::horizontal().id_salt("recipients_h").max_width(720.0).show(ui, |ui| { - ui.vertical(|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() { @@ -424,7 +423,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter }); }; let mut remove = None; - egui::Grid::new("recipient_filter").num_columns(5).spacing(vec2(6.0, 4.0)).show(ui, |ui| { + 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()); @@ -466,7 +465,7 @@ fn recipient_filter(ui: &mut Ui, fields: &[String], filter: &mut RecipientFilter } }); ui.add_space(4.0); - egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("recipient_sort").num_columns(3).spacing(vec2(6.0, 4.0)), |ui| { 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); diff --git a/crates/ui-egui/src/dialogs/page_setup.rs b/crates/ui-egui/src/dialogs/page_setup.rs index 966bc38191..8d18598420 100644 --- a/crates/ui-egui/src/dialogs/page_setup.rs +++ b/crates/ui-egui/src/dialogs/page_setup.rs @@ -106,19 +106,23 @@ pub(super) fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: & }); combo.response.labelled_by(label.id); }); - egui::Grid::new("ps_paper").num_columns(4).show(ui, |ui| { + crate::rtl::Table::new("ps_paper").columns(4).show(ui, |t| { use wordcraft_engine::cmd::page::{MAX_PAGE_SIDE, MIN_PAGE_SIDE}; let range = MIN_PAGE_SIDE / ppu..=MAX_PAGE_SIDE / ppu; - let width_label = ui.label(tl!(l.width)); - let w = ui - .add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)) - .labelled_by(width_label.id); - let height_label = ui.label(tl!(l.height)); - let h = ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id); - if (w.changed() || h.changed()) && paper.0 != paper.1 { - *landscape = paper.0 > paper.1; - } - ui.end_row(); + t.row(|r| { + let width_label = r.cell(|ui| ui.label(tl!(l.width))); + let w = r.cell(|ui| { + ui.add(egui::DragValue::new(&mut paper.0).speed(3.6 / ppu).range(range.clone()).measure(unit).max_decimals(2)) + .labelled_by(width_label.id) + }); + let height_label = r.cell(|ui| ui.label(tl!(l.height))); + let h = r.cell(|ui| { + ui.add(egui::DragValue::new(&mut paper.1).speed(3.6 / ppu).range(range).measure(unit).max_decimals(2)).labelled_by(height_label.id) + }); + if (w.changed() || h.changed()) && paper.0 != paper.1 { + *landscape = paper.0 > paper.1; + } + }); }); } diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs index 4dfede6ccf..5579fb0275 100644 --- a/crates/ui-egui/src/dialogs/paragraph.rs +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -61,6 +61,9 @@ pub(super) enum ParaNumber { Lines, } +/// The right-to-left mark. +const RLM: char = '\u{200F}'; + /// A Paragraph dialog number box for the field `key`. While the selected paragraphs differ in /// it, it shows nothing, not even the unit, until a value is typed or dragged in. pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: &str, mixed: &mut BTreeSet, label: &egui::Response) { @@ -76,6 +79,9 @@ pub(super) fn para_number(ui: &mut Ui, value: &mut f32, kind: ParaNumber, key: & ParaNumber::Length if blank => field.measure_blank(unit), ParaNumber::Length => field.measure(unit), _ if blank => field.custom_formatter(|_, _| String::new()), + // Right to left, the unit's space goes after the word and a right-to-left mark closes it: a + // plain leading space between the digits and an Arabic unit is drawn with no width. + ParaNumber::Points if crate::rtl::on() => field.suffix(format!("{} {RLM}", tl!("pt"))), ParaNumber::Points => field.suffix(format!(" {}", tl!("pt"))), ParaNumber::Lines => field, }; diff --git a/crates/ui-egui/src/equation_tab.rs b/crates/ui-egui/src/equation_tab.rs index 5a44575bf6..1fe5fa35d3 100644 --- a/crates/ui-egui/src/equation_tab.rs +++ b/crates/ui-egui/src/equation_tab.rs @@ -124,7 +124,7 @@ fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, y += LABEL_PITCH; } let resp = resp.on_hover_text(label.replace('\n', " ")); - egui::Popup::menu(&resp).show(|ui| { + crate::rtl::menu(&resp).show(|ui| { ui.set_min_width(260.0); menu(ui, app); }); @@ -239,7 +239,7 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) { let _ = app.run("insert.equation", json!({})); app.canvas.want_focus = true; } - egui::Popup::menu(&rb).show(|ui| builtin_menu(ui, app)); + crate::rtl::menu(&rb).show(|ui| builtin_menu(ui, app)); } /// The Symbols group: a strip of the current set's symbols, and a menu with every set. @@ -253,7 +253,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) { let all: Vec = chars.chars().collect(); let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(all.len().saturating_sub(1)); let t = Tokens::get(ui.ctx()); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 1.0); for row in 0..rows { ui.horizontal(|ui| { @@ -272,7 +272,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) { }); } }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 1.0); let page = per_row * rows; if ui.add_enabled(start > 0, egui::Button::new("▲").min_size(vec2(16.0, 22.0))).clicked() { @@ -282,7 +282,7 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) { ui.data_mut(|d| d.insert_temp(start_id, start + per_row)); } let resp = ui.add(egui::Button::new("…").min_size(vec2(16.0, 22.0))).on_hover_text(tl!("All symbol sets")); - egui::Popup::menu(&resp).show(|ui| { + crate::rtl::menu(&resp).show(|ui| { ui.set_width(420.0); ui.horizontal_wrapped(|ui| { for (i, (name, _)) in SYMBOL_SETS.iter().enumerate() { @@ -341,7 +341,7 @@ fn symbol_tooltip(c: char, display: &str) -> String { pub fn show(app: &mut WordApp, ui: &mut Ui) { group(ui, "Tools", None, app, |ui, app| { math_menu_button(ui, app, EQUATION_PICTURE, "Equation", builtin_menu); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); let latex = app.session.math_latex; small( @@ -386,7 +386,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }, ); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); let normal = app.session.math_normal_text; small( @@ -407,7 +407,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } }); group(ui, "Equation Options", None, app, |ui, app| { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); let info = app.session.run("equation.get", &json!({})).ok(); let display = info.as_ref().and_then(|v| v.get("display")).and_then(|v| v.as_bool()).unwrap_or(false); @@ -437,7 +437,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::widgets::ButtonAction::new("equation.number", json!({}), numbered), ); }); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); crate::widgets::menu_button( ui, diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index e05f39baca..d7ac349dbc 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -316,7 +316,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { .anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0)); let shown = crate::window_close::window(ctx, window).show(ctx, |ui| { ui.set_width(620.0); - done = body(ui, &mut b, &t); + done = crate::rtl::root(ui, |ui| body(ui, &mut b, &t)); }); if shown.is_some_and(|s| crate::window_close::show(ctx, &s.response, &t)) { done = Some(None); @@ -385,9 +385,21 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> } else if resp.hovered() { ui.painter().rect_filled(r, 2.0, t.hover); } - let icon = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(18.0, 18.0)); + let rtl = crate::rtl::on(); + let icon = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 14.0), r.center().y), vec2(18.0, 18.0)); icons::paint(ui.painter(), icon, if e.dir { "folder" } else { "file" }, t.icon, t.accent); - ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &e.name, regular(13.0), t.text); + // A long name keeps its beginning and ends in an ellipsis inside the row, from the + // side reading starts from; the whole name is the row's tooltip then. + let mut job = egui::text::LayoutJob::simple_singleline(crate::i18n::isolate(&e.name), regular(13.0), t.text); + job.wrap.max_width = (r.width() - 36.0).max(0.0); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + let galley = ui.painter().layout_job(job); + let text_x = crate::rtl::lead_x(rtl, r, 30.0); + let at = pos2(if rtl { text_x - galley.size().x } else { text_x }, r.center().y - galley.size().y / 2.0); + let elided = galley.elided; + ui.painter().galley(at, galley, t.text); + let resp = if elided { resp.on_hover_text(&e.name) } else { resp }; if resp.double_clicked() { if e.dir { go = Some(e.path.clone()); @@ -459,7 +471,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option> let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape)); let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let (ok, cancelb) = crate::dialogs::ok_cancel_responses(ui, |ui| ui.add(crate::widgets::primary_button(ui, tl!(b.ok_label()))), tl!("Cancel")); cancel |= cancelb.clicked(); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index c18a68a4ae..ffcbafecff 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -301,7 +301,7 @@ impl WordApp { self.run("file.saveAs", json!({"path": path})) }; if r.is_ok() { - self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)])); + self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))])); } r } @@ -311,7 +311,7 @@ impl WordApp { params["path"] = json!(path); let r = self.run("file.printPdf", params); if r.is_ok() { - self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &path)])); + self.status(crate::i18n::fmt(tl!("Exported {path}"), &[("path", &crate::i18n::isolate_path(&path))])); } r } diff --git a/crates/ui-egui/src/format_shape.rs b/crates/ui-egui/src/format_shape.rs index b71045eb54..f9f9e53968 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -216,7 +216,7 @@ fn kind_of(t: usize, focus: [f32; 4], angle: f32, scaled: bool) -> GradientKind /// add and remove, and the selected stop's colour, position, transparency and rotate-with-shape /// controls. fn gradient(app: &mut WordApp, ui: &mut Ui, theme: &[Rgb], g: &Gradient) { - egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("format_shape.gradient").num_columns(2).spacing(vec2(8.0, 6.0)), |ui| { ui.label(tl!("Preset gradients")); ui.menu_button("▾", |ui| { if let Some(f) = presets_gallery(ui, theme) { @@ -394,7 +394,7 @@ fn stops_bar(app: &mut WordApp, ui: &mut Ui, g: &Gradient, sel: usize) { fn presets_gallery(ui: &mut Ui, theme: &[Rgb]) -> Option { let presets = shape_fill::presets(theme); let mut out = None; - egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("format_shape.presets").spacing(vec2(4.0, 4.0)), |ui| { for (i, f) in presets.iter().enumerate() { let style = shape_fill::PRESET_STYLES.get(i / 6).copied().unwrap_or(""); let name = format!("{}, {} {}", tl!(style), tl!("Accent"), i % 6 + 1); @@ -418,7 +418,7 @@ fn directions(ui: &mut Ui, g: &Gradient, t: usize) -> Option { PATH_DIRECTIONS.iter().map(|&f| (kind_of(t, f, 90.0, true), shape_fill::path_direction_name(f))).collect() }; let mut out = None; - egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("format_shape.directions").spacing(vec2(4.0, 4.0)), |ui| { for (i, (k, name)) in kinds.into_iter().enumerate() { let f = with(g, |g| set_kind(g, k)); if swatch(ui, &f, &name) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 63a1c52d34..fff9145d0d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -70,7 +70,7 @@ pub struct LangInfo { /// The languages WordCraft offers, when their catalogs are embedded. English first: it is the /// fallback and the source language. -pub static LANGUAGES: [LangInfo; 27] = [ +pub static LANGUAGES: [LangInfo; 28] = [ LangInfo { code: "en", prefer_hans: false, editing_lang: None }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching). LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None }, @@ -124,10 +124,18 @@ pub static LANGUAGES: [LangInfo; 27] = [ LangInfo { code: "da", prefer_hans: false, editing_lang: None }, // Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here. LangInfo { code: "ro", prefer_hans: false, editing_lang: None }, + // Arabic (Modern Standard); `ar` and every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA` …) resolve + // here. Right to left: see [`RTL_LANGUAGES`] and [`Lang::rtl`]. + LangInfo { code: "ar", prefer_hans: false, editing_lang: None }, // Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here. LangInfo { code: "sl", prefer_hans: false, editing_lang: None }, ]; +/// Interface languages written right to left. Their catalogs are written in typing order and +/// egui's text layout orders each line itself, so the lookups return them as they are; the window +/// is mirrored ([`crate::rtl`]). +const RTL_LANGUAGES: [&str; 1] = ["ar"]; + /// A language the UI can be shown in: English, or an offered language whose catalogs are embedded. #[derive(Clone, Copy)] pub struct Lang { @@ -171,6 +179,12 @@ impl Lang { self.i18n().name(self.lang) } + /// Whether the interface is written right to left. Its translations come back from the lookups + /// as written, in typing order (see [`RTL_LANGUAGES`]). + pub fn rtl(self) -> bool { + RTL_LANGUAGES.contains(&self.code()) + } + pub fn prefers_hans(self) -> bool { self.info().is_some_and(|l| l.prefer_hans) } @@ -400,6 +414,46 @@ pub fn tr(lang: Lang, s: &str) -> &str { lang.i18n().t(lang.lang, s) } +/// Left-to-right mark: it keeps what it touches from joining a right-to-left run next to it. +const LRM: char = '\u{200E}'; + +/// `name` with a left-to-right mark before its extension (`ملف 2.docx`), which a right-to-left +/// line would otherwise draw as `ملف docx.2`. +fn mark_extension(name: &str) -> String { + match name.rfind('.') { + Some(dot) + if dot > 0 && name.get(dot + 1..).is_some_and(|e| !e.is_empty() && e.len() <= 8 && e.bytes().all(|b| b.is_ascii_alphanumeric())) => + { + format!("{}{LRM}{}", &name[..dot], &name[dot..]) + } + _ => name.to_string(), + } +} + +/// `name` (a file or folder name, a document's name) set apart from the right-to-left text around +/// it, so its own direction and its neutral marks (`(`, `.`, `-`) keep their places. Only in a +/// right-to-left interface; elsewhere the text as it is. Display only: never for text that is saved. +pub fn isolate(name: &str) -> String { + if current().rtl() { format!("\u{2068}{}\u{2069}", mark_extension(name)) } else { name.to_string() } +} + +/// [`isolate`] for a path or a web address, which always reads left to right: its folders keep +/// their order even where neighbouring ones are Arabic (a mark after every separator keeps two +/// right-to-left names from joining into one run that swaps them). +pub fn isolate_path(path: &str) -> String { + if !current().rtl() { + return path.to_string(); + } + let mut out = String::with_capacity(path.len() + 8); + for c in mark_extension(path).chars() { + out.push(c); + if matches!(c, '/' | '\\') { + out.push(LRM); + } + } + format!("\u{2066}{out}\u{2069}") +} + /// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in /// the current language, styles the author made exactly as named. Display only: the document, /// commands and saved files keep the English name. @@ -425,9 +479,11 @@ pub fn builtin_style_name(name: &str) -> String { name.to_string() } -/// A ribbon location (`Home › Font`) in the current language, segment by segment. +/// A ribbon location (`Home › Font`) in the current language, segment by segment. A +/// right-to-left language reads it from the right, so the mark points left. pub fn location(loc: &str) -> String { - loc.split(" › ").map(t).collect::>().join(" › ") + let segments: Vec<&str> = loc.split(" › ").map(t).collect(); + segments.join(if current().rtl() { " ‹ " } else { " › " }) } /// A label with an action after a fixed English prefix, e.g. `Undo Typing`: both parts translated diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index a6db6f20c9..e8f8b68239 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -597,3 +597,237 @@ fn disabled_reasons() -> std::collections::BTreeSet { } reasons } + +/// One frame at a given window width; returns the left edge of every text drawn. +fn frame_at(ctx: &egui::Context, app: &mut WordApp, width: f32) -> Vec<(String, f32)> { + fn collect(shape: &egui::Shape, out: &mut Vec<(String, f32)>) { + match shape { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos.x)), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)), + _ => {} + } + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0)); + let mut drawn = Vec::new(); + for _ in 0..4 { + TIME.set(TIME.get() + 0.1); + let input = egui::RawInput { screen_rect: Some(screen), time: Some(TIME.get()), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + drawn.clear(); + for s in &out.shapes { + collect(&s.shape, &mut drawn); + } + out.textures_delta.clear(); + } + drawn +} + +/// The Arabic interface is mirrored: reading starts at the right. And nothing in it can panic: +/// every tab (wide, and narrow enough to scroll), File page, dialog, pane, context menu and +/// ribbon control is laid out right to left. egui's `Grid` asserts against right-to-left parents, +/// so a grid that is not wrapped in `rtl::grid` fails here. +#[test] +fn the_arabic_interface_is_mirrored_and_never_panics() { + // Arabic comes from the craft-i18n catalogs: a build without them (English only) has nothing to mirror. + let Some(ar) = super::Lang::from_code("ar") else { + eprintln!("skipped: built without the craft-i18n Arabic catalog (set CRAFT_I18N_DIR to a checkout)"); + return; + }; + let start = |ctx: &egui::Context| { + let mut app = fresh_app(ctx); + app.ui.language = "ar".into(); + frame(ctx, &mut app); + app + }; + let ctx = egui::Context::default(); + let mut app = start(&ctx); + assert!(crate::rtl::on()); + let x_of = |drawn: &[(String, f32)], english: &str| { + let label = super::tr(ar, english); + drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap_or_else(|| panic!("{english} is not drawn")) + }; + // Tabs run from the right: File, Home, Insert … and the Share button is at the far left. + let drawn = frame_at(&ctx, &mut app, 1600.0); + let (file, home, insert, share) = (x_of(&drawn, "File"), x_of(&drawn, "Home"), x_of(&drawn, "Insert"), x_of(&drawn, "Share")); + assert!(file > home && home > insert && insert > 800.0, "tabs: {file} {home} {insert}"); + assert!(share < 200.0, "Share: {share}"); + // Ribbon groups too: Clipboard is right of Font, which is right of Styles. + assert!(x_of(&drawn, "Clipboard") > x_of(&drawn, "Font") && x_of(&drawn, "Font") > x_of(&drawn, "Styles")); + // The status bar starts at the right, and a narrow window still shows the ribbon's first group. + let page = drawn.iter().find(|(t, _)| t.contains(super::tr(ar, "Editing"))).map(|(_, x)| *x); + assert!(page.is_some()); + let narrow = frame_at(&ctx, &mut app, 700.0); + let clipboard = x_of(&narrow, "Clipboard"); + assert!((350.0..700.0).contains(&clipboard), "the narrow ribbon starts at its right end: {clipboard}"); + // Side panes swap sides: Navigation is on the right. + let _ = app.run("view.navigationPane", json!({"value": true})); + let drawn = frame_at(&ctx, &mut app, 1600.0); + assert!(x_of(&drawn, "Navigation") > 1300.0); + let _ = app.run("view.navigationPane", json!({"value": false})); + // English stays as it was. + { + let ctx = egui::Context::default(); + let mut app = fresh_app(&ctx); + let drawn = frame_at(&ctx, &mut app, 1600.0); + let x = |label: &str| drawn.iter().find(|(t, _)| t == label).map(|(_, x)| *x).unwrap(); + assert!(x("File") < x("Home") && x("Home") < x("Insert") && x("Insert") < 400.0 && x("Share") > 1400.0); + assert!(x("Clipboard") < x("Font") && x("Font") < x("Styles")); + assert!(x("Cut") < 200.0, "ribbon buttons keep their icon on the left and start at the left"); + assert!(!crate::rtl::on()); + super::set_current(ar); + } + // Everything else: no panic at any width. + let tabs: Vec<&str> = crate::ribbon::TABS.iter().copied().filter(|t| *t != "File").collect(); + for tab in &tabs { + let _ = app.run("ui.tab", json!({"tab": tab})); + for width in [2560.0, 1100.0, 420.0] { + frame_at(&ctx, &mut app, width); + } + } + let _ = app.run("ui.tab", json!({"tab": "Home"})); + // Reading Mode's toolbar is mirrored too: its far-side button ("Edit Document") is at the left. + let _ = app.run("view.readMode", json!({})); + let drawn = frame_at(&ctx, &mut app, 1600.0); + let edit = x_of(&drawn, "Edit Document"); + assert!(edit < 400.0, "Reading Mode: Edit Document at {edit}"); + let _ = app.run("view.printLayout", json!({})); + // File › Print: the settings are on the right and the preview on the left, not over each other. + let _ = app.run("ui.backstage", json!({"value": true, "page": "print"})); + for width in [1600.0, 1100.0] { + let drawn = frame_at(&ctx, &mut app, width); + let settings = x_of(&drawn, "Save these pages as PDF…"); + let indicator = super::tr(ar, "Page {page} of {pages}").split('{').next().unwrap_or_default(); + // (The status bar's page count is drawn under the page too, at the right: take the leftmost.) + let preview = drawn.iter().filter(|(t, _)| t.starts_with(indicator)).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(preview < f32::MAX, "the preview's page indicator is drawn"); + assert!(preview + 100.0 < settings, "print page at {width}: settings {settings}, preview {preview}"); + } + for page in ["home", "new", "open", "info", "export", "print", "options"] { + let _ = app.run("ui.backstage", json!({"value": true, "page": page})); + frame(&ctx, &mut app); + frame_at(&ctx, &mut app, 500.0); + } + let _ = app.run("ui.backstage", json!({"value": false})); + // A dialog's columns start at the right too: Go To's list of targets is right of its input. + let _ = app.run("ui.dialog", json!({"name": "goto"})); + let drawn = frame_at(&ctx, &mut app, 1600.0); + let (targets, input) = (x_of(&drawn, "Go to what:"), x_of(&drawn, "Enter page number:")); + assert!(targets > input, "Go To: targets {targets}, input {input}"); + app.dialog = None; + let dialogs = [ + "font", + "paragraph", + "tabs", + "goto", + "insertTable", + "pageSetup", + "link", + "bookmark", + "wordCount", + "zoom", + "watermark", + "phonetic", + "newStyle", + "manageStyles", + "newTableStyle", + "modifyTableStyle", + "commands", + "icons", + "tableProperties", + "enclose", + "pasteSpecial", + "about", + "contributors", + "models", + "insertMergeField", + "findRecipient", + "fieldCode", + "encryptPassword", + "defineList", + "trackChangesOptions", + "customizeKeyboard", + "customizeRibbon", + "customizeQuickAccess", + "noteOptions", + "caption", + "index", + "markEntry", + "toc", + "sourceManager", + "createSource", + "lineNumbers", + "hyphenation", + "language", + "envelopes", + "labels", + "find", + "replace", + "symbol", + "field", + "columns", + "borders", + "pageBorders", + "newRecipientList", + "editRecipientList", + "rules", + "matchFields", + "dropCap", + "dateTime", + "crossReference", + ]; + for name in dialogs { + let _ = app.run("ui.dialog", json!({"name": name})); + frame(&ctx, &mut app); + app.dialog = None; + } + let panes: Vec = + app.session.registry.all().iter().map(|c| c.id.to_string()).filter(|id| id.starts_with("view.") && id.ends_with("Pane")).collect(); + for pane in &panes { + let _ = app.run(pane, json!({"value": true})); + frame(&ctx, &mut app); + frame_at(&ctx, &mut app, 600.0); + let _ = app.run(pane, json!({"value": false})); + } + frames(&ctx, &mut app, click(egui::pos2(1280.0, 520.0), egui::PointerButton::Secondary)); + frames(&ctx, &mut app, escape()); + let _ = app.run("insert.table", json!({"rows": 2, "cols": 2})); + for tab in ["Table Design", "Table Layout"] { + let _ = app.run("ui.tab", json!({"tab": tab})); + frame(&ctx, &mut app); + frame_at(&ctx, &mut app, 600.0); + } + let _ = app.run("insert.equation", json!({})); + let _ = app.run("ui.tab", json!({"tab": "Equation"})); + frame(&ctx, &mut app); + // Every ribbon control on every tab, hovered and clicked: its tooltip, menu or dialog. + let contextual = ["Table Design", "Table Layout", "Equation"]; + let open = |tab: &str| { + let ctx = egui::Context::default(); + let mut app = start(&ctx); + match tab { + "Table Design" | "Table Layout" => drop(app.run("insert.table", json!({"rows": 2, "cols": 2}))), + "Equation" => drop(app.run("insert.equation", json!({}))), + _ => {} + } + let _ = app.run("ui.tab", json!({"tab": tab})); + frame(&ctx, &mut app); + (ctx, app) + }; + for tab in tabs.iter().chain(&contextual) { + let (ctx, mut app) = open(tab); + for at in ribbon_controls(&ctx) { + frames(&ctx, &mut app, vec![vec![egui::Event::PointerMoved(at)]; 2]); + frames(&ctx, &mut app, click(at, egui::PointerButton::Primary)); + frames(&ctx, &mut app, escape()); + app.dialog = None; + app.ui.backstage = false; + if app.ui.tab != *tab { + (_, app) = open(tab); + } + } + } + super::set_current(super::Lang::EN); +} diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index a5930b5b49..17d4b771cf 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1189,3 +1189,113 @@ fn the_flatpak_manifest_pins_the_craft_i18n_rev() { assert_eq!(commit.trim().trim_matches(['\'', '"']), rev); assert!(manifest.contains("CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n"), "the build embeds the catalogs"); } + +/// A right-to-left test language whose catalog is `source`, selected as the current language. +fn arabic_fixture(source: &'static str) -> Lang { + let ar = fixture_lang("ar", source); + set_current(ar); + ar +} + +#[test] +fn arabic_locales_and_saved_preference_work_without_changing_the_document() { + needs_catalogs!(); + let Some(ar) = Lang::from_code("ar") else { + eprintln!("skipped: the embedded catalogs have no Arabic"); + return; + }; + for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant", "ar-Arab-AE"] { + assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); + } + assert_eq!(first_supported(["xx-XX", "ar-MA", "en-US"]), Some(ar)); + assert_eq!(normalize_pref("AR"), Some("ar")); + assert!(ar.rtl()); + assert!(Lang::all().filter(|l| l.rtl()).eq([ar]), "Arabic is the only right-to-left language"); + assert!(!ar.uses_cjk()); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Default::default()); + let before = serde_json::to_value(&app.session.doc).unwrap(); + let result = app.run("ui.language", serde_json::json!({"value": "AR"})).unwrap(); + assert_eq!(result["effective"], "ar"); + assert_eq!(app.ui.language, "ar"); + assert_eq!(serde_json::to_value(&app.session.doc).unwrap(), before); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(Lang::from_pref(&restored.language), ar); + assert!(app.run("ui.language", serde_json::json!({"value": "ar-unknown"})).is_err()); + assert_eq!(app.ui.language, "ar", "invalid preferences must keep the saved language"); +} + +/// Arabic comes back from the lookups exactly as the catalog has it, in typing order: egui's text +/// layout orders the words of each line itself, so nothing here reverses them. Placeholders are +/// filled in place and text for the document stays as typed. +#[test] +fn arabic_lookups_keep_typing_order() { + let ar = arabic_fixture(concat!( + "\tTable of Contents\tجدول المحتويات\n", + "\tPaste Special…\tلصق خاص…\n", + "\tAbout WordCraft\tحول WordCraft\n", + "\tPassword:\tكلمة المرور:\n", + "\tTable of\\nContents\tجدول\\nالمحتويات\n", + "\tPage {page} of {pages}\tالصفحة {page} من {pages}\n", + "\tExported {path}\tتم التصدير إلى {path}\n", + "preview\tTitle\tالعنوان\n", + "\tHome\tالرئيسية\n", + "\tFont\tالخط\n", + )); + assert!(ar.rtl()); + assert_eq!(tr(ar, "Table of Contents"), "جدول المحتويات"); + assert_eq!(tr(ar, "Paste Special…"), "لصق خاص…"); + assert_eq!(tr(ar, "About WordCraft"), "حول WordCraft"); + assert_eq!(tr(ar, "Password:"), "كلمة المرور:"); + // A two-line ribbon label keeps its two lines. + assert_eq!(tr(ar, "Table of\nContents"), "جدول\nالمحتويات"); + // Placeholders are filled in place; inserted text is never touched, and the text for the + // document is the same string. + let page = fmt_plain(tr(ar, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]); + assert_eq!(page, "الصفحة 3 من 9"); + assert_eq!(fmt_plain(tr(ar, "Exported {path}"), &[("path", "draft-{words}.docx")]), "تم التصدير إلى draft-{words}.docx"); + // The preview context is looked up the same way. + assert_eq!(trc(ar, "preview", "Title"), "العنوان"); + // A string without a translation stays English, untouched. + assert_eq!(tr(ar, "unknown future label"), "unknown future label"); + set_current(ar); + // A ribbon location reads from the right: the tab first, the mark pointing left. + assert_eq!(location("Home › Font"), "الرئيسية ‹ الخط"); + // A search query typed in reading order is a plain substring of the label. + assert!(lowercase(t("Table of Contents")).contains("جدول المحتويات")); + set_current(Lang::EN); + assert_eq!(location("Home › Font"), "Home › Font"); +} + +/// File names and paths shown in a right-to-left line are set apart and marked: the name in its +/// own isolate with its extension kept left to right, a path as a left-to-right isolate with a +/// mark after every separator so neighbouring Arabic folders do not swap. Other languages get +/// the text untouched. +#[test] +fn names_and_paths_are_isolated_in_a_right_to_left_interface() { + set_current(Lang::EN); + assert_eq!(isolate("تقرير 2.docx"), "تقرير 2.docx"); + assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "C:/ملفات/تقرير.docx"); + let ar = fixture_lang("ar", "\tHome\tالرئيسية\n"); + set_current(ar); + assert_eq!(isolate("تقرير 2.docx"), "\u{2068}تقرير 2\u{200E}.docx\u{2069}"); + assert_eq!(isolate("ملاحظات"), "\u{2068}ملاحظات\u{2069}", "no extension, no mark"); + assert_eq!(isolate("v1.2 تقرير"), "\u{2068}v1.2 تقرير\u{2069}", "a dot that is not an extension"); + assert_eq!(isolate_path(r"C:\ملفات\تقرير.docx"), "\u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}"); + assert_eq!(isolate_path("C:/ملفات/تقرير.docx"), "\u{2066}C:/\u{200E}ملفات/\u{200E}تقرير\u{200E}.docx\u{2069}"); + set_current(Lang::EN); +} + +/// A path inside a sentence (the open failure on the status bar) is isolated in a right-to-left +/// interface, so Arabic folders do not scramble; in English the message holds the path as it is. +#[test] +fn a_path_in_a_message_is_isolated_in_a_right_to_left_interface() { + let path = r"C:\ملفات\تقرير.docx"; + set_current(Lang::EN); + let mut a = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + assert!(a.report_open_failure(path, "bad").contains(path)); + set_current(fixture_lang("ar", "\tCouldn't open {path}: {error}\tتعذر فتح {path}: {error}\n")); + let msg = a.report_open_failure(path, "bad"); + set_current(Lang::EN); + assert_eq!(msg, "تعذر فتح \u{2066}C:\\\u{200E}ملفات\\\u{200E}تقرير\u{200E}.docx\u{2069}: \u{2068}bad\u{2069}"); +} diff --git a/crates/ui-egui/src/language_packs.rs b/crates/ui-egui/src/language_packs.rs index b8c9156378..a4f750260c 100644 --- a/crates/ui-egui/src/language_packs.rs +++ b/crates/ui-egui/src/language_packs.rs @@ -337,6 +337,8 @@ fn licence_hover(pack: &Pack) -> String { /// File › Options › Language › Language packs: every available and installed pack with its /// name, size, licence and source, and Install or Remove. pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) { + // Pack names include Arabic and other scripts; ask for a system Arabic face when none is embedded. + app.want_system_arabic = true; let t = Tokens::get(ui.ctx()); ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0))); ui.add_space(2.0); @@ -407,7 +409,7 @@ pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) { let mut install = None; let mut removal = None; egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| { - egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true), |ui| { for h in ["Language", "Size", "Licence", "Source", ""] { ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim)); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b5ee202cf8..d21289eef5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,7 @@ pub mod proofing_pane; pub mod read_aloud; pub mod ribbon; pub mod ribbon_fit; +pub mod rtl; pub mod ruler_tabs; pub mod scroll; pub mod system_clipboard; @@ -532,9 +533,16 @@ pub struct WordApp { fonts_hant: bool, /// Whether the installed UI fonts include an installed CJK fallback font (#241). fonts_system_cjk: bool, + /// Whether the installed UI fonts were asked to include an installed Arabic font (an Arabic + /// interface; used when no embedded face has Arabic letters). + fonts_system_arabic: bool, /// CJK text is (about to be) on screen in a non-CJK interface, e.g. the language names in /// File ▸ Options: load the installed CJK fallback font if no embedded face covers it. pub(crate) want_system_cjk: bool, + /// Arabic text is (about to be) on screen in a non-Arabic interface, e.g. "العربية" in the + /// language picker or Language packs list: load an installed Arabic font when no embedded face + /// covers it (dev builds without craft-fonts). + pub(crate) want_system_arabic: bool, /// The document (id, revision) whose comment text [`WordApp::ensure_comment_fonts`] last /// looked at. comment_fonts_for: Option<(u64, u64)>, diff --git a/crates/ui-egui/src/mini_toolbar.rs b/crates/ui-egui/src/mini_toolbar.rs index e5e195d7b4..4b8c9fe57a 100644 --- a/crates/ui-egui/src/mini_toolbar.rs +++ b/crates/ui-egui/src/mini_toolbar.rs @@ -354,7 +354,7 @@ pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option crate::table_resize::tests::H { /// pictures whose cached preview already contains a rotation and either reflection. #[test] fn rotation_drag_turns_picture_pixels_live_and_commits_once_or_cancels() { - use crate::table_resize::tests::{H, button, steps}; + use crate::table_resize::tests::{H, button, gpu_turn, steps}; + let _gpu = gpu_turn(); for (initial, flip_h, flip_v) in [(0.0, false, false), (30.0, true, false), (90.0, false, true), (315.0, true, true)] { // Four original colour quadrants make both accidental extra rotation and reflected // UVs observable. Sample near the long ends after a quarter turn, outside the old diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 40e27376aa..2e6a01864d 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -140,8 +140,9 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { ui.label(RichText::new(tl!("Options")).font(semibold(26.0))); ui.add_space(16.0); let current = if PANES.contains(&app.ui.options_pane.as_str()) { app.ui.options_pane.clone() } else { "general".to_string() }; - ui.horizontal_top(|ui| { - ui.vertical(|ui| { + let rtl = crate::rtl::on(); + let body = |ui: &mut Ui| { + crate::rtl::vertical(ui, |ui| { ui.set_width(170.0); for id in PANES { let label = tl!(pane_label(id)); @@ -155,7 +156,13 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { } let font = if active { semibold(14.0) } else { crate::theme::regular(14.0) }; // Long translations stay inside the row; WidgetInfo above keeps the full name. - ui.painter().with_clip_rect(r).text(egui::pos2(r.min.x + 12.0, r.center().y), egui::Align2::LEFT_CENTER, label, font, t.text); + ui.painter().with_clip_rect(r).text( + egui::pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), + crate::rtl::lead_center(rtl), + label, + font, + t.text, + ); if resp.clicked() { app.ui.options_pane = id.to_string(); } @@ -165,7 +172,7 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { let (line, _) = ui.allocate_exact_size(vec2(1.0, 420.0), egui::Sense::hover()); ui.painter().rect_filled(line, 0.0, t.border); ui.add_space(20.0); - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.set_max_width(640.0); match current.as_str() { "display" => display(app, ui), @@ -178,7 +185,13 @@ pub fn page(app: &mut WordApp, ui: &mut Ui) { _ => general(app, ui), } }); - }); + }; + // Right to left the pane list is on the right and the settings fill in from it. + if rtl { + ui.with_layout(crate::rtl::row_layout(ui, egui::Align::Min), body) + } else { + crate::rtl::horizontal_top(ui, body) + }; } fn section(ui: &mut Ui, title: &str) { @@ -237,27 +250,38 @@ fn general(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(8.0); section(ui, "Personalize"); - egui::Grid::new("options_user").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - let user_label = ui.label(tl!("User name:")); - let mut n = app.session.author.clone(); - let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc); - if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() && !protected { - app.session.author = n; - } - ui.end_row(); - let initials_label = ui.label(tl!("Initials:")); - let mut i = app.session.initials.clone(); - let hint = app.session.user_initials(); - if ui - .add( - egui::TextEdit::singleline(&mut i).desired_width(80.0).char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS).hint_text(hint), - ) - .labelled_by(initials_label.id) - .changed() - { - set(app, json!({"initials": i})); - } - ui.end_row(); + crate::rtl::Table::new("options_user").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| { + t.row(|r| { + let user_label = r.cell(|ui| ui.label(tl!("User name:"))); + r.cell(|ui| { + let mut n = app.session.author.clone(); + let protected = wordcraft_engine::cmd::protect::enforced(&app.session.doc); + if ui.add_enabled(!protected, egui::TextEdit::singleline(&mut n).desired_width(260.0)).labelled_by(user_label.id).changed() + && !protected + { + app.session.author = n; + } + }); + }); + t.row(|r| { + let initials_label = r.cell(|ui| ui.label(tl!("Initials:"))); + r.cell(|ui| { + let mut i = app.session.initials.clone(); + let hint = app.session.user_initials(); + if ui + .add( + egui::TextEdit::singleline(&mut i) + .desired_width(80.0) + .char_limit(wordcraft_engine::cmd::options::MAX_INITIALS_CHARS) + .hint_text(hint), + ) + .labelled_by(initials_label.id) + .changed() + { + set(app, json!({"initials": i})); + } + }); + }); }); note(ui, "New comments and tracked changes are recorded under this name and these initials."); } @@ -369,10 +393,11 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) { }; for (language, file, error) in found { let line = match (file, error) { - (Some(f), Some(e)) => { - crate::i18n::fmt(tl!("{language}: {file} (unreadable: {reason})"), &[("language", &language), ("file", &f), ("reason", &e)]) - } - (Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &f)]), + (Some(f), Some(e)) => crate::i18n::fmt( + tl!("{language}: {file} (unreadable: {reason})"), + &[("language", &language), ("file", &crate::i18n::isolate_path(&f)), ("reason", &e)], + ), + (Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &crate::i18n::isolate_path(&f))]), (None, _) => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]), }; ui.add(egui::Label::new(RichText::new(line).small()).truncate()); @@ -449,7 +474,7 @@ fn save(app: &mut WordApp, ui: &mut Ui) { // The browser can't write to the user's files, so AutoSave can't be turned on there (#176). let browser = app.autosave_block() == Some(crate::AutoSaveBlock::Browser); let mut autosave = app.autosave && !browser; - let r = ui.add_enabled(!browser, egui::Checkbox::new(&mut autosave, tl!("AutoSave documents you have saved in WordCraft"))); + let r = ui.add_enabled_ui(!browser, |ui| ui.checkbox(&mut autosave, tl!("AutoSave documents you have saved in WordCraft"))).inner; if browser { r.on_disabled_hover_text(crate::AutoSaveBlock::Browser.reason()); } else if r.changed() { @@ -537,51 +562,61 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { } ui.add_space(8.0); section(ui, "Display"); - egui::Grid::new("options_display").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - let recent_label = ui.label(tl!("Recent documents to show:")); - let mut n = app.ui.recent_shown(); - if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() { - app.ui.recent_count = n; - } - ui.end_row(); - let units_label = ui.label(tl!("Measurement units:")); - // The user's choice, else the region's (#838): Automatic follows the region. - let units = app.session.units(); - let auto = app.session.prefs.units.is_none(); - let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n); - let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() }; - let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| { - if ui.selectable_label(auto, tl!("Automatic")).clicked() { - set(app, json!({"units": "auto"})); - } - for (u, label) in UNITS { - if ui.selectable_label(!auto && units == u, tl!(label)).clicked() { - set(app, json!({"units": u})); + crate::rtl::Table::new("options_display").columns(2).spacing(vec2(8.0, 6.0)).show(ui, |t| { + t.row(|r| { + let recent_label = r.cell(|ui| ui.label(tl!("Recent documents to show:"))); + r.cell(|ui| { + let mut n = app.ui.recent_shown(); + if ui.add(egui::DragValue::new(&mut n).range(0..=crate::MAX_RECENT).speed(0.2)).labelled_by(recent_label.id).changed() { + app.ui.recent_count = n; } - } + }); + }); + t.row(|r| { + let units_label = r.cell(|ui| ui.label(tl!("Measurement units:"))); + r.cell(|ui| { + // The user's choice, else the region's (#838): Automatic follows the region. + let units = app.session.units(); + let auto = app.session.prefs.units.is_none(); + let name = UNITS.iter().find(|(u, _)| *u == units).map_or("Inches", |(_, n)| n); + let shown = if auto { format!("{} ({})", tl!("Automatic"), tl!(name)) } else { tl!(name).to_string() }; + let combo = craft_ui::ComboBox::from_id_salt("options_units").selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(auto, tl!("Automatic")).clicked() { + set(app, json!({"units": "auto"})); + } + for (u, label) in UNITS { + if ui.selectable_label(!auto && units == u, tl!(label)).clicked() { + set(app, json!({"units": u})); + } + } + }); + combo.response.labelled_by(units_label.id); + }); }); - combo.response.labelled_by(units_label.id); - ui.end_row(); // The region new documents, units and the decimal separator follow; Automatic is the // system's (#838). - let region_label = ui.label(tl!("Region:")); - let chosen = app.session.prefs.region.clone(); - let system = &app.session.system_region; - let system_tag = if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) }; - let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]); - let shown = chosen.clone().unwrap_or_else(|| automatic.clone()); - let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| { - if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() { - set(app, json!({"region": "auto"})); - } - for tag in REGIONS { - if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() { - set(app, json!({"region": tag})); - } - } + t.row(|r| { + let region_label = r.cell(|ui| ui.label(tl!("Region:"))); + r.cell(|ui| { + let chosen = app.session.prefs.region.clone(); + let system = &app.session.system_region; + let system_tag = + if system.territory.is_empty() { system.language.clone() } else { format!("{}-{}", system.language, system.territory) }; + let automatic = crate::i18n::fmt(tl!("Automatic ({region})"), &[("region", system_tag.as_str())]); + let shown = chosen.clone().unwrap_or_else(|| automatic.clone()); + let combo = craft_ui::ComboBox::from_id_salt("options_region").selected_text(shown).width(200.0).show_ui(ui, |ui| { + if ui.selectable_label(chosen.is_none(), automatic.as_str()).clicked() { + set(app, json!({"region": "auto"})); + } + for tag in REGIONS { + if ui.selectable_label(chosen.as_deref() == Some(tag), tag).clicked() { + set(app, json!({"region": tag})); + } + } + }); + combo.response.labelled_by(region_label.id); + }); }); - combo.response.labelled_by(region_label.id); - ui.end_row(); }); let mut keytips = app.ui.keytips_enabled; if ui.checkbox(&mut keytips, tl!("Show KeyTips (Alt or F10)")).changed() { @@ -678,7 +713,10 @@ fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) { use crate::theme::InterfaceSize; let size = app.ui.interface_size; let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); - let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]); + // The template keeps the percent sign outside its placeholder, so `fmt` isolates the number + // without it and a right-to-left line draws "(%200)": the sign goes inside the isolate. + let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &InterfaceSize::AUTO.drawn_percent(native).to_string())]) + .replace("\u{2069}%", "%\u{2069}"); let selected = size.percent().map_or_else(|| auto.clone(), |pct| format!("{pct}%")); ui.horizontal(|ui| { let label = ui.label(tl!("Interface size:")); @@ -735,6 +773,7 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) { use crate::i18n::{AUTO, Lang}; // The language names are in their own scripts: fonts for them load from the next frame. app.want_system_cjk = true; + app.want_system_arabic = true; let system = crate::i18n::system_lang(); let auto_label = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", system.name())]); let current = if app.ui.language == AUTO { auto_label.clone() } else { Lang::from_pref(&app.ui.language).name().to_string() }; diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 09e63f689c..9e6e878243 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -29,6 +29,8 @@ pub(crate) use comments::comment_draft; use comments::comments; pub(crate) use comments::{CommentDrafts, comment_editor, commit_comment, commit_reply, flush_comment_drafts, has_comment_drafts, reply_editor}; use host::Content; +#[cfg(test)] +pub(crate) use host::apply; pub use host::show; pub(crate) use host::{dock_command, overlay_open}; pub(crate) use inspector::describe_props; @@ -153,7 +155,7 @@ impl PaneContent for Content<'_> { fn body(&mut self, ui: &mut Ui, pane: &Pane) { let app = &mut *self.app; - match pane { + crate::rtl::root(ui, |ui| match pane { Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui), Pane::Navigation => nav(app, ui), Pane::Clipboard => clipboard(app, ui), @@ -167,7 +169,7 @@ impl PaneContent for Content<'_> { Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), Pane::Proofing => crate::proofing_pane::show(app, ui), Pane::Comments => comments(app, ui), - } + }) } fn default_state(&self, pane: &Pane) -> PaneState { diff --git a/crates/ui-egui/src/panes/host.rs b/crates/ui-egui/src/panes/host.rs index 346e320dea..87c039cee9 100644 --- a/crates/ui-egui/src/panes/host.rs +++ b/crates/ui-egui/src/panes/host.rs @@ -74,6 +74,8 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { match request { PaneRequest::Close(pane) => close(app, pane), PaneRequest::Place(Pane::Styles, place) => { + // Physical sides: the host is not RTL-flipped, and Dock Left / Dock Right mean the + // window's left and right (same as `ui.paneDock` / `view.stylesPaneDock`). let dock = match place { Place::Docked(Edge::Start) => "left", Place::Docked(Edge::End) => "right", @@ -138,6 +140,7 @@ pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result "left", Place::Floating => "float", diff --git a/crates/ui-egui/src/panes/inspector.rs b/crates/ui-egui/src/panes/inspector.rs index 8c5b6f7b8c..9353abad78 100644 --- a/crates/ui-egui/src/panes/inspector.rs +++ b/crates/ui-egui/src/panes/inspector.rs @@ -84,7 +84,7 @@ fn level_row(ui: &mut Ui, t: &Tokens, caption: &str, value: &str, dim: bool, tip let text = egui::RichText::new(value).font(regular(12.5)).color(if dim { t.text_disabled } else { t.text }); ui.add(egui::Label::new(text).wrap()); }); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), egui::Sense::click()); let enabled = !disabled; if enabled && resp.hovered() { diff --git a/crates/ui-egui/src/panes/side.rs b/crates/ui-egui/src/panes/side.rs index e896924b72..7c4d3a26dd 100644 --- a/crates/ui-egui/src/panes/side.rs +++ b/crates/ui-egui/src/panes/side.rs @@ -31,7 +31,7 @@ pub(super) fn clipboard(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("edit.pasteClipboardItem", json!({"index": i})); app.canvas.want_focus = true; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Min), |ui| { if ui.small_button(tl!("Delete")).clicked() { let _ = app.run("edit.deleteClipboardItem", json!({"index": i})); app.canvas.want_focus = true; @@ -87,14 +87,14 @@ pub(super) fn styles(app: &mut WordApp, ui: &mut Ui) { for (id, name, is_char, builtin) in list { ui.horizontal(|ui| { let r = ui.selectable_label(id == cur, &name); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { ui.label(egui::RichText::new(if is_char { "a" } else { "¶" }).weak()); }); if r.clicked() { let _ = app.run("para.style", json!({"style": id})); app.canvas.want_focus = true; } - r.context_menu(|ui| { + crate::rtl::context_menu(&r, |ui| { if ui.button(tl!("Modify…")).clicked() { app.dialog = crate::dialogs::Dialog::modify_style(app, &id); ui.close(); diff --git a/crates/ui-egui/src/panes/state.rs b/crates/ui-egui/src/panes/state.rs index 2a806714e7..67d4c59027 100644 --- a/crates/ui-egui/src/panes/state.rs +++ b/crates/ui-egui/src/panes/state.rs @@ -114,19 +114,46 @@ pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> { /// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the /// others' from `panePlaces`. +/// +/// `left` / `right` in the docking API and in saved prefs are **physical** window sides (and the +/// Dock Left / Dock Right menus mean the same). Only an unset default place is mirrored in a +/// right-to-left interface, so a pane that has never been docked still opens on the reading-start +/// side. pub(super) fn pane_state(app: &WordApp, pane: Pane) -> PaneState { + let (mut state, explicit) = stored_state(app, pane); + if !explicit { + state.place = mirror_default(state.place); + } + state +} + +/// Flip a default dock edge in a right-to-left interface so Start (reading start in LTR) opens on +/// the physical right. Explicit and saved left/right are never flipped — see [`pane_state`]. +pub(super) fn mirror_default(place: Place) -> Place { + match place { + Place::Docked(Edge::Start) if crate::rtl::on() => Place::Docked(Edge::End), + Place::Docked(Edge::End) if crate::rtl::on() => Place::Docked(Edge::Start), + other => other, + } +} + +/// Physical place from prefs, and whether that place was explicitly saved (`true`) or is still the +/// pane's default (`false`). +fn stored_state(app: &WordApp, pane: Pane) -> (PaneState, bool) { if pane == Pane::Styles { - return styles_state(app); + return (styles_state(app), true); } let default = pane.default_state(); - let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default }; - let place = match saved.dock { - Some(SavedDock::Left) => Place::Docked(Edge::Start), - Some(SavedDock::Right) => Place::Docked(Edge::End), - Some(SavedDock::Float) => Place::Floating, - None => default.place, + let Some(saved) = app.ui.pane_places.get(pane.key()) else { + return (default, false); }; - PaneState { place, size: saved.width.or(default.size), float_rect: saved.float } + let (place, explicit) = match saved.dock { + Some(SavedDock::Left) => (Place::Docked(Edge::Start), true), + Some(SavedDock::Right) => (Place::Docked(Edge::End), true), + Some(SavedDock::Float) => (Place::Floating, true), + None => (default.place, false), + }; + (PaneState { place, size: saved.width.or(default.size), float_rect: saved.float }, explicit) } /// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). diff --git a/crates/ui-egui/src/panes/tests.rs b/crates/ui-egui/src/panes/tests.rs index 6485f4d6ae..ba20604848 100644 --- a/crates/ui-egui/src/panes/tests.rs +++ b/crates/ui-egui/src/panes/tests.rs @@ -378,6 +378,37 @@ fn pane_dock_command_docks_floats_and_reads_each_pane() { assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing"); } +/// Explicit `left` / `right` stay on the physical window sides in an Arabic (RTL) interface; +/// defaults still flip to the reading-start side. Covers `ui.paneDock` and `view.stylesPaneDock`. +#[test] +fn pane_dock_left_and_right_are_physical_in_rtl() { + use craft_ui::panes::{Edge, Place}; + use serde_json::json; + let arabic = crate::i18n::fixture_lang("ar", "\tHome\tالرئيسية\n"); + crate::i18n::set_current(arabic); + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + // Defaults: Navigation's Start opens on the physical right (reading start) until set. + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::End)); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "right"); + // Explicit left stays physical left and is reported as left. + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "left"})).unwrap()["dock"], "left"); + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Docked(Edge::Start)); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap()["dock"], "left"); + assert_eq!(app.run("ui.paneDock", json!({"pane": "comments", "dock": "right"})).unwrap()["dock"], "right"); + assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::End)); + // Styles through view.stylesPaneDock — same physical contract. + assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "left"})).unwrap()["dock"], "left"); + assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::Start)); + assert_eq!(app.run("view.stylesPaneDock", json!({})).unwrap()["dock"], "left"); + assert_eq!(app.run("view.stylesPaneDock", json!({"dock": "right"})).unwrap()["dock"], "right"); + assert_eq!(super::pane_state(&app, Pane::Styles).place, Place::Docked(Edge::End)); + // A host Place request (Dock Left menu / drag) saves physical left without flipping. + super::apply(&mut app, craft_ui::panes::PaneRequest::Place(Pane::Clipboard, Place::Docked(Edge::Start))); + assert_eq!(app.ui.pane_places.get("clipboard").and_then(|p| p.dock), Some(super::SavedDock::Left)); + assert_eq!(super::pane_state(&app, Pane::Clipboard).place, Place::Docked(Edge::Start)); + crate::i18n::set_current(crate::i18n::Lang::EN); +} + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 091c2654de..73249a7532 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -399,7 +399,7 @@ pub fn ink_mask(img: &mut egui::ColorImage) { } } -/// A font list entry: `name` in its own face, 240×20 pt. The face is read for the preview alone +/// A font list entry: `name` in its own face, 244×22 pt. The face is read for the preview alone /// and dropped (#848): browsing the list used to load every family shown for good, so each trip /// through the Font box grew memory by tens of megabytes. A name the font has no glyphs for (a /// symbol font's), or a font that can't be read, shows in the default face. @@ -409,8 +409,36 @@ pub fn font_entry_image(name: &str, ppp: f32) -> egui::ColorImage { .preview_face(name) .filter(|f| name.chars().filter(|c| !c.is_whitespace()).all(|c| f.covers(c))) .unwrap_or_else(|| db.face(wordcraft_fonts::FALLBACK_FAMILY, "Regular")); - let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, 22.0, 2.0, ppp); - egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()) + // Rendered taller than the row and cut back to the drawn text: faces with tall line metrics + // (Arabic, CJK) put their baseline well below a Latin face's, and a fixed 22 pt strip showed + // only the top of their names. + let img = wordcraft_render::render_text_line(&face, name, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp); + let img = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight()); + center_rows_on_ink(&img, (FONT_PREVIEW_H * ppp).round() as usize) +} + +/// Height of a font-menu entry's picture, in points. +const FONT_PREVIEW_H: f32 = 22.0; +/// Height the entry is first rendered at, tall enough for any face's first line at 13 pt. +const FONT_PREVIEW_RENDER_H: f32 = 56.0; + +/// `img` cut to `rows` pixel rows centred on what is drawn in it (the rows that differ from the +/// top-left background pixel). An image that is already that short, or blank, keeps its top rows. +fn center_rows_on_ink(img: &egui::ColorImage, rows: usize) -> egui::ColorImage { + let [w, h] = img.size; + let rows = rows.clamp(1, h.max(1)); + if w == 0 || h <= rows { + return img.clone(); + } + let background = img.pixels.first().copied().unwrap_or_default(); + let inked = + |y: usize| img.pixels.get(y.saturating_mul(w)..y.saturating_add(1).saturating_mul(w)).is_some_and(|row| row.iter().any(|p| *p != background)); + let top = (0..h).find(|y| inked(*y)).unwrap_or(0); + let bottom = (0..h).rev().find(|y| inked(*y)).unwrap_or(top); + let middle = top.saturating_add(bottom).saturating_add(1) / 2; + let start = middle.saturating_sub(rows / 2).min(h.saturating_sub(rows)); + let pixels = img.pixels.get(start.saturating_mul(w)..start.saturating_add(rows).saturating_mul(w)).unwrap_or_default().to_vec(); + egui::ColorImage::new([w, rows], pixels) } /// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it. @@ -684,3 +712,45 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response #[cfg(test)] mod tests; +#[test] +fn font_preview_rows_are_centred_on_the_drawn_text() { + let ink = egui::Color32::BLACK; + let paper = egui::Color32::WHITE; + // Ink low in a tall picture, as a face with a deep baseline draws it. + let mut img = egui::ColorImage::filled([4, 40], paper); + for y in 28..34 { + img.pixels[y * 4 + 1] = ink; + } + let cut = center_rows_on_ink(&img, 10); + assert_eq!(cut.size, [4, 10]); + let rows: Vec = (0..10).filter(|y| cut.pixels[y * 4 + 1] == ink).collect(); + assert_eq!(rows, [2, 3, 4, 5, 6, 7], "all six inked rows, with equal space above and below"); + // Blank, short, empty and oversized requests never panic. + assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([4, 40], paper), 10).size, [4, 10]); + assert_eq!(center_rows_on_ink(&img, 400).size, [4, 40]); + assert_eq!(center_rows_on_ink(&img, 0).size, [4, 1]); + assert_eq!(center_rows_on_ink(&egui::ColorImage::filled([0, 0], paper), 10).size, [0, 0]); +} + +/// Every loaded face's name fits its font-menu row: faces with tall line metrics (the +/// craft-fonts Arabic and Japanese faces) were cut off at the bottom. +#[test] +fn font_preview_shows_the_whole_name_in_every_loaded_face() { + let ppp = 2.0; + let rows = (FONT_PREVIEW_H * ppp).round() as usize; + for family in wordcraft_fonts::FontDb::with_font_dirs(Vec::new()).families() { + let face = wordcraft_fonts::FontDb::global().face(&family, "Regular"); + let tall = wordcraft_render::render_text_line(&face, &family, 13.0, 244.0, FONT_PREVIEW_RENDER_H, 2.0, ppp); + let tall = egui::ColorImage::from_rgba_unmultiplied([tall.width as usize, tall.height as usize], &tall.to_straight()); + let img = center_rows_on_ink(&tall, rows); + let [w, h] = img.size; + assert_eq!(h, rows, "{family}"); + let background = img.pixels[0]; + let inked = |y: usize| img.pixels[y * w..(y + 1) * w].iter().any(|p| *p != background); + assert!((0..h).any(inked), "{family}: the name is drawn"); + assert!(!inked(0) && !inked(h - 1), "{family}: the name touches the edge of its row"); + // Nothing of the name is left outside the cut. + let all = (0..tall.size[1]).filter(|y| tall.pixels[y * w..(y + 1) * w].iter().any(|p| *p != tall.pixels[0])).count(); + assert_eq!((0..h).filter(|y| inked(*y)).count(), all, "{family}: part of the name is cut off"); + } +} diff --git a/crates/ui-egui/src/print_page.rs b/crates/ui-egui/src/print_page.rs index e496268bb3..920f571784 100644 --- a/crates/ui-egui/src/print_page.rs +++ b/crates/ui-egui/src/print_page.rs @@ -156,10 +156,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let current = app.session.current_pages(&lay); app.ui.print.preview = app.ui.print.preview.min(total.saturating_sub(1)); - ui.horizontal_top(|ui| { - ui.vertical(|ui| { - ui.set_width(380.0); + let settings = |app: &mut WordApp, ui: &mut Ui| { + { + ui.set_width(SETTINGS_WIDTH); // The settings scroll on their own: the preview beside them keeps the whole height (#1156). + // egui draws the bar at the right edge, over the right-aligned controls of a mirrored + // column: a solid bar there takes its own gutter instead of floating over them. + if crate::rtl::flows(ui) { + ui.spacing_mut().scroll = egui::style::ScrollStyle::solid(); + } egui::ScrollArea::vertical().id_salt("print_settings").show(ui, |ui| { ui.spacing_mut().item_spacing.y = 8.0; let native = app.services.print_native.is_some(); @@ -198,7 +203,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if native { if app.ui.print.copies > 1 { - ui.add_enabled(!app.printing.busy(), egui::Checkbox::new(&mut app.ui.print.collate, tl!("Collate copies"))); + ui.add_enabled_ui(!app.printing.busy(), |ui| ui.checkbox(&mut app.ui.print.collate, tl!("Collate copies"))); } } else { let copies_label = ui.label(egui::RichText::new(tl!("Copies")).font(medium(13.0))); @@ -255,12 +260,37 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } export_formats(app, ui); }); - }); - ui.add_space(24.0); - ui.vertical(|ui| preview(app, ui, &lay, total)); + } + }; + // A right-to-left interface splits the room by hand, settings on the side reading starts from: + // a `horizontal_top` row there lays the preview over the settings. + if crate::rtl::flows(ui) { + let room = ui.available_rect_before_wrap(); + let split = (room.max.x - SETTINGS_WIDTH).max(room.min.x); + let settings_rect = Rect::from_min_max(pos2(split, room.min.y), room.max); + let preview_rect = Rect::from_min_max(room.min, pos2((split - SETTINGS_GAP).max(room.min.x), room.max.y)); + let layout = egui::Layout::top_down(egui::Align::Max); + ui.scope_builder(egui::UiBuilder::new().max_rect(settings_rect).layout(layout), |ui| settings(app, ui)); + // A window too narrow for a usable preview shows the settings alone. + if preview_rect.width() >= MIN_PREVIEW_WIDTH { + ui.scope_builder(egui::UiBuilder::new().max_rect(preview_rect).layout(layout), |ui| preview(app, ui, &lay, total)); + } + ui.allocate_rect(room, Sense::hover()); + return; + } + crate::rtl::horizontal_top(ui, |ui| { + crate::rtl::vertical(ui, |ui| settings(app, ui)); + ui.add_space(SETTINGS_GAP); + crate::rtl::vertical(ui, |ui| preview(app, ui, &lay, total)); }); } +/// The settings column's width, and the space between it and the preview. +const SETTINGS_WIDTH: f32 = 380.0; +const SETTINGS_GAP: f32 = 24.0; +/// The narrowest preview pane worth drawing when the layout is mirrored. +const MIN_PREVIEW_WIDTH: f32 = 200.0; + /// Room around the page inside the preview pane. const PREVIEW_MARGIN: f32 = 10.0; /// The preview's bottom bar: the page indicator on the left, the zoom on the right. @@ -301,6 +331,7 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { ui.painter().image(tex.id(), on_sheet, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE); } }); + let rtl = crate::rtl::flows(ui); ui.horizontal(|ui| { ui.set_height(PREVIEW_BAR); let nav = |ui: &mut Ui, icon: &str, tip: &str, enabled: bool| { @@ -313,21 +344,26 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { icons::paint(ui.painter(), r.shrink(4.0), icon, col, col); enabled && resp.on_hover_text(tip).clicked() }; - if nav(ui, "chevronLeft", tl!("Previous Page"), page > 0) { + if nav(ui, if rtl { "chevronRight" } else { "chevronLeft" }, tl!("Previous Page"), page > 0) { app.ui.print.preview = page - 1; } ui.label(egui::RichText::new(&label).font(regular(13.0))); - if nav(ui, "chevronRight", tl!("Next Page"), page + 1 < total) { + if nav(ui, if rtl { "chevronLeft" } else { "chevronRight" }, tl!("Next Page"), page + 1 < total) { app.ui.print.preview = page + 1; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { let z = scale / crate::canvas::PX_PER_PT; if nav(ui, "onePage", tl!("One Page"), true) { app.ui.print.zoom = None; } ui.label(egui::RichText::new(format!("{}%", (z * 100.0).round())).font(regular(13.0))); - if nav(ui, "plus", tl!("Zoom In"), z < 5.0) { - app.ui.print.zoom = Some(zoom_step(z, true)); + // The slider grows to the right in every language, as in the status bar: minus at its + // left end, plus at its right. This cluster fills from the far end inwards. + let (inner, outer) = (("minus", "Zoom Out", false), ("plus", "Zoom In", true)); + let (near, far) = if rtl { (inner, outer) } else { (outer, inner) }; + let enabled = |up: bool| if up { z < 5.0 } else { z > 0.1 }; + if nav(ui, near.0, tl!(near.1), enabled(near.2)) { + app.ui.print.zoom = Some(zoom_step(z, near.2)); } // A narrow window shortens the slider, then drops it, before it covers the page indicator. let room = ui.available_width() - 26.0 - 2.0 * ui.spacing().item_spacing.x - 12.0; @@ -340,8 +376,8 @@ fn preview(app: &mut WordApp, ui: &mut Ui, lay: &DocLayout, total: usize) { app.ui.print.zoom = Some(v.clamp(0.1, 5.0)); } } - if nav(ui, "minus", tl!("Zoom Out"), z > 0.1) { - app.ui.print.zoom = Some(zoom_step(z, false)); + if nav(ui, far.0, tl!(far.1), enabled(far.2)) { + app.ui.print.zoom = Some(zoom_step(z, far.2)); } }); }); diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs index 91cf5dac0a..806b25038f 100644 --- a/crates/ui-egui/src/proofing_pane.rs +++ b/crates/ui-egui/src/proofing_pane.rs @@ -373,6 +373,7 @@ mod tests { #[test] #[ignore = "offscreen visual review; requires a software or hardware graphics adapter"] fn spelling_review_dialog_preview() { + let _gpu = crate::table_resize::tests::gpu_turn(); let mut a = app("The quick brwn fox."); a.run("review.spelling", json!({})).unwrap(); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 4cb0ec2c2d..3990b1823a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -130,184 +130,200 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { .exact_size(30.0) .frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - // Share, Editing mode and Comments keep their room; the tabs get what is left of it - // (#897, #2026), and the buttons give up their labels before a tab is drawn over them. - let share = tl!("Share"); - let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") }; - let comments = tl!("Comments"); - let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x); - let spaced = |w: f32| w + 2.0; - let button_widths = |level: usize| -> f32 { - let labels = level == 0; - let share_full = (share_w + 36.0).max(74.0); - let share_button = if level < 2 { share_full } else { 30.0 }; - let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels); - let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels); - // The gaps (6 + 4 + 8) and the strip's margin. - spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0 - }; - let button_levels = [button_widths(0), button_widths(1), button_widths(2)]; - let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect(); - let specs: Vec = tabs - .iter() - .zip(&labels) - .map(|((tab, custom), shown)| TabSpec { - text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x), - file: tab == "File", - active: *tab == app.ui.tab, - contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()), - }) - .collect(); - let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // No document open (#1129): nothing to share, edit or comment on — but only - // these buttons. Disabling the whole line took the tab strip with it: the - // tabs took clicks that did nothing, so the empty window had no way back to - // the Start screen (#3045). - ui.add_enabled_ui(app.has_document(), |ui| { - let (share_size, share_text) = - if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) }; - let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click()); - ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); - let icon_x = if share_text { r.min.x + 14.0 } else { r.center().x }; - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)), - "share", - t.on_accent, - t.on_accent, - ); - if share_text { - ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, share, medium(12.0), t.on_accent); - } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share)); - if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { - let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); - } - ui.add_space(6.0); - let track = app.session.doc.settings.track_changes; - let mode = if plan.buttons == 0 { - crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0)) - } else { - crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true) - }; - egui::Popup::menu(&mode).show(|ui| { - if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": false})); - ui.close(); + crate::rtl::root(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + // Share, Editing mode and Comments keep their room; the tabs get what is left of it + // (#897, #2026), and the buttons give up their labels before a tab is drawn over them. + let share = tl!("Share"); + let editing_label = if app.session.doc.settings.track_changes { tl!("Reviewing") } else { tl!("Editing") }; + let comments = tl!("Comments"); + let share_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(share.to_string(), medium(12.0), t.text).size().x); + let spaced = |w: f32| w + 2.0; + let button_widths = |level: usize| -> f32 { + let labels = level == 0; + let share_full = (share_w + 36.0).max(74.0); + let share_button = if level < 2 { share_full } else { 30.0 }; + let editing = crate::widgets::icon_text_button_width(ui, editing_label, regular(12.0), true, labels); + let comment = crate::widgets::icon_text_button_width(ui, comments, regular(12.0), false, labels); + // The gaps (6 + 4 + 8) and the strip's margin. + spaced(share_button) + spaced(editing) + spaced(comment) + 18.0 + 12.0 + }; + let button_levels = [button_widths(0), button_widths(1), button_widths(2)]; + let labels: Vec<&str> = tabs.iter().map(|(tab, custom)| if *custom { tab.as_str() } else { tl!(tab) }).collect(); + let specs: Vec = tabs + .iter() + .zip(&labels) + .map(|((tab, custom), shown)| TabSpec { + text_w: ui.ctx().fonts_mut(|f| f.layout_no_wrap(shown.to_string(), medium(12.5), t.text).size().x), + file: tab == "File", + active: *tab == app.ui.tab, + contextual: !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab.as_str()), + }) + .collect(); + let plan = plan_strip(&specs, button_levels, ui.available_width(), MORE_TABS_W + 2.0); + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { + // No document open (#1129): nothing to share, edit or comment on — but only + // these buttons. Disabling the whole line took the tab strip with it: the + // tabs took clicks that did nothing, so the empty window had no way back to + // the Start screen (#3045). + ui.add_enabled_ui(app.has_document(), |ui| { + let (share_size, share_text) = + if plan.buttons < 2 { (((share_w + 36.0).max(74.0), 24.0), true) } else { ((30.0, 24.0), false) }; + let (r, resp) = ui.allocate_exact_size(vec2(share_size.0, share_size.1), Sense::click()); + ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent }); + let rtl = crate::rtl::on(); + let icon_x = if share_text { crate::rtl::lead_x(rtl, r, 14.0) } else { r.center().x }; + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(icon_x, r.center().y), vec2(14.0, 14.0)), + "share", + t.on_accent, + t.on_accent, + ); + if share_text { + ui.painter().text( + pos2(crate::rtl::lead_x(rtl, r, 26.0), r.center().y), + crate::rtl::lead_center(rtl), + share, + medium(12.0), + t.on_accent, + ); } - if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { - let _ = app.run("review.trackChanges", json!({"value": true})); - ui.close(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, share)); + if resp.on_hover_text(tl!("Export a copy to share (PDF, Word document)")).clicked() { + let _ = app.run("ui.backstage", json!({"value": true, "page": "export"})); + } + ui.add_space(6.0); + let track = app.session.doc.settings.track_changes; + let mode = if plan.buttons == 0 { + crate::widgets::icon_text_menu_button(ui, "pencil", editing_label, regular(12.0)) + } else { + crate::widgets::icon_only_button(ui, "pencil", editing_label, regular(12.0), true) + }; + crate::rtl::menu(&mode).show(|ui| { + if ui.selectable_label(!track, tl!(tl!("Editing — edit the document directly"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": false})); + ui.close(); + } + if ui.selectable_label(track, tl!(tl!("Reviewing — edits become suggestions"))).clicked() { + let _ = app.run("review.trackChanges", json!({"value": true})); + ui.close(); + } + }); + ui.add_space(4.0); + let comments_button = if plan.buttons == 0 { + crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0)) + } else { + crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false) + }; + if comments_button.clicked() { + let _ = app.run("view.commentsPane", json!({})); } }); - ui.add_space(4.0); - let comments_button = if plan.buttons == 0 { - crate::widgets::icon_text_button(ui, "comment", comments, regular(12.0)) + ui.add_space(8.0); + // The tabs fill the room left of the cluster, from the side reading starts from. + // (By language: this row sits in the far-side cluster's layout, which fills from the other end.) + let tabs_layout = if crate::rtl::on() { + egui::Layout::right_to_left(egui::Align::Center) } else { - crate::widgets::icon_only_button(ui, "comment", comments, regular(12.0), false) + egui::Layout::left_to_right(egui::Align::Center) }; - if comments_button.clicked() { - let _ = app.run("view.commentsPane", json!({})); - } - }); - ui.add_space(8.0); - ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { - // The tabs that don't fit go into a » menu at the end of the strip (#897, #2026). - for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) { - let (tab, custom) = (tab.as_str(), *custom); - let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); - // A custom tab's name is the user's own text: shown as typed. - let shown = if custom { tab } else { tl!(tab) }; - let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0; - let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if app.ui.keytips == crate::keytips::Phase::Tabs { - crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects); - } else if app.ui.keytips == crate::keytips::Phase::Legacy { - // Every tab: they only place the legacy keys' hint (#1697). - crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects); + ui.with_layout(tabs_layout, |ui| { + // The tabs that don't fit go into a » menu at the end of the strip (#897, #2026). + for (i, ((tab, custom), _)) in tabs.iter().zip(&plan.fits).enumerate().filter(|(_, (_, fits))| **fits) { + let (tab, custom) = (tab.as_str(), *custom); + let contextual = !custom && wordcraft_engine::ribbon::CONTEXTUAL_TABS.contains(&tab); + // A custom tab's name is the user's own text: shown as typed. + let shown = if custom { tab } else { tl!(tab) }; + let w = plan.widths.get(i).copied().unwrap_or(0.0) - 2.0; + let cut = specs.get(i).is_some_and(|sp| sp.text_w + plan.pad > w + 0.5); + let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); + if app.ui.keytips == crate::keytips::Phase::Tabs { + crate::keytips::record_tab(r, tab, &app.ui.keytip_prefix, &mut app.keytip_rects); + } else if app.ui.keytips == crate::keytips::Phase::Legacy { + // Every tab: they only place the legacy keys' hint (#1697). + crate::keytips::record_tab(r, tab, "", &mut app.keytip_rects); + } + let active = app.ui.tab == tab && !app.ui.backstage; + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown)); + if resp.hovered() && !active { + ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + } + let color = if contextual || active { t.accent_text } else { t.text }; + if cut { + let mut job = egui::text::LayoutJob::single_section( + shown.to_string(), + egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color), + ); + job.wrap = egui::text::TextWrapping { + max_width: (w - plan.pad).max(1.0), + max_rows: 1, + break_anywhere: true, + overflow_character: Some('…'), + }; + let galley = ui.painter().layout_job(job); + ui.painter().galley(r.center() - galley.size() / 2.0, galley, color); + } else { + ui.painter().text( + r.center(), + Align2::CENTER_CENTER, + shown, + if active { semibold(12.5) } else { medium(12.5) }, + color, + ); + } + let resp = if cut { resp.on_hover_text(shown) } else { resp }; + if active { + let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); + ui.painter().rect_filled(u, 2.0, t.accent); + } + if resp.clicked() { + let _ = app.run("ui.tab", json!({"tab": tab})); + } + if resp.double_clicked() && tab != "File" { + let _ = app.run("ui.collapseRibbon", json!({})); + } + resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); } - let active = app.ui.tab == tab && !app.ui.backstage; - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown)); - if resp.hovered() && !active { - ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); - } - let color = if contextual || active { t.accent_text } else { t.text }; - if cut { - let mut job = egui::text::LayoutJob::single_section( - shown.to_string(), - egui::text::TextFormat::simple(if active { semibold(12.5) } else { medium(12.5) }, color), - ); - job.wrap = egui::text::TextWrapping { - max_width: (w - plan.pad).max(1.0), - max_rows: 1, - break_anywhere: true, - overflow_character: Some('…'), - }; - let galley = ui.painter().layout_job(job); - ui.painter().galley(r.center() - galley.size() / 2.0, galley, color); - } else { + let left_out: Vec<(&str, &str)> = tabs + .iter() + .zip(&labels) + .zip(&plan.fits) + .filter(|(_, fits)| !**fits) + .map(|(((tab, _), shown), _)| (tab.as_str(), *shown)) + .collect(); + if !left_out.is_empty() { + let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); + } + // The active tab is in the menu: the button says so. + let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab); ui.painter().text( r.center(), Align2::CENTER_CENTER, - shown, - if active { semibold(12.5) } else { medium(12.5) }, - color, + "»", + medium(12.5), + if holds_active { t.accent_text } else { t.text }, ); - } - let resp = if cut { resp.on_hover_text(shown) } else { resp }; - if active { - let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0)); - ui.painter().rect_filled(u, 2.0, t.accent); - } - if resp.clicked() { - let _ = app.run("ui.tab", json!({"tab": tab})); - } - if resp.double_clicked() && tab != "File" { - let _ = app.run("ui.collapseRibbon", json!({})); - } - resp.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); - } - let left_out: Vec<(&str, &str)> = tabs - .iter() - .zip(&labels) - .zip(&plan.fits) - .filter(|(_, fits)| !**fits) - .map(|(((tab, _), shown), _)| (tab.as_str(), *shown)) - .collect(); - if !left_out.is_empty() { - let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click()); - if resp.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover); - } - // The active tab is in the menu: the button says so. - let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab); - ui.painter().text( - r.center(), - Align2::CENTER_CENTER, - "»", - medium(12.5), - if holds_active { t.accent_text } else { t.text }, - ); - // "»" means nothing read aloud: the button says what it holds. - let more = tl!("More tabs"); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more)); - let resp = resp.on_hover_text(more); - egui::Popup::menu(&resp).show(|ui| { - for (tab, shown) in left_out { - if ui.selectable_label(app.ui.tab == tab, shown).clicked() { - let _ = app.run("ui.tab", json!({"tab": tab})); - ui.close(); + // "»" means nothing read aloud: the button says what it holds. + let more = tl!("More tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, more)); + let resp = resp.on_hover_text(more); + crate::rtl::menu(&resp).show(|ui| { + for (tab, shown) in left_out { + if ui.selectable_label(app.ui.tab == tab, shown).clicked() { + let _ = app.run("ui.tab", json!({"tab": tab})); + ui.close(); + } } - } - }); - } + }); + } + }); }); }); - }); + }) }); if app.ui.ribbon_collapsed { return; @@ -325,19 +341,32 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { ui.disable(); } let bg = ui.interact(ui.max_rect(), ui.scope_id().with("ribbon_background"), Sense::click()); - bg.context_menu(|ui| crate::widgets::customize_menu_items(ui, app)); + crate::rtl::context_menu(&bg, |ui| crate::rtl::root(ui, |ui| crate::widgets::customize_menu_items(ui, app))); let entry = user_tabs.iter().find(|e| e.name == app.ui.tab); crate::ribbon_fit::begin(ui.ctx(), &app.ui.tab, ui.available_width() - 4.0); // 0.37 keeps the scroll offset private: an edge-chevron click leaves a one-frame // request here and the next scroll area is built at that offset. let pending_id = ui.scope_id().with("ribbon_scroll_pending"); let pending = ui.data(|d| d.get_temp::(pending_id)); + // Right to left, the groups start at the right edge. A scroll area's content is + // unbounded, so the row gets a width: the view's, or last frame's content if wider. + // The scroll position is kept as a distance from the right, where reading starts. + let rtl = crate::rtl::on(); + let view_w = ui.available_width(); + let row_id = ui.scope_id().with(("ribbon_row", app.ui.tab.as_str())); + let (content_w, from_right, last_over) = ui.data(|d| d.get_temp::<(f32, f32, f32)>(row_id)).unwrap_or((0.0, 0.0, 0.0)); + let row_w = content_w.max(view_w); + let set_offset = (last_over - from_right).max(0.0); let mut area = egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); - if let Some(x) = pending { + if rtl { + area = area.horizontal_scroll_offset(set_offset); + } else if let Some(x) = pending { area = area.horizontal_scroll_offset(x); } + let mut measured = 0.0; let scroll = area.show(ui, |ui| { - ui.horizontal_top(|ui| { + let size = vec2(if rtl { row_w } else { ui.available_size_before_wrap().x }, ui.available_size_before_wrap().y); + let body = |ui: &mut Ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); match entry { // Every tab hidden: nothing to show. @@ -371,8 +400,24 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if let Some(e) = entry { custom_groups(app, ui, &e.groups); } - }); + }; + if rtl { + measured = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Min), body).response.rect.width(); + } else { + crate::rtl::horizontal_top(ui, body); + } }); + if rtl { + let over = (scroll.content_size.x - scroll.inner_rect.width()).max(0.0); + // Only the user's own scrolling moves the remembered distance: while the width + // is still settling, the offset set above lags a frame behind. + let scrolled = (scroll.state.clamped_offset().x - set_offset).abs() > 0.5 && set_offset <= over; + let from_right = if scrolled { (over - scroll.state.clamped_offset().x).max(0.0) } else { from_right.min(over) }; + ui.data_mut(|d| d.insert_temp(row_id, (measured, from_right, over))); + if (over - last_over).abs() > 0.5 { + ui.ctx().request_repaint(); + } + } if pending.is_some() { let _ = ui.data_mut(|d| d.remove_temp::(pending_id)); } @@ -397,7 +442,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } if let Some(x) = next { - ui.data_mut(|d| d.insert_temp(pending_id, x.clamp(0.0, overflow.max(0.0)))); + let x = x.clamp(0.0, overflow.max(0.0)); + if rtl { + ui.data_mut(|d| d.insert_temp(row_id, (measured, (overflow - x).max(0.0), overflow.max(0.0)))); + } + ui.data_mut(|d| d.insert_temp(pending_id, x)); ui.ctx().request_repaint(); } }); diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs index df446d10e9..abade17c9d 100644 --- a/crates/ui-egui/src/ribbon/arrange.rs +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -55,14 +55,16 @@ pub(super) fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { } ui.separator(); ui.menu_button(tl!("Wrap Text Sides"), |ui| { - for (l, s, v) in [ - ("Both Sides", WrapText::BothSides, "bothSides"), - ("Left Only", WrapText::Left, "left"), - ("Right Only", WrapText::Right, "right"), - ("Largest Only", WrapText::Largest, "largest"), - ] { - mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v})); - } + crate::rtl::root(ui, |ui| { + for (l, s, v) in [ + ("Both Sides", WrapText::BothSides, "bothSides"), + ("Left Only", WrapText::Left, "left"), + ("Right Only", WrapText::Right, "right"), + ("Largest Only", WrapText::Largest, "largest"), + ] { + mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v})); + } + }) }); }); } diff --git a/crates/ui-egui/src/ribbon/charts.rs b/crates/ui-egui/src/ribbon/charts.rs index 82324300fb..9a668b6bb4 100644 --- a/crates/ui-egui/src/ribbon/charts.rs +++ b/crates/ui-egui/src/ribbon/charts.rs @@ -28,7 +28,7 @@ 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()); - egui::Grid::new("smart_art_layout_gallery").spacing(vec2(2.0, 2.0)).show(ui, |ui| { + 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; diff --git a/crates/ui-egui/src/ribbon/common.rs b/crates/ui-egui/src/ribbon/common.rs index 244967d8f5..b9b006b9dd 100644 --- a/crates/ui-egui/src/ribbon/common.rs +++ b/crates/ui-egui/src/ribbon/common.rs @@ -66,7 +66,7 @@ pub(super) fn esc_cancelled(ui: &Ui, r: &egui::Response, id: egui::Id) -> bool { } pub(super) fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { - ui.vertical(|ui| { + crate::rtl::vertical(ui, |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 0.0); add(ui); }); @@ -110,7 +110,7 @@ pub(super) fn mi_icon(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, i pub(super) fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) { let sc = crate::widgets::shortcut_text(app, id); let enabled = crate::widgets::enabled(app, id); - let resp = ui.add_enabled(enabled, egui::Button::new(text).shortcut_text(sc).min_size(vec2(200.0, 0.0))); + let resp = ui.add_enabled(enabled, crate::rtl::menu_item(ui, text, &sc, 200.0)); if resp.clicked() { crate::widgets::run_from_ribbon(app, id, params); ui.close(); diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs index 8b89c2eb0f..e0c3d06b8b 100644 --- a/crates/ui-egui/src/ribbon/draw.rs +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -116,7 +116,7 @@ fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine:: if resp.clicked() { let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width})); } - resp.context_menu(|ui| { + crate::rtl::context_menu(&resp, |ui| { if ui.button(tl!("Remove Pen")).clicked() { let _ = app.run("draw.removePen", json!({"index": i})); ui.close(); diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index d4e62f2be3..f858f5b696 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -68,9 +68,11 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { mi_check(ui, app, "Draw Table", drawing, "table.draw", json!({})); mi(ui, app, "Convert Text to Table…", "table.fromText", json!({})); ui.menu_button(tl!("Quick Tables"), |ui| { - mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); - mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"})); - mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"})); + crate::rtl::root(ui, |ui| { + mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"})); + mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"})); + mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"})); + }) }); }); }); @@ -205,7 +207,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) { } } items.truncate(30); - egui::Grid::new("syms").show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("syms"), |ui| { for (i, (c, font)) in items.iter().enumerate() { let b = ui.button(egui::RichText::new(c).size(16.0)); let b = if font.is_empty() { b } else { b.on_hover_text(font) }; diff --git a/crates/ui-egui/src/ribbon/layout.rs b/crates/ui-egui/src/ribbon/layout.rs index 0d4318235d..b1200faca4 100644 --- a/crates/ui-egui/src/ribbon/layout.rs +++ b/crates/ui-egui/src/ribbon/layout.rs @@ -136,7 +136,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) { ui.label(text); }); }; - egui::Grid::new("layout_para").spacing(vec2(4.0, 1.5)).min_row_height(FIELD.y).show(ui, |ui| { + 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()); diff --git a/crates/ui-egui/src/ribbon/shape.rs b/crates/ui-egui/src/ribbon/shape.rs index aa30f0cb6b..e8a265f6da 100644 --- a/crates/ui-egui/src/ribbon/shape.rs +++ b/crates/ui-egui/src/ribbon/shape.rs @@ -21,7 +21,7 @@ pub(crate) fn gradient_menu(ui: &mut Ui, app: &mut WordApp, fill: Option<&wordcr } for (title, tiles) in ["Light Variations", "Dark Variations"].into_iter().zip(shape_fill::variations(main)) { ui.label(egui::RichText::new(tl!(title)).small().weak()); - egui::Grid::new(title).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new(title).spacing(vec2(4.0, 4.0)), |ui| { for (i, (f, name)) in tiles.iter().enumerate() { if crate::format_shape::swatch(ui, f, name) { let _ = app.run("shape.fill", shape_fill::params(Some(f))); diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs index d902460070..afae6cf104 100644 --- a/crates/ui-egui/src/ribbon/table.rs +++ b/crates/ui-egui/src/ribbon/table.rs @@ -5,7 +5,7 @@ pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) { let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look)); group(ui, "Table Style Options", None, app, |ui, app| { let Some(l) = look else { return }; - egui::Grid::new("look").show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("look"), |ui| { for (row, items) in [ [("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], @@ -37,14 +37,16 @@ pub(super) fn table_design(app: &mut WordApp, ui: &mut Ui) { .map(|s| (s.builtin, s.id.clone(), crate::i18n::style_name(s))) .collect(); styles.sort_by_key(|(builtin, _, _)| *builtin); - egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { - ui.horizontal(|ui| { - for (_, id, name) in styles { - if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { - let _ = app.run("table.style", json!({"style": id})); + crate::rtl::island(ui, |ui| { + egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| { + ui.horizontal(|ui| { + for (_, id, name) in styles { + if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() { + let _ = app.run("table.style", json!({"style": id})); + } } - } - }); + }); + }) }); stack(ui, |ui| { let current = look.and_then(|_| { @@ -287,7 +289,7 @@ pub(super) fn table_layout(app: &mut WordApp, ui: &mut Ui) { }); }); group(ui, "Alignment", None, app, |ui, app| { - egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| { + crate::rtl::grid(ui, egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)), |ui| { for row in [ [("topLeft", "Align Top Left"), ("topCenter", "Align Top Center"), ("topRight", "Align Top Right")], [("centerLeft", "Align Center Left"), ("center", "Align Center"), ("centerRight", "Align Center Right")], diff --git a/crates/ui-egui/src/ribbon/tabs.rs b/crates/ui-egui/src/ribbon/tabs.rs index 2763c2e4aa..627c8c5889 100644 --- a/crates/ui-egui/src/ribbon/tabs.rs +++ b/crates/ui-egui/src/ribbon/tabs.rs @@ -21,77 +21,82 @@ pub(super) fn reading_toolbar(app: &mut WordApp, ui: &mut Ui, t: Tokens) { .exact_size(46.0) .frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(12, 6)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { - ui.horizontal_centered(|ui| { - ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0))); - ui.separator(); - let w = app.session.view.read_column_width; - let active = match w { - None => "Auto", - Some(v) if v <= 180.0 => "Narrow", - Some(v) if v <= 240.0 => "Medium", - Some(v) if v <= 350.0 => "Wide", - _ => "Page", - }; - ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| { - for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] { - if ui.selectable_label(active == name, tl!(name)).clicked() { - let _ = app.run("view.readColumnWidth", json!({"value": value})); + crate::rtl::root(ui, |ui| { + ui.horizontal_centered(|ui| { + ui.label(egui::RichText::new(tl!("Reading Mode")).font(semibold(14.0))); + ui.separator(); + let w = app.session.view.read_column_width; + let active = match w { + None => "Auto", + Some(v) if v <= 180.0 => "Narrow", + Some(v) if v <= 240.0 => "Medium", + Some(v) if v <= 350.0 => "Wide", + _ => "Page", + }; + ui.menu_button(format!("{}: {}", tl!("Column width"), tl!(active)), |ui| { + for (name, value) in [("Auto", "auto"), ("Narrow", "narrow"), ("Medium", "medium"), ("Wide", "wide"), ("Page", "page")] { + if ui.selectable_label(active == name, tl!(name)).clicked() { + let _ = app.run("view.readColumnWidth", json!({"value": value})); + ui.close(); + } + } + }); + ui.separator(); + let pct = (app.session.view.read_zoom * 100.0).round() as u32; + ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| { + for percent in [75, 90, 100, 110, 125, 150] { + if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() { + let _ = app.run("view.readTextSize", json!({"value": percent})); + ui.close(); + } + } + }); + ui.separator(); + let spacing = app.session.view.read_line_spacing; + let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×")); + ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| { + if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": "document"})); ui.close(); } - } - }); - ui.separator(); - let pct = (app.session.view.read_zoom * 100.0).round() as u32; - ui.menu_button(format!("{}: {pct}%", tl!("Text size")), |ui| { - for percent in [75, 90, 100, 110, 125, 150] { - if ui.selectable_label(pct == percent, format!("{percent}%")).clicked() { - let _ = app.run("view.readTextSize", json!({"value": percent})); - ui.close(); + for value in [1.0, 1.25, 1.5, 2.0] { + if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() { + let _ = app.run("view.readLineSpacing", json!({"value": value})); + ui.close(); + } } - } - }); - ui.separator(); - let spacing = app.session.view.read_line_spacing; - let label = spacing.map_or_else(|| tl!("Document").to_owned(), |v| format!("{v}×")); - ui.menu_button(format!("{}: {}", tl!("Line spacing"), label), |ui| { - if ui.selectable_label(spacing.is_none(), tl!("Document")).clicked() { - let _ = app.run("view.readLineSpacing", json!({"value": "document"})); - ui.close(); - } - for value in [1.0, 1.25, 1.5, 2.0] { - if ui.selectable_label(spacing == Some(value), format!("{value}×")).clicked() { - let _ = app.run("view.readLineSpacing", json!({"value": value})); - ui.close(); + }); + ui.separator(); + use wordcraft_engine::cmd::view::ReadPageColor; + let colors = [ + (ReadPageColor::None, "None", "none"), + (ReadPageColor::Sepia, "Sepia", "sepia"), + (ReadPageColor::Inverse, "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| { + for (color, name, value) in colors { + if ui.selectable_label(current == color, tl!(name)).clicked() { + let _ = app.run("view.readPageColor", json!({"value": value})); + ui.close(); + } } + }); + ui.separator(); + // Headings to jump to, in the Navigation pane beside the columns (Outline view + // is an editing layout, so it isn't offered here). + if ui.button(tl!("Navigation Pane")).clicked() { + let _ = app.run("view.navigationPane", json!({"value": true})); + app.ui.nav_tab = "headings".into(); } - }); - ui.separator(); - use wordcraft_engine::cmd::view::ReadPageColor; - let colors = - [(ReadPageColor::None, "None", "none"), (ReadPageColor::Sepia, "Sepia", "sepia"), (ReadPageColor::Inverse, "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| { - for (color, name, value) in colors { - if ui.selectable_label(current == color, tl!(name)).clicked() { - let _ = app.run("view.readPageColor", json!({"value": value})); - ui.close(); + ui.with_layout(crate::rtl::far_side(ui, egui::Align::Center), |ui| { + if ui.button(tl!("Edit Document")).clicked() { + let _ = app.run("view.printLayout", json!({})); } - } + }); }); - ui.separator(); - // Headings to jump to, in the Navigation pane beside the columns (Outline view - // is an editing layout, so it isn't offered here). - if ui.button(tl!("Navigation Pane")).clicked() { - let _ = app.run("view.navigationPane", json!({"value": true})); - app.ui.nav_tab = "headings".into(); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(tl!("Edit Document")).clicked() { - let _ = app.run("view.printLayout", json!({})); - } - }); - }); + }) }); } diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs new file mode 100644 index 0000000000..f8b87ac3dc --- /dev/null +++ b/crates/ui-egui/src/rtl.rs @@ -0,0 +1,350 @@ +//! Mirrored window layout for a right-to-left interface language (Arabic). +//! +//! egui has no global direction, but `ui.horizontal` runs right to left wherever the enclosing +//! layout prefers it (`Layout::prefer_right_to_left`): a right-to-left row, or a column whose +//! items are aligned to the right. So direction is inherited from a root: each panel, window and +//! menu body is wrapped in [`root`], columns are opened with [`vertical`] (egui's `ui.vertical` +//! always resets to left-aligned), and "far side" clusters use [`far_side`]. Widgets that paint +//! their own parts ask [`flows`] where their leading edge is. +//! +//! An area that egui lays out left to right on its own (a `Grid`, a far-side cluster) stays left +//! to right inside, because everything here follows the enclosing layout rather than the language. +//! The document itself is never mirrored: its direction belongs to the document. + +use egui::{Align, Align2, InnerResponse, Layout, Rect, Ui, UiBuilder, pos2}; + +use craft_ui::direction::{self, Direction}; + +fn dir(rtl: bool) -> Direction { + if rtl { Direction::Rtl } else { Direction::Ltr } +} + +/// Install the interface direction on `ctx` when the language's has changed: the shared +/// `craft_ui::direction` state, and egui's own chrome (check boxes and radio buttons with the mark +/// at the reading start, submenus, scroll bars, title bars, the font combo) through +/// `Style::right_to_left`. +pub fn sync(ctx: &egui::Context) { + let want = dir(on()); + if direction::HasDirection::direction(ctx) != want { + direction::apply(ctx, want); + } +} + +/// Is the interface language written right to left? +pub fn on() -> bool { + crate::i18n::current().rtl() +} + +/// Is `ui` part of the mirrored interface: a right-to-left language, in an area that lays its +/// content out from the right (so rows opened in it run right to left)? Never in a left-to-right +/// language, whose far-side clusters also fill from the right but are not mirrored. +pub fn flows(ui: &Ui) -> bool { + on() && ui.layout().prefer_right_to_left() +} + +/// Run a container's body (a panel, window, popup or menu, which egui starts left-aligned) in the +/// interface's direction. In a left-to-right language this is just `add`. +pub fn root(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + if !on() || flows(ui) { + return add(ui); + } + ui.scope_builder(UiBuilder::new().layout(Layout::top_down(Align::Max)), add).inner +} + +/// `ui.vertical`, aligned to the side rows start from: a column inside a right-to-left area keeps +/// its content on the right and its rows right to left. +pub fn vertical(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + if flows(ui) { ui.scope_builder(UiBuilder::new().layout(Layout::top_down(Align::Max)), add) } else { ui.vertical(add) } +} + +/// `ui.horizontal_top`, with its items running from the side reading starts from: a row opened +/// in a right-to-left area is a right-to-left layout of its own, so its first column sits at the +/// right edge. +pub fn horizontal_top(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + if flows(ui) { ui.scope_builder(UiBuilder::new().layout(Layout::right_to_left(Align::Min)), add) } else { ui.horizontal_top(add) } +} + +/// 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 { + if flows(ui) { Layout::left_to_right(align) } else { Layout::right_to_left(align) } +} + +/// A row layout in the direction of `ui`, for `allocate_ui_with_layout` and `with_layout`. +pub fn row_layout(ui: &Ui, align: Align) -> Layout { + if flows(ui) { Layout::right_to_left(align) } else { Layout::left_to_right(align) } +} + +/// The x coordinate `inset` points in from the leading edge of `rect` (left, or right when `rtl`). +pub fn lead_x(rtl: bool, rect: Rect, inset: f32) -> f32 { + let d = dir(rtl); + direction::start_x(&d, rect) + direction::forward(&d) * inset +} + +/// The x coordinate `inset` points in from the trailing edge of `rect`. +pub fn trail_x(rtl: bool, rect: Rect, inset: f32) -> f32 { + lead_x(!rtl, rect, inset) +} + +/// Text anchored at its leading edge and vertically centred: left-centre, or right-centre when `rtl`. +pub fn lead_center(rtl: bool) -> Align2 { + direction::align2(&dir(rtl), Align2::LEFT_CENTER) +} + +/// Text anchored at its leading edge and top. +pub fn lead_top(rtl: bool) -> Align2 { + direction::align2(&dir(rtl), Align2::LEFT_TOP) +} + +/// Where to put something `width` wide so that it starts `inset` in from the leading edge of +/// `rect`: its left x. +pub fn lead_left(rtl: bool, rect: Rect, inset: f32, width: f32) -> f32 { + if rtl { rect.max.x - inset - width } else { rect.min.x + inset } +} + +/// The leading top corner of `rect`. +pub fn lead_corner(rtl: bool, rect: Rect) -> egui::Pos2 { + pos2(lead_x(rtl, rect, 0.0), rect.min.y) +} + +/// A side panel on the side reading starts from (left; right in a right-to-left interface). +pub fn start_panel(id: &'static str) -> egui::Panel { + direction::start_panel(&dir(on()), id) +} + +/// A side panel on the side reading ends on (right; left in a right-to-left interface). +pub fn end_panel(id: &'static str) -> egui::Panel { + direction::end_panel(&dir(on()), id) +} + +/// `egui::Popup::menu`, with its items aligned to the side reading starts from. +pub fn menu(response: &egui::Response) -> egui::Popup<'_> { + let popup = egui::Popup::menu(response); + if on() { popup.layout(menu_layout()) } else { popup } +} + +/// A right-to-left menu's layout: items against the right edge. Not justified, because egui +/// starts a full-width button's text at its left. +fn menu_layout() -> Layout { + Layout::top_down(Align::Max) +} + +/// A menu item: `text`, with its keyboard shortcut at the far side. `min_width` keeps a menu's +/// items one width. +pub fn menu_item<'a>(ui: &Ui, text: &'a str, shortcut: &'a str, min_width: f32) -> egui::Button<'a> { + let button = if flows(ui) { + egui::Button::new(egui::RichText::new(shortcut).weak()).right_text(text) + } else { + egui::Button::new(text).shortcut_text(shortcut) + }; + button.min_size(egui::vec2(min_width, 0.0)) +} + +/// `Response::context_menu`, with its items aligned to the side reading starts from. +pub fn context_menu(response: &egui::Response, add: impl FnOnce(&mut Ui)) { + let popup = egui::Popup::context_menu(response); + let popup = if on() { popup.layout(menu_layout()) } else { popup }; + popup.show(add); +} + +/// Run `add` in a left-to-right island inside a right-to-left area, for content egui can only +/// lay out from the left (a `Grid`, a horizontally scrolling strip). The island is set against the +/// leading (right) edge using the width it had last frame. Outside a right-to-left area this is +/// just `add`. +pub fn island(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + if !flows(ui) { + return add(ui); + } + // The island gets all the room there is, so its content takes its natural width; it starts + // where content of last frame's width would end at the leading edge. A change of width shows + // one frame late, so that frame asks for another. + let key = ui.next_auto_id().with("rtl_island_width"); + let known = ui.data(|d| d.get_temp::(key)); + let room = ui.available_rect_before_wrap(); + let available = if room.width().is_finite() { room.width().max(0.0) } else { 0.0 }; + let width = known.unwrap_or(available); + let left = room.max.x - width; + if !left.is_finite() { + return add(ui); + } + let max_rect = Rect::from_min_max(pos2(left, room.min.y), pos2(left + available.max(width), room.max.y)); + let shown = ui.scope_builder(UiBuilder::new().max_rect(max_rect).layout(Layout::top_down(Align::Min)), add); + let now = shown.response.rect.width(); + if now.is_finite() && known.is_none_or(|w| (w - now).abs() > 0.5) { + ui.data_mut(|d| d.insert_temp(key, now)); + ui.ctx().request_repaint(); + } + shown.inner +} + +/// `grid.show(ui, add)`. egui's `Grid` cannot be laid out from the right (it places cells from a +/// left edge that a right-to-left parent does not have, and says so with a debug assertion), so +/// inside a right-to-left area it is drawn in an [`island`]. Its columns keep their order. +pub fn grid(ui: &mut Ui, grid: egui::Grid, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + island(ui, |ui| grid.show(ui, add)) +} + +/// A form of rows and cells. Where the interface is mirrored (`flows`), a row's cells run from the +/// right edge, the first cell at the right, and every column is as wide as its widest cell, so the +/// labels sit at the reading start with their fields after them, to their left. Elsewhere it is +/// exactly an `egui::Grid` with the same id, columns and spacing. A `Grid` itself cannot be laid +/// out from the right, which is why [`grid`] keeps its columns in order. +pub struct Table { + id: &'static str, + columns: usize, + spacing: Option, +} + +impl Table { + pub fn new(id: &'static str) -> Self { + Self { id, columns: 2, spacing: None } + } + + pub fn columns(mut self, columns: usize) -> Self { + self.columns = columns; + self + } + + pub fn spacing(mut self, spacing: egui::Vec2) -> Self { + self.spacing = Some(spacing); + 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 = match self.spacing { + Some(spacing) => spec.spacing(spacing), + None => spec, + }; + return grid(ui, spec, |ui| add(&mut Rows { ui, mirror: None })).inner; + } + let key = ui.scope_id().with(("rtl_table_widths", self.id)); + 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| { + ui.spacing_mut().item_spacing.y = spacing.y; + let mut rows = Rows { ui, mirror: Some(Mirror { prev: prev.clone(), cur: Vec::new(), gap }) }; + let out = add(&mut rows); + (out, rows.mirror.map(|m| m.cur).unwrap_or_default()) + }); + let (out, cur) = shown.inner; + let same = prev.len() == cur.len() && prev.iter().zip(&cur).all(|(a, b)| (a - b).abs() <= 0.5); + if !same { + ui.data_mut(|d| d.insert_temp(key, cur)); + ui.ctx().request_repaint(); + } + out + } +} + +struct Mirror { + /// The width each column had last frame. + prev: Vec, + /// The width each column has now (the widest of its cells). + cur: Vec, + gap: f32, +} + +/// The rows of a [`Table`]. +pub struct Rows<'a> { + ui: &'a mut Ui, + mirror: Option, +} + +impl Rows<'_> { + /// One row: add its cells in reading order with [`Cells::cell`]. + pub fn row(&mut self, add: impl FnOnce(&mut Cells) -> R) -> R { + match &mut self.mirror { + None => { + let out = add(&mut Cells { ui: self.ui, mirror: None, col: 0, pending: 0.0 }); + self.ui.end_row(); + 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 + } + } + } +} + +/// The cells of one row of a [`Table`]. +pub struct Cells<'a> { + ui: &'a mut Ui, + mirror: Option<&'a mut Mirror>, + col: usize, + /// The space still to leave before the next cell: what its column is wider than the last cell. + pending: f32, +} + +impl Cells<'_> { + /// The next cell of the row (its widgets, in the order they read). + pub fn cell(&mut self, add: impl FnOnce(&mut Ui) -> R) -> R { + let Some(mirror) = self.mirror.as_deref_mut() else { + return add(self.ui); + }; + if self.pending > 0.0 { + self.ui.add_space(self.pending); + } + let shown = self.ui.scope(add); + let width = shown.response.rect.width(); + let width = if width.is_finite() { width.max(0.0) } else { 0.0 }; + if mirror.cur.len() <= self.col { + mirror.cur.resize(self.col + 1, 0.0); + } + if let Some(w) = mirror.cur.get_mut(self.col) { + *w = w.max(width); + } + let column = mirror.prev.get(self.col).copied().unwrap_or(0.0); + self.pending = (column - width).max(0.0) + mirror.gap; + self.col += 1; + shown.inner + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{Lang, fixture_lang, set_current}; + + /// Where a two-cell row puts its label and its field, as the centres of their rectangles. + fn row_centres() -> (f32, f32) { + let ctx = egui::Context::default(); + let mut centres = (0.0, 0.0); + // The second frame has the column widths the first measured. + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + root(ui, |ui| { + Table::new("t").columns(2).show(ui, |t| { + t.row(|r| { + let label = r.cell(|ui| ui.label("Label:")); + let field = r.cell(|ui| ui.add(egui::TextEdit::singleline(&mut String::new()).desired_width(80.0))); + centres = (label.rect.center().x, field.rect.center().x); + }); + }); + }); + }); + out.textures_delta.clear(); + } + centres + } + + /// A form reads from the reading start: the label of a row first, its field after it, so in a + /// right-to-left interface the label is at the right and the field to its left. + #[test] + fn a_form_row_puts_the_label_at_the_reading_start() { + set_current(Lang::EN); + let (label, field) = row_centres(); + assert!(label < field, "left to right: label {label}, field {field}"); + set_current(fixture_lang("ar", "\tHome\tالرئيسية\n")); + let (label, field) = row_centres(); + set_current(Lang::EN); + assert!(label > field, "right to left: label {label}, field {field}"); + } +} diff --git a/crates/ui-egui/src/table_resize/tests.rs b/crates/ui-egui/src/table_resize/tests.rs index c4300ee99a..0bb4895cad 100644 --- a/crates/ui-egui/src/table_resize/tests.rs +++ b/crates/ui-egui/src/table_resize/tests.rs @@ -65,6 +65,14 @@ pub(crate) fn steps(h: &mut H, n: usize) { } } +/// Held by a test while its harness renders through wgpu: two devices drawing at once crash +/// Windows' software rasterizer (WARP, `d3d10warp.dll`), which is what a machine without a GPU +/// or a remote session gets, so those tests take turns. +pub(crate) fn gpu_turn() -> std::sync::MutexGuard<'static, ()> { + static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + GPU.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// A real mouse drag of a column border: the resize arrow on hover, a preview and no change /// while dragging, then one undo step on release, the selection untouched. #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 562d12c53a..ee9347c790 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -485,7 +485,22 @@ pub fn install_fonts_language_extra( system_cjk: bool, faces: &[Arc], ) { - let mut fonts = font_definitions_language(prefer_hans, prefer_korean, prefer_hant, system_cjk); + install_fonts_language_arabic(ctx, prefer_hans, prefer_korean, prefer_hant, system_cjk, false, faces); +} + +/// [`install_fonts_language_extra`], also adding an installed Arabic font when `system_arabic` is +/// set and no embedded face has Arabic letters. Asked for by an Arabic interface only, apart from +/// the installed CJK font: reading either is a font file read the other languages never need. +pub fn install_fonts_language_arabic( + ctx: &egui::Context, + prefer_hans: bool, + prefer_korean: bool, + prefer_hant: bool, + system_cjk: bool, + system_arabic: bool, + faces: &[Arc], +) { + let mut fonts = font_definitions_language_arabic(prefer_hans, prefer_korean, prefer_hant, system_cjk, system_arabic); add_fallback_faces(&mut fonts, faces); ctx.set_fonts(fonts); } @@ -541,6 +556,18 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions /// The interface fonts, ordered for the preferred CJK writing system. pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, prefer_hant: bool, system_cjk: bool) -> FontDefinitions { + font_definitions_language_arabic(prefer_hans, prefer_korean, prefer_hant, system_cjk, false) +} + +/// [`font_definitions_language`], also with an installed Arabic font when `system_arabic` is set +/// and the build embeds none. +pub fn font_definitions_language_arabic( + prefer_hans: bool, + prefer_korean: bool, + prefer_hant: bool, + system_cjk: bool, + system_arabic: bool, +) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| { fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes))); @@ -598,10 +625,33 @@ pub fn font_definitions_language(prefer_hans: bool, prefer_korean: bool, prefer_ } #[cfg(target_arch = "wasm32")] let _ = system_cjk; + // Arabic-script faces last: the Arabic interface, its name in the language list, Arabic file + // names. Without an embedded one (a build without craft-fonts), an installed font, read when + // `system_arabic` is set: for an Arabic interface. + let arabic = wordcraft_fonts::ui_arabic_fonts(); + for f in &arabic { + add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes)); + } + #[cfg(not(target_arch = "wasm32"))] + if system_arabic + && arabic.is_empty() + && let Some(f) = system_arabic_font() + { + let mut data = FontData::from_static(&f.bytes); + data.index = f.index; + add_cjk(&mut fonts, format!("system {}", f.family), data); + } // Symbols and emoji fall back to egui's defaults (kept in the families). fonts } +/// The installed Arabic interface font, read once. +#[cfg(not(target_arch = "wasm32"))] +fn system_arabic_font() -> Option<&'static wordcraft_fonts::SystemUiFont> { + static ARABIC: std::sync::OnceLock> = std::sync::OnceLock::new(); + ARABIC.get_or_init(wordcraft_fonts::system_arabic_ui_font).as_ref() +} + /// The installed CJK interface font for a Chinese (`hans`) or other interface, chosen once: /// `None` when the embedded faces already cover the interface text /// ([`cjk_font_text`](crate::i18n::Lang::cjk_font_text)). The font is picked for the catalogs' @@ -947,6 +997,20 @@ mod tests { /// explicitly (kittest's own dev-dependencies do not propagate). WordCraft inserts Inter at /// the front and SourceSans after egui's complete default fallback block, including the /// monochrome emoji faces. Production keeps only `default_fonts` through eframe. + /// An installed Arabic font is read for an Arabic interface only (`system_arabic`), and the + /// installed CJK font request alone never brings it in: neither read is paid by the other. + #[test] + fn the_installed_arabic_font_is_asked_for_by_arabic_alone() { + let plain = super::font_definitions_language_arabic(false, false, false, false, false).font_data.len(); + let cjk_only = super::font_definitions_language_arabic(false, false, false, true, false); + let arabic = super::font_definitions_language_arabic(false, false, false, false, true).font_data.len(); + let installed_arabic = wordcraft_fonts::ui_arabic_fonts().is_empty() && wordcraft_fonts::system_arabic_ui_font().is_some(); + assert_eq!(arabic, plain + usize::from(installed_arabic), "Arabic adds the installed font when nothing is embedded"); + let arabic_named = |defs: &egui::FontDefinitions| defs.font_data.keys().filter(|k| k.starts_with("system ")).count(); + let cjk_font = super::system_cjk_font(false, false).is_some(); + assert_eq!(arabic_named(&cjk_only), usize::from(cjk_font), "the CJK request adds the CJK font and no Arabic one"); + } + #[test] fn interface_font_order_keeps_test_default_fonts_explicit() { let defs = super::font_definitions(false, false); diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/updates_ui.rs index c7cd6348ac..ebd03406f6 100644 --- a/crates/ui-egui/src/updates_ui.rs +++ b/crates/ui-egui/src/updates_ui.rs @@ -122,9 +122,11 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) { .collapsible(false) .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) .show(ctx, |ui| { - if let Some(msg) = &app.updates_rt.status_message { - ui.label(msg); - } + crate::rtl::root(ui, |ui| { + if let Some(msg) = &app.updates_rt.status_message { + ui.label(msg); + } + }) }); if !open { app.updates_rt.manual_window_open = false; diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index 1cec74ea42..299da8897d 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -1,6 +1,6 @@ use super::groups::command_menu; use super::tooltip::{keytip_badge, shortcut_text, show_hint, tooltip, tooltip_lines}; -use super::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Value, WordApp, icons, pos2, regular, vec2}; +use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Value, WordApp, icons, pos2, regular, vec2}; /// Height of the ribbon content area (without group labels). pub const CONTENT_H: f32 = 66.0; @@ -147,11 +147,13 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti keytip_badge(ui, r, id, tip); let on = enabled(app, id); bg(ui, r, &resp, checked, &t); - let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)); + let rtl = crate::rtl::flows(ui); + let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); if let Some(l) = label { - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled }); + let at = pos2(crate::rtl::lead_x(rtl, r, 24.0), r.center().y); + ui.painter().text(at, crate::rtl::lead_center(rtl), l, regular(11.5), if on { t.text } else { t.text_disabled }); } let resp = tooltip(app, resp, tip, id); command_menu(&resp, app, id); @@ -205,7 +207,7 @@ pub fn split( if !on { return; } - egui::Popup::menu(&aresp).show(|ui| { + crate::rtl::menu(&aresp).show(|ui| { ui.set_min_width(180.0); menu(ui, app); }); @@ -263,11 +265,14 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_ let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click()); keytip_badge(ui, r, "menu", tip); bg(ui, r, &resp, false, &t); - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, c, a); + let rtl = crate::rtl::flows(ui); + let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset); + icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a); if let Some(l) = label { - ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), text); + ui.painter().text(pos2(lead(24.0), r.center().y), crate::rtl::lead_center(rtl), l, regular(11.5), text); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(DROPDOWN, DROPDOWN)), "dropdown", c, c); + let arrow = pos2(crate::rtl::trail_x(rtl, r, 6.0), r.center().y); + icons::paint(ui.painter(), Rect::from_center_size(arrow, vec2(DROPDOWN, DROPDOWN)), "dropdown", c, c); resp }; let reason = if on { None } else { Some(app.session.why_disabled(id).unwrap_or("")) }; @@ -277,7 +282,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_ if !on { return; } - egui::Popup::menu(&resp).show(|ui| { + crate::rtl::menu(&resp).show(|ui| { ui.set_min_width(200.0); menu(ui, app); }); diff --git a/crates/ui-egui/src/widgets/combo.rs b/crates/ui-egui/src/widgets/combo.rs index d457b25ec2..398bdbd4d3 100644 --- a/crates/ui-egui/src/widgets/combo.rs +++ b/crates/ui-egui/src/widgets/combo.rs @@ -157,6 +157,17 @@ struct ComboOptions<'a> { tip: &'a str, } +/// The arrow's box next to a combo's field: its corner radii and the rectangle it paints, reaching +/// one border width under the field on the side the two meet (the field is left of the arrow in a +/// left-to-right row, right of it in a right-to-left one). +fn arrow_box_shape(rtl: bool, arrow: Rect, r: CornerRadius) -> (CornerRadius, Rect) { + if rtl { + (CornerRadius { nw: r.nw, sw: r.sw, ne: 0, se: 0 }, Rect::from_min_max(arrow.min, pos2(arrow.max.x + ARROW_BORDER, arrow.max.y))) + } else { + (CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se }, Rect::from_min_max(pos2(arrow.min.x - ARROW_BORDER, arrow.min.y), arrow.max)) + } +} + /// Indices of the `items` matching `query`, ignoring case: exact matches, then the names starting /// with it, then those containing it, each group in list order. A blank query matches everything. pub fn font_matches(query: &str, items: &[String]) -> Vec { @@ -220,12 +231,16 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); // The field and its arrow share one outline: rounded on the outside, square where they - // meet. The field's frame takes the widget visuals' radius in every state. + // meet. The field's frame takes the widget visuals' radius in every state. In a row that + // runs right to left the arrow comes after the field on its left, so the corners and the + // arrow's underlap are the mirror image. + let rtl = ui.layout().prefer_right_to_left(); let r = ui.visuals().widgets.inactive.corner_radius; let w = &mut ui.visuals_mut().widgets; for v in [&mut w.noninteractive, &mut w.inactive, &mut w.hovered, &mut w.active, &mut w.open] { - v.corner_radius.ne = 0; - v.corner_radius.se = 0; + let (a, b) = if rtl { (&mut v.corner_radius.nw, &mut v.corner_radius.sw) } else { (&mut v.corner_radius.ne, &mut v.corner_radius.se) }; + *a = 0; + *b = 0; } // Up/Down move the list's highlight: take them before the text field moves its cursor. let mut step = 0i32; @@ -306,8 +321,7 @@ fn combo_impl(ui: &mut Ui, options: ComboOptions<'_>) -> Option { } // It reaches one border width under the field, so the field's right border is the only line // between them: two borders side by side read as a double-width divider. - let ar_radius = CornerRadius { nw: 0, sw: 0, ne: r.ne, se: r.se }; - let under = Rect::from_min_max(pos2(ar.min.x - ARROW_BORDER, ar.min.y), ar.max); + let (ar_radius, under) = arrow_box_shape(rtl, ar, r); let fill = if aresp.hovered() { t.hover } else { t.input }; let arrow_box = egui::epaint::RectShape::new(under, ar_radius, fill, Stroke::new(ARROW_BORDER, t.input_border), egui::StrokeKind::Inside); ui.painter().set(arrow_bg, arrow_box); @@ -602,3 +616,24 @@ pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The arrow's box fuses with its field on the side they meet: in a left-to-right row the + /// arrow is right of the field (rounded on the right, painted one border under the field's + /// right edge), in a right-to-left row it is left of it (rounded on the left, under the + /// field's left edge). + #[test] + fn the_arrow_box_is_rounded_away_from_its_field() { + let arrow = Rect::from_min_max(pos2(100.0, 0.0), pos2(116.0, 20.0)); + let r = CornerRadius::same(4); + let (ltr, under) = arrow_box_shape(false, arrow, r); + assert_eq!((ltr.nw, ltr.sw, ltr.ne, ltr.se), (0, 0, 4, 4)); + assert_eq!((under.min.x, under.max.x), (100.0 - ARROW_BORDER, 116.0)); + let (rtl, under) = arrow_box_shape(true, arrow, r); + assert_eq!((rtl.nw, rtl.sw, rtl.ne, rtl.se), (4, 4, 0, 0)); + assert_eq!((under.min.x, under.max.x), (100.0, 116.0 + ARROW_BORDER)); + } +} diff --git a/crates/ui-egui/src/widgets/groups.rs b/crates/ui-egui/src/widgets/groups.rs index 94a7147eaf..ad84305344 100644 --- a/crates/ui-egui/src/widgets/groups.rs +++ b/crates/ui-egui/src/widgets/groups.rs @@ -12,7 +12,11 @@ pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp /// A ribbon group whose title is shown as is (a custom group's name, Customize Ribbon). pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) { let t = Tokens::get(ui.ctx()); - let start = ui.cursor().min; + // Groups follow the ribbon's direction: in a right-to-left interface the content starts at + // the right, and the launcher and the divider sit on the left. + let rtl = crate::rtl::flows(ui); + let start_cursor = ui.cursor(); + let start = start_cursor.min; // #662: the window may be too narrow for the tab; the plan says how this group fits. let position = crate::ribbon_fit::position(ui.ctx()); let level = crate::ribbon_fit::level(ui.ctx(), id_title); @@ -22,10 +26,10 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& // Not folded: a drop-down left open while it was folded doesn't come back by itself. ui.data_mut(|d| d.remove::(collapsed_open_id(ui, id_title, position))); let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x); - let inner = ui.vertical(|ui| { + let inner = crate::rtl::vertical(ui, |ui| { ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 }); crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT); - let content = ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), egui::Layout::left_to_right(egui::Align::Min), |ui| { + let content = ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), crate::rtl::row_layout(ui, egui::Align::Min), |ui| { ui.spacing_mut().item_spacing = vec2(1.0, 1.0); add(ui, app); }); @@ -40,7 +44,7 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& let r = inner.response.rect; title_row( ui, - Rect::from_min_max(pos2(start.x, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)), + Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)), id_title, title, launcher, @@ -48,19 +52,27 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<& ); } ui.add_space(5.0); - let x = ui.cursor().min.x; + let x = if rtl { ui.cursor().max.x } else { ui.cursor().min.x }; ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border)); ui.add_space(6.0); - crate::ribbon_fit::record(ui.ctx(), id_title, level, ui.cursor().min.x - start.x); + crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor())); +} + +/// The width a group took: the cursor's way along the row, from `before` to `after`. A +/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity, +/// so there the width is how far the right edge moved. +fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 { + if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x } } /// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher. fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) { let t = Tokens::get(ui.ctx()); let title_y = r.center().y; + let rtl = crate::rtl::flows(ui); ui.painter().text(pos2(r.center().x, title_y), Align2::CENTER_CENTER, title, regular(11.0), t.group_label); if let Some(cmd) = launcher { - let lr = Rect::from_center_size(pos2(r.max.x - 6.0, title_y), vec2(11.0, 11.0)); + let lr = Rect::from_center_size(pos2(crate::rtl::trail_x(rtl, r, 6.0), title_y), vec2(11.0, 11.0)); let resp = ui.interact(lr, ui.scope_id().with(("launch", id_title)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover); @@ -127,9 +139,9 @@ fn collapsed_group( // This popup isn't counted by `Popup::is_any_open`, so keep the same Esc from also leaving // full screen. app.escape_free = false; - egui::Popup::menu(&resp).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| { - ui.vertical(|ui| { - ui.horizontal_top(|ui| { + crate::rtl::menu(&resp).open_bool(&mut shown).close_behavior(egui::PopupCloseBehavior::IgnoreClicks).show(|ui| { + crate::rtl::vertical(ui, |ui| { + crate::rtl::horizontal_top(ui, |ui| { ui.set_min_height(CONTENT_H); ui.spacing_mut().item_spacing = vec2(1.0, 1.0); add(ui, app); @@ -193,7 +205,7 @@ pub fn command_icon(id: &str) -> &str { /// Right-click on a command's button: put it on (or take it off) the Quick Access Toolbar, or /// open Customize Ribbon (#379). pub fn command_menu(resp: &Response, app: &mut WordApp, id: &str) { - resp.context_menu(|ui| { + crate::rtl::context_menu(resp, |ui| { let known = app.session.registry.get(id).is_some(); let on_qat = app.session.ribbon.qat().contains(&id); if known && !on_qat && ui.button(tl!("Add to Quick Access Toolbar")).clicked() { @@ -235,3 +247,21 @@ pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { add(ui); }); } + +#[cfg(test)] +mod tests { + use super::*; + + /// A right-to-left row's cursor reaches to negative infinity on the left, so the width of a + /// group in it comes from the right edge: finite and positive, as the ribbon planner needs. + #[test] + fn a_group_in_a_right_to_left_row_has_a_finite_width() { + let before = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(900.0, 90.0)); + let after = Rect::from_min_max(pos2(f32::NEG_INFINITY, 0.0), pos2(740.0, 90.0)); + assert_eq!(group_width(true, before, after), 160.0); + assert!(!(before.min.x - after.min.x).is_finite(), "the left edge cannot measure it"); + let before = Rect::from_min_max(pos2(20.0, 0.0), pos2(f32::INFINITY, 90.0)); + let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0)); + assert_eq!(group_width(false, before, after), 160.0); + } +} diff --git a/crates/ui-egui/src/widgets/icon_buttons.rs b/crates/ui-egui/src/widgets/icon_buttons.rs index 5ac62fef89..54508ec444 100644 --- a/crates/ui-egui/src/widgets/icon_buttons.rs +++ b/crates/ui-egui/src/widgets/icon_buttons.rs @@ -63,19 +63,22 @@ fn icon_text_button_with(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId let st = drawn_state(ui, &resp, true); let r = r.expand(st.expansion); ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside); + // By language, not by layout: these buttons also sit in far-side clusters, which fill from + // the other end. + let rtl = crate::rtl::on(); icons::paint( ui.painter(), - Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)), + Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, pad.x + icon_w / 2.0), r.center().y), vec2(icon_w, icon_w)), icon, t.icon, t.accent, ); - let text_x = r.min.x + pad.x + icon_w + if show_text { 4.0 } else { 0.0 }; + let text_x = crate::rtl::lead_left(rtl, r, pad.x + icon_w + if show_text { 4.0 } else { 0.0 }, text_w); if show_text { ui.painter().galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, st.text); } if marker { - let c = pos2(text_x + text_w + 4.0 + DROPDOWN / 2.0, r.center().y); + let c = pos2(if rtl { text_x - 4.0 - DROPDOWN / 2.0 } else { text_x + text_w + 4.0 + DROPDOWN / 2.0 }, r.center().y); icons::paint(ui.painter(), Rect::from_center_size(c, vec2(DROPDOWN, DROPDOWN)), "dropdown", st.text, st.text); } resp @@ -118,13 +121,16 @@ pub fn dialog_tabs(ui: &mut Ui, labels: &[String], selected: usize) -> Option Option **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); 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-03 · **Change:** 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,12 +73,12 @@ 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 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | 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; 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**: text in a freely rotated text box (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**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [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`); 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) | | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | -| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | +| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | @@ -155,6 +155,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | | 2026-11-03 | trivial | #8: language-packs-2 published and pinned, 75 packs; Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto available as Language packs | | 2026-11-02 | trivial | #8 (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available as Language packs; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded; no ranked gap changed | | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3f11b12d59..5137aba02c 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-03 · **Change:** 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); previously minor (English spelling reads SCOWL 2026 per English, CP2; previously trivial: proofing file paths point at craft-proof in craft-libs and craft-i18n `language-data/`, #5202; previously trivial: word count counts each Han ideograph and kana as a word, #4547; previously trivial: the Traditional Chinese interface draws hanzi to the Taiwan standard with an installed Traditional Chinese font first: PingFang TC, Microsoft JhengHei, Noto Sans CJK TC; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** 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 @@ -21,8 +21,8 @@ native-speaker review (human). ## What WordCraft ships (measured) -- **27 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`, - Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl` and Danish `da` (#2722). Catalogs have 2,061–2,674 entries +- **28 interface languages**: English plus `zh-hans`, `zh-hant`, `ja`, Korean `ko` (#1216), `uk`, `es`, `pt-br`, + Serbian `sr`/`sr-latn` (#250), Estonian `et`, Turkish `tr`, Russian `ru`, Italian `it` (#594), German `de` (#574), Polish `pl`, Czech `cs`, Hungarian `hu`, French `fr`, Bulgarian `bg`, Vietnamese `vi` (#1434), Swedish `sv` (#1219), Croatian `hr` (#1271), Romanian `ro`, Slovenian `sl`, Dutch `nl`, Danish `da` (#2722) and Arabic `ar` (#1195). Catalogs have 2,061–2,730 entries each; Polish has every key of every other catalog, including the built-in style names and the equation gallery, except the citation style names only Hungarian, Bulgarian, Swedish, Romanian and Slovenian translate so far. A missing translation falls back to English and never fails a test or build. `cargo xtask i18n-report`, part of `cargo xtask ci`, prints per language the interface strings its catalog lacks: everything the interface audit saw looked up, the ribbon tabs, command @@ -34,12 +34,14 @@ native-speaker review (human). Tests of the lookup code (English fallback, contexts, placeholder templates, built-in style names, the per-thread language, command search) use small inline catalogs or the pseudo-language, so rewording a translation never fails a test. - `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. + `pl`, `pl-PL` and `pl_PL.UTF-8` system locales pick Polish automatically. `ar` and every `ar-*` + locale pick Arabic; the shell mirrors for it (ribbon, panes, File page, dialogs, menus), while the + document canvas keeps its own direction. - Of the 675 English strings a scan extracts from `tl!(…)` calls and command labels/locations, each catalog covers **661 (98%)**. Some newer dialog and pane strings (Equation tab tooltips, Zotero, Read Aloud) aren't wrapped in `tl!` yet, so they show in English in every language: the true share of visible strings is **~93–95%** (estimated). -- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). +- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195). - **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through the styles; Review › Language and the status bar set it: a short list, plus Set Proofing Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get @@ -61,7 +63,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 | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 | +| 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) | | French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) | | 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 | @@ -245,7 +247,7 @@ WordCraft keeps 2,54 cm). | Item | Hours | Notes | |---|---|---| | Wrap the remaining English literals in `tl!` and translate them in every catalog | 4–6 | Makes the six partial languages full except script items | -| Mirrored (right-to-left) interface for Arabic and Hebrew | 15–25 | Ribbon, panes, dialogs, rulers | +| Mirrored (right-to-left) interface for Arabic and Hebrew | 8–12 (Arabic done #1195; Hebrew remains) | Ribbon, panes, dialogs, rulers | | Vertical text, ruby, document grid (Chinese, Japanese, Korean) | counted in [layout-parity.md](layout-parity.md) | | | Proofing dictionaries, grammar and hyphenation for the 11 non-English key languages | 20–40 | Open dictionaries only (licence check per dictionary); issues #25, #40, #100. Per-run languages, the Hunspell-style affix engine and the TeX pattern reader landed with Polish, so a language with an open Hunspell dictionary and hyphenation patterns is mostly data plus grammar rules | | Native-speaker review of every catalog | human | Swedish: terminology and the running app reviewed (2026-10-13); Italian reviewed (2026-10-14); the others not yet | @@ -254,6 +256,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 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 packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto | | 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded | | 2026-11-01 | minor | Language packs (CP4.3–CP4.5): install and remove a language's spelling and hyphenation from File › Options › Language or the missing-pack bar, About › Language data, the commands; the default build embeds English data only; the language requests point at their packs: Lithuanian and Romanian available once language-packs-1 is published, the others waiting on craft-proof engine support | diff --git a/docs/roadmap.md b/docs/roadmap.md index efa50e38f0..f53c06bae0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (M15: Arabic mirrored UI #1195; 28 UI languages; Hebrew UI and native IME acceptance remain open); previously trivial (HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged; previously trivial: egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -62,7 +62,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 27 UI languages from the shared catalogs pinned by `craft-i18n.rev` (`9205108c…`); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; native-speaker review and native IME acceptance (#155–#164) remain open | 70–115 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 28 UI languages from the shared catalogs pinned by `craft-i18n.rev` (Arabic mirrored UI #1195); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open | 55–95 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta @@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | M15: Arabic mirrored interface (#1195); 28 UI languages from craft-i18n; Hebrew UI, native-speaker review and native IME acceptance (#155–#164) remain open | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | | 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open | | 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1bbe430482..59b692cef2 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Arabic mirrored interface: ribbon, panes, File page, dialogs, menus; document canvas independent, #1195); previously trivial (Alt, F badges the Backstage pages; Alt, F, P and V open Print, #4960); previously trivial (View › Macros opens a Macros dialog with Run, Delete and Record, #4327); previously trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (Table Properties: row, column and cell settings apply to the selected rows, columns and cells; mixed row check boxes, #4857); previously trivial (Insert › Shapes shows the basic shapes as a grid of drawn icon tiles, #4349); previously trivial (File › Options › Proofing › AutoCorrect Options opens with no document open, #4585); previously trivial (the Shading buttons show the last picked colour, #4412); previously trivial (a blank User Name in File › Options stays blank after a restart, #4368); previously trivial (window size falls back to the screen rectangle when the reported inner size is invalid; equation symbol tooltips no longer repeat a missing glyph's name); previously trivial (window titles and suggested file names omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -60,7 +60,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94), its Save As type list naming every format with the document's own first (#1399); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — | | Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 | | Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, also onto an empty line after Enter or Shift+Enter (#1119); File › Home and New's templates and recent documents are buttons named by their caption or file name, with the path when two file names are the same (#3176); not yet tried with real screen readers | full | 50% | 5–8 | -| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization | +| Right-to-left (mirrored) interface | ✅ Arabic (`ar`): shell, ribbon, panes, File page, dialogs and menus mirror; document canvas stays independent (#1195). Hebrew UI still open (#20) | ✅ (Arabic, Hebrew Word) | 50% | Hebrew UI + remaining chrome edges | > **Header/footer preset update:** 2026-10-15 · Three columns creates a one-row, three-cell borderless table (#2197). The active header/footer story has screen-only cell guides while editing, even with View Gridlines off. Closing the story removes automatic guides; explicit View Gridlines still outlines every table. No document border is changed by showing these guides. Readiness estimates unchanged. @@ -92,6 +92,7 @@ Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Arabic mirrored interface: title bar, ribbon, panes, File page, dialogs and menus run from the right; document canvas stays independent (#1195); Hebrew UI still open | | 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-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 |