Compare commits

...
22 Commits
Author SHA1 Message Date
Dylan M. TaylorandBrandon Thomas ae5c65b9dd Paste: charge copied text box stories for the formatting they clone (#5379)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:05:14 -05:00
1f8f9e8e2c DOC import: keep one override per table cell instead of a copy per repeated sprm (#5374)
* fix(docbin): keep one override per table cell instead of a copy per repeated sprm

* DOC import: vertical alignment reads only the cells a row can hold, like the other cell-range sprms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:04:13 -05:00
Dylan M. TaylorandBrandon Thomas cf23ca73c9 DOC import: attach sections to their paragraphs through an index of paragraph ends (#5373)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:03:10 -05:00
pakiatandClaude Opus 5.5 b2b988cdc8 Tests: harnesses that render through wgpu take turns (#5354)
Two wgpu devices drawing at once crash Windows' software rasterizer (WARP, d3d10warp.dll: 0xc0000005, sometimes heap corruption 0xc0000374), which is the adapter a machine without a GPU or a remote session gets. The ui-egui tests that render (a dragged shape, a rotation drag, and the ignored spelling review preview) now hold one lock while their harness lives. On Windows 11 over SSH the two rendering tests crashed 8 of 20 runs together (0 of 20 alone), and the whole ui-egui lib run 2 of 6; with the lock 0 of 20 and 0 of 10.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:01:59 -05:00
1ff68d64e3 i18n: Arabic interface with mirrored RTL shell (+ parity docs) (#5370)
* Arabic interface: mirrored right-to-left window and Arabic UI fonts

Adds the Arabic (`ar`) interface language's code: its catalogs are craft-i18n's
`locales/ar`, so this changes no catalog.

- `ar` is registered in `i18n::LANGUAGES` and `Lang::rtl()` says it is written
  right to left; its translations are used as written (egui's text layout
  orders each line's bidi text itself). A ribbon location reads from the right
  with a leftwards mark.
- `rtl.rs`: the window is mirrored. Containers open in `rtl::root`; columns,
  grids, checkboxes, radio buttons, menus, rows, panels, the dialog tab strip
  and the ribbon's scrolling row follow the language's direction. Title bar,
  status bar, ribbon tabs and groups, File pages (including Print, with the
  settings on the right and a solid scrollbar gutter), dialogs, menus and the
  task panes (their docking edge swaps) are laid out from the right. egui's
  `Grid` panics in a right-aligned layout, so grids go through `rtl::grid`.
- Arabic UI font fallback: the craft-fonts Arabic face, else an installed
  Arabic font, loaded when the interface is Arabic.
- The "Auto (200%)" size label keeps its percent sign inside the isolated
  number.
- Tests: Arabic locale, saved preference and lookup order on fixture catalogs;
  an interface audit (skipped without the craft-i18n Arabic catalog) that lays
  the app out right to left at several widths, checks tab, group, pane, Print
  and Go To positions and that nothing panics.

The Arabic work builds on Abdelhaq El Amraoui's mirrored-interface PR (#737).

Co-Authored-By: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: pin craft-i18n at a commit with the Arabic catalog; review fixes

- craft-i18n.rev and the Flatpak manifest pin 19358491 (craft-i18n main), the
  first commit line that has locales/ar/wordcraft.tsv. The old pin had no
  Arabic catalog, so `ar` was registered but not embedded and two i18n tests
  (the_language_setting_is_a_ui_command_and_persists,
  the_languages_offered_are_the_embedded_ones_of_the_list) failed in CI.
- The Arabic font fallback is requested for any right-to-left interface
  (`Lang::rtl()`), not only after the language picker sets the flag, so an
  automatic or saved Arabic gets the installed Arabic face.
- The file browser, the update window and Reading Mode's toolbar run their
  bodies in `rtl::root`, so their far-side buttons and rows are mirrored.
- A group in a right-to-left ribbon row is measured from the right edge: the
  cursor's left edge is negative infinity there, so its width was NaN and the
  ribbon planner dropped every group, never compacting a narrow Arabic window.
  A narrow window now folds groups into drop-downs.
- The mirrored checkbox and radio marks are labelled by their text, so screen
  readers name them.
- Tests: group width in a right-to-left row; the Arabic interface audit checks
  Reading Mode's toolbar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: ask for the installed Arabic font separately from the CJK one

A previous commit made every right-to-left interface request the installed CJK
fallback font (`system_cjk`) so that the installed Arabic font would load. That
also made every Arabic launch read a large system CJK font file when no
craft-fonts input is embedded (the Flatpak build has none).

`install_ui_fonts` now passes a separate `system_arabic` flag (`Lang::rtl()`):
the installed Arabic font is read for it, and `system_cjk` is again set only
by a CJK interface or by CJK text on screen. `WordApp::fonts_system_arabic`
remembers what was installed, so a language change reinstalls the fonts.

Tests: the_installed_arabic_font_is_asked_for_by_arabic_alone (the Arabic flag
adds the installed font only when none is embedded, and the CJK request alone
adds no Arabic font). Checked a headless screenshot of the Arabic interface
without craft-fonts: the installed Arabic font draws the labels.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix the AutoSave switch knob: on is at the right, mirrored in Arabic

The title bar's AutoSave knob was placed with `lead_x(rtl == on, ..)`, which
reverses main's mapping in both directions: left to right it drew the knob at
the left when the switch was on, and right to left at the right. main draws it
at the right when on and at the left when off, so the mirrored interface must
draw it at the left when on: `rtl != on`.

The position is now `autosave_knob_x(rtl, on, track)`, pinned by a test for all
four cases. A headless screenshot of the off switch shows the knob at the left
in English and at the right in Arabic.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: file names and paths in right-to-left lines; kerning row order

Tester feedback on the Arabic interface:

- File > Open's Recent list, the built-in file browser and the title bar drew
  file names and paths raw, from the left edge. In an Arabic line the marks of
  a name ("(نهائي) 2.docx") and the folders of a path with Arabic parts were
  scrambled. Rows now start at the side reading starts from (`rtl::lead_x`),
  names go through `i18n::isolate` (a first-strong isolate, with a left-to-right
  mark before the extension so `2.docx` stays `2.docx`) and paths through
  `i18n::isolate_path` (a left-to-right isolate with a mark after every
  separator, so neighbouring Arabic folders do not swap). Other languages get
  the text untouched; saved and spoken text never carries the marks.
- Font dialog, Kerning row: the dialog's grid is drawn left to right inside a
  right-to-left window, so "Points and above" came after its number; in an
  Arabic interface it now comes first, so the line reads "14 نقطة فأكثر" from the
  right. (The grid's columns themselves still keep their left-to-right order.)

Tests: names_and_paths_are_isolated_in_a_right_to_left_interface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: mirror through craft_ui::direction and Style::right_to_left

The suite's craft-ui now has the direction helpers (`craft_ui::direction`) and
its egui fork a `Style::right_to_left` that mirrors egui's own chrome. Use them
instead of our parallel wrappers:

- `rtl::sync(ctx)` applies the interface direction every frame the language's
  changes (`direction::apply`): it sets the shared direction state and
  `Style::right_to_left` on every theme, which draws check boxes and radio
  buttons with the mark at the reading start, mirrors submenus, scroll bars,
  title bars and the combo boxes' arrows (the Print page's choice lists, the
  font list) and opens combo menus from the right.
- `rtl::checkbox`, `radio_value`, `radio` and `indeterminate_checkbox` are
  gone: about 200 call sites use plain `ui.checkbox`, `ui.radio_value`, ... and
  egui does the mirroring (and names the control by its text, as a plain widget).
- `lead_x`, `lead_center`, `lead_top`, `start_panel` and `end_panel` delegate to
  `craft_ui::direction`. `root`, `flows`, `island`, `grid`, `menu`, `vertical`,
  `far_side` and the other layout helpers stay: the shared crate has no
  equivalent that fits them.

Visual changes: the combo boxes' drop-down arrows and lists are mirrored in
Arabic; nothing changes in left-to-right languages (checked headless
screenshots of the English main window, Font dialog and Print page).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Recent files and the file browser: full path on hover, long names keep their start

Review feedback:

- Each row of File > Open's Recent list shows the document's full path as its
  tooltip (the path line under the name can be longer than the row).
- A long name in the built-in file browser is laid out in one row, with an
  ellipsis at its end, from the side reading starts from, instead of being
  clipped at the row's edge: in a right-to-left interface the old text anchored
  at the right edge cut the end of an Arabic name and the start of a Latin one.
  The whole name is the row's tooltip when it was shortened.

Checked headless screenshots of the file browser with a long English and a long
Arabic name.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Font combos in a right-to-left row: the arrow joins its field on the left

The ribbon's font name and size boxes are drawn from a field and a separate
arrow box that share one outline: the field is square and the arrow's box is
rounded on the side where the arrow goes, painted a border width under the
field. That assumed the arrow is right of the field. In a right-to-left row
(the Arabic ribbon) the arrow comes after the field on its left, so the field
was square on the wrong side and the arrow box rounded on the side facing the
field: two boxes with a gap-like seam, the arrow looking outside the frame.

`arrow_box_shape(rtl, ..)` mirrors the radii and the underlap, and the field's
square corners are chosen by the row's direction (the nw/sw pair in right to
left). Left-to-right rows are drawn exactly as before (English before/after
crops are identical).

Test: the_arrow_box_is_rounded_away_from_its_field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pin craft-i18n 4612850 (merged Arabic WordCraft rows, craft-i18n#76)

The pin and the Flatpak manifest commit move from 1935849 to 4612850, which is on
craft-i18n main and adds the 70 Arabic rows this branch relies on.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pin craft-i18n cac2cf0 (Arabic number agreement and status-bar rows)

Moves the pin and the Flatpak manifest commit from 4612850 to the current craft-i18n
main, which includes craft-i18n#95 (48bed03) and #81 (ef58ff3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic: isolate paths shown beside labels; mirror the Print page zoom

File > Info shows Location as its own left-to-right path label (cut short with the full path on hover, not wrapped), and the open-failure, Exported and dictionary-file messages isolate their paths. The Print preview zoom puts minus at the slider's left end and plus at its right in Arabic, as the status bar does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic: mirror the label/field rows of the Font, Info, Paragraph, Page Setup, Insert Table and Options forms

rtl::Table lays a form out as an egui Grid in a left-to-right interface (same id, columns and spacing, so the output is unchanged) and, where the interface is mirrored, with each row's cells running from the right, every column as wide as its widest cell. File > Info puts Properties at the right and Statistics after it, and the Font dialog's kerning row reads 14 points and above.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: record Arabic mirrored interface landing (#1195)

Update the language counts, Arabic parity row, gap #11, and UI/ROADMAP
status so they match the craft-i18n catalog and mirrored shell already
on this branch.

* Fix Organizer Copy arrow in RTL and refresh roadmap status.

Point the Copy glyph at the Document column when the shell is mirrored,
and bump docs/roadmap.md status/revision history for Arabic UI (#1195).

* fix(ui): keep pane dock left/right physical in RTL

ui.paneDock and view.stylesPaneDock now store and report physical window sides; only unset defaults still open on the reading-start edge. Also wrap the language-packs grid for RTL, document the contract, and load a system Arabic face for Arabic names in LTR UIs.

* docs: clarify mirrored default pane edges in RTL

Start-default panes open on the physical right in Arabic; end-default
panes open on the physical left — not every never-docked pane on the right.

* refactor(ui): one PaneState for a saved pane place

The four saved-dock arms built the same PaneState; only the place and
whether it was set explicitly differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(ui): rustfmt the panes re-exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: The Legend Of MTS Dev <tlo.mts.tsh@gmail.com>
Co-authored-by: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: email <email@email.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-03 15:00:45 +01:00
Dylan M. Taylor 051fd875ef Proofing: look up unchecked runs by binary search, not a scan per issue (#5344)
paragraph_issues dropped issues touching a run left unchecked by testing
every run of the paragraph against every issue. A paragraph can have many
runs and many issues within the proofing size limit, so the filter was
quadratic in the paragraph's size. The unchecked ranges are now merged and
sorted once, each issue is looked up by binary search, and paragraphs
without unchecked runs skip the filter.
2026-11-03 08:55:09 -05:00
Brandon ThomasandClaude Opus 5.5 ffb155a032 ci: the font-store sanitising test waits out an install another test's window moved onto the engine's thread
Another ui-egui test's frame runs language_packs::tick, which puts installs on the engine's
thread while this test's backend is set; start_install then returned Ok and the test failed
in the full suite (it passed alone). Red on main since the language packs merge (#5224).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:09:38 -05:00
dd2a651093 References: Remove and Replace Table of Contents treat a title above a section break as Word does (#5152)
Word's TOC over entries in columns keeps its TOC Heading title in a one-column
section above a blank paragraph that ends it; Update Table moves the field into
that blank paragraph. Replace (references.toc with settings) put the new title
under the break, inside the two-column section, and Remove kept the blank
paragraph and its break.

Observed black-box in Word for Mac 16.114.3 on a synthetic document: Insert
Table of Contents keeps the title, the blank paragraph and the break and only
changes the field; Remove Table of Contents takes the title, the blank
paragraph and its break with the entries.

Replace now keeps the title above the break (renamed when a title is given)
and rebuilds the field and entries in place; Remove takes the title and the
blank section-ending paragraph with the table. A paragraph of another style
above the break stays.

Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:47:03 -05:00
ljkx bcbc8619e9 #5290: Page Up/Down move by a page in Outline, Draft and Web Layout (#5300)
* #5290: Page Up/Down move by a page in Outline, Draft and Web Layout

These views lay the document out as one long page, so caret.pageUp/pageDown looked for a previous or next page, found none and left the caret where it was. Move by the text area height of the caret's section instead, the same distance Print Layout moves.

* #5290: Page Up/Down pick the line past the caret, and page from a hidden outline selection end

Choose the destination among the lines past the caret by height first (DocLayout::line_toward), so narrow right-indented paragraphs can't keep the caret on its own line when the step is short. The above/below test is shared with vertical().

In Outline view, a selection that ends in text a collapsed heading hides pages from where the outline shows it (outline::shown_pos, pulled out of after_command), keeping the anchor. Avoid f32::clamp, which panics on a NaN page height.
2026-11-02 22:43:22 -05:00
Michael MuandClaude Sonnet 5.5 ab5ee42049 A command line file's Password box survives the startup document's replacement (#5334)
When a region is chosen in File › Options, start-up replaces the first blank document with one
on that region's paper through `session.set_document`. That changes the session's active
document, but the window still showed the old one, so on the first frame `sync_document` took
the change for a switch of document and cleared the dialog. A password-protected file named on
the command line (a double-click in the file manager) had just asked for its password with
`file.open`, so the Password box was dropped before it was drawn and a blank Document1 stayed.

`WordApp::replace_startup_document` swaps the document and lets the window follow at once; the
start-up code uses it. Before the file is opened, so nothing is open to lose.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:43:19 -05:00
Dylan M. Taylor e00ff04d4a Fonts: refuse WOFF2 collections whose rebuilt directories are huge or list a table twice (#5332)
The WOFF2 decoder rebuilds every collection font's table directory (16 bytes
per listed table) before checking its output size, while the file spends as
little as one byte per listed table. A small collection listing the same
tables over and over made it allocate hundreds of megabytes twice.

Preflight the collection directory before decoding: the rebuilt directories
must fit 16 MiB and no font may list a table twice.
2026-11-02 22:24:55 -05:00
Dylan M. Taylor 220994decf Fonts: cut names read from a font's name table to 128 characters (#5333)
A name record can hold 64K characters, and every face copies its family into
each localized name and every variable-font named instance (with their
localized names again), so a small font could claim hundreds of megabytes of
name strings before any later check.

name() now keeps at most MAX_NAME_CHARS characters, the limit localized names
already used, so every copy stays small.
2026-11-02 22:24:52 -05:00
Dylan M. Taylor ab0020d6f4 Pictures: refuse a TIFF whose first image names its compression twice (#5337)
The TIFF guard read the first Compression tag, but the tiff crate keeps the
last copy of a tag. An allowed first value (none, LZW...) could hide a JPEG
compression after it, and the JPEG strips are then decoded by a nested
decoder with its own limits, outside the caller's budget.
2026-11-02 21:30:33 -05:00
Dylan M. Taylor 5702fa08b2 Merge pull request #5224 from benandez/fu-packs-p3
Language packs 2: 75 packs and the merged craft-proof (compounding, en-AU); #5208 review follow-ups
2026-11-02 21:23:33 -05:00
Dylan M. Taylor 1a8932b3ed Read Aloud: look citations up by binary search, not a scan per character (#5335)
Building the queue checked every character of the document against every
citation field, so a document with many citations made Read Aloud do
characters x citations comparisons before playback started.

The citation spans are now sorted and merged once, and each position is
looked up by binary search.
2026-11-02 21:16:49 -05:00
Martin BallandClaude Opus 5.5 8865455cfb CP4.4: review — gap 8 and the localization parity rows say the compounding languages are packs (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 02:07:54 +00:00
Martin BallandClaude Opus 5.5 0842d4faec CP4.4: language-packs-2 pinned (75 packs, adds the compounding languages); craft-proof at craft-libs main's merge of #497 (with #496, en-AU)
- PACK_INDEX: language-packs-2 index.json, sha256 b35c7aef…, built from craft-i18n 20467b63
- craft-proof pinned at storytold/craft-libs 47c9f625 (flag-based compounding, #497; review follow-ups and en-AU, #496)
- Options › Language: ProofLang::EnAu has no Hunspell dictionary name of its own (English uses SCOWL)
- docs: Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto are packs; Mongolian and Thai stay hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:55:16 +00:00
Martin Ball 14513ae5f6 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-03 01:50:29 +00:00
Martin Ball 7d9968fd98 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-02 22:43:02 +00:00
Martin BallandClaude Opus 5.5 7aab0b202c FU.3: language packs — the status bar's pack lookup is cached by index generation, not copied every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 c4d7948e68 FU.2: language packs — tag characters and line/paragraph separators are stripped from shown text; font store errors go through the same sanitiser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 d393e99879 FU.1: language packs — a user's cancel isn't an error; Install right after Cancel starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
125 changed files with 3827 additions and 1248 deletions
Generated
+1 -1
View File
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=89fa1559e94378ecef85cd7e4681400f4df053e0#89fa1559e94378ecef85cd7e4681400f4df053e0"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
dependencies = [
"log",
"miniz_oxide 0.9.1",
+4 -5
View File
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is the head of storytold/craft-libs#495 (review fixes), which contains #485 (CP4.2,
# Language packs, on #483 SCOWL English and #482); the font store's `language-pack` kind is #486
# (CP4.1). GitHub serves PR heads from storytold/craft-libs, and merged with merge commits they
# stay in its main.
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "89fa1559e94378ecef85cd7e4681400f4df053e0", version = "=0.1.0" }
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
+1 -1
View File
@@ -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
+13 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -496,7 +496,7 @@ fn main() -> eframe::Result {
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
app.replace_startup_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
+5
View File
@@ -228,6 +228,11 @@ fn block_cost(b: &Block) -> (usize, bool) {
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
// The formatting a clone copies too: style ids, font names and the like in the runs,
// the mark and the paragraph's properties.
let runs = p.runs.iter().filter(|r| !r.props.is_empty()).fold(0usize, |n, r| n.saturating_add(serialized_len(&r.props)));
let props = serialized_len(&p.props).saturating_add(serialized_len(&p.mark)).saturating_add(p.section.as_ref().map_or(0, serialized_len));
cost = cost.saturating_add(runs).saturating_add(props);
boxes |= box_ids(p).next().is_some();
});
(cost, boxes)
+18
View File
@@ -415,6 +415,24 @@ fn pasted_box_copies_are_capped_in_bytes() {
assert_eq!(got.iter().flatten().count(), added);
}
#[test]
fn pasted_box_copies_count_the_formatting_they_copy() {
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
let mut p = Paragraph::with_text("x", props);
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let f = fan_out(vec![para_block(p)], 64);
let mut d = doc(&["x"]);
let before = d.parts.len();
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let added = d.parts.len() - before;
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
assert!((1..64).contains(&added), "{added}");
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
}
#[test]
fn pasted_box_copies_count_the_objects_they_copy() {
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
+7 -5
View File
@@ -364,6 +364,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// Sections: each section's properties attach to the paragraph that ends it (its story of
// header/footer parts included); the section reaching the end of the text is the final one.
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
// The first paragraph ending at each CP, found once rather than by a scan per section.
let mut ending: std::collections::HashMap<u32, usize> = 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<Docume
if is_final {
continue; // handled below through `last`
}
for p in paras.iter_mut() {
if p.end_cp == sec.end_cp {
p.para.section = Some(Box::new(props));
break;
}
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
p.para.section = Some(Box::new(props));
}
}
if let Some(mut l) = last {
+26 -8
View File
@@ -101,6 +101,12 @@ impl RowInfo {
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
}
/// How many per-cell overrides the row keeps, for tests.
#[cfg(test)]
pub(crate) fn overrides(&self) -> 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<f32> {
@@ -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<T>(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<i16> = 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);
}
}
}
+43
View File
@@ -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<u8>)> = (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):
+25
View File
@@ -2,6 +2,7 @@
use serde_json::Value;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_layout::ViewMode;
use super::{parse_pos, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -254,10 +255,24 @@ fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult {
sel_result(s)
}
/// Page Up (`dir < 0`) / Page Down: the same place on the page before or after, or in the views
/// that are one long page, the line a printed page's text above or below.
fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
let ext = extend(v);
let l = s.layout();
let from = s.sel.focus.clone();
// Web Layout, Draft and Outline lay the document out as one long page (#5290).
if !s.view.read_mode && matches!(s.view.mode, ViewMode::Web | ViewMode::Draft | ViewMode::Outline) {
// A selection can end in text a collapsed outline heading hides: page from where it shows.
let at = if l.caret_on(&from, s.page_hint).is_none() { super::outline::shown_pos(s, &from, false) } else { None };
let at = at.unwrap_or_else(|| from.clone());
let Some(c) = l.caret_on(&at, s.page_hint) else { return sel_result(s) };
let page_h = l.pages.get(c.page).map_or(0.0, |p| p.h);
let y = (c.top + 1.0 + dir as f32 * page_step(s, &at)).min(page_h).max(0.0);
let to = l.line_toward(&at, s.page_hint, c.x, y, dir).unwrap_or(from);
apply(s, to, ext);
return sel_result(s);
}
let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };
let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };
let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);
@@ -266,6 +281,16 @@ fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult {
sel_result(s)
}
/// How far Page Up/Down move in the continuous views: the height of the text area of the
/// section `at` is in (the first section when `at` isn't in the body), at least an inch.
fn page_step(s: &Session, at: &Pos) -> f32 {
let block = if at.story == StoryRef::Body { at.path.0.first().map_or(0, |&i| i as usize) } else { 0 };
let sections = s.doc.sections();
let sect = sections.iter().find(|(end, _)| *end >= block).or(sections.last()).map(|(_, p)| *p);
let body = sect.map_or(0.0, |p| p.page_h - p.margin_top.abs() - p.margin_bottom.abs());
if body.is_finite() { body.max(72.0) } else { 72.0 }
}
fn set(s: &mut Session, v: &Value) -> CmdResult {
let ext = extend(v);
let to = if let Some(pv) = v.get("pos") {
+16 -7
View File
@@ -520,13 +520,25 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
if s.layout().caret(&s.sel.focus).is_some() {
return;
}
let Some(&block) = s.sel.focus.path.0.first() else { return };
let block = block as usize;
let forward = before.focus <= s.sel.focus;
let focus = s.sel.focus.clone();
if let Some(t) = shown_pos(s, &focus, forward) {
s.sel = Selection::caret(t);
}
}
/// In Outline view, the nearest place the outline shows to body position `pos`, which it doesn't
/// show (in collapsed text, or past a body paragraph's first line), going on (`forward`) or back
/// from it. `None` outside Outline view and the body, or when no paragraph shows that way.
pub(crate) fn shown_pos(s: &mut Session, pos: &Pos, forward: bool) -> Option<Pos> {
if s.view.mode != ViewMode::Outline || pos.story != StoryRef::Body {
return None;
}
let block = *pos.path.0.first()? as usize;
let levels = ol::levels(&s.doc);
let hidden = ol::hidden(&levels, &s.view.outline.view());
let layout = s.layout();
let shown_para = |j: usize| !hidden.get(j).copied().unwrap_or(true) && matches!(s.doc.body.get(j).map(|b| &**b), Some(Block::Para(_)));
let forward = before.focus <= s.sel.focus;
let next = (block + 1..levels.len()).find(|j| shown_para(*j)).map(|j| Pos::body(j, 0));
let prev = || {
if shown_para(block) {
@@ -538,8 +550,5 @@ pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool)
Some(if layout.caret(&end).is_some() { end } else { Pos::body(j, 0) })
};
let target = if forward { next.or_else(prev) } else { prev().or(next) };
if let Some(t) = target {
let t = s.doc.clamp(&t);
s.sel = Selection::caret(t);
}
target.map(|t| s.doc.clamp(&t))
}
+67
View File
@@ -326,6 +326,73 @@ mod tests {
assert!(s.ui_requests.is_empty(), "no dialog opens");
}
/// Sets up [`Slow`] with downloading on and installs on a thread; returns its go-ahead.
fn slow_in_background() -> std::sync::mpsc::Sender<()> {
let (tx, rx) = std::sync::mpsc::channel();
let slow = Arc::new(Slow { inner: FixedBackend::sample(), go: std::sync::Mutex::new(rx) });
slow.inner.network.store(true, Ordering::Relaxed);
packs::set_backend(Some(slow as Arc<dyn Backend>));
packs::set_background(Some(Arc::new(|| {})));
tx
}
fn wait_for_finish(before: u64) {
for _ in 0..500 {
if packs::finished() != before {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_ne!(packs::finished(), before, "the install thread ended");
}
/// FU.1: the person's own Cancel isn't a failure. The install stops being listed at once and
/// ends without an error ("Couldn't install: cancelled" in red was wrong); removing the pack
/// while the cancelled download is still stopping says so.
#[test]
fn a_cancelled_install_ends_without_an_error() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let go = slow_in_background();
let before = packs::finished();
assert!(packs::start_install("pl").unwrap());
assert_eq!(packs::installing().len(), 1);
packs::cancel("pl");
assert!(packs::installing().is_empty(), "Install shows again at once");
let e = packs::remove("pl").unwrap_err();
assert!(e.contains("still stopping"), "{e}");
go.send(()).unwrap();
wait_for_finish(before);
assert!(packs::installing().is_empty());
assert_eq!(packs::error("pl"), None, "a cancel is no error");
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
assert!(!packs::is_installed("pl"));
}
/// FU.1: Install right after Cancel, before the cancelled download has stopped, isn't
/// refused as still running: it shows as installing and the pack installs once the cancelled
/// run ends (never two downloads of a pack at once).
#[test]
fn install_right_after_cancel_starts_again() {
let _g = LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = Reset;
let go = slow_in_background();
let before = packs::finished();
assert!(packs::start_install("pl").unwrap());
packs::cancel("pl");
assert!(packs::start_install("pl").unwrap(), "started again");
let running = packs::installing();
assert_eq!(running.len(), 1);
assert_eq!(running[0].locale, "pl");
// The cancelled run ends, then the new one.
go.send(()).unwrap();
go.send(()).unwrap();
wait_for_finish(before);
assert!(packs::is_installed("pl"), "the second Install installed it");
assert!(packs::installing().is_empty());
assert!(packs::errors().is_empty(), "{:?}", packs::errors());
}
/// A pack read from its folder proofs and hyphenates its language's runs, and leaves the
/// English runs to English: a tiny Lithuanian pack (`ProofLang::Pack`, no rules of its own).
#[test]
+70 -1
View File
@@ -182,14 +182,45 @@ pub(super) fn toc_range(s: &Session) -> Option<(usize, usize)> {
Some((start, end))
}
/// The TOC title Word's table keeps above a section break (#4041): the TOC field's paragraph at
/// `start` holds nothing but the field (Word's blank paragraph ending the title's one-column
/// section, which the field moves into), and a TOC Heading paragraph of its own sits just above
/// it. The title's block.
fn title_above_break(s: &Session, start: usize) -> Option<usize> {
let field = s.doc.body.get(start)?.as_para()?;
let blank = field.text.chars().all(|c| c == OBJ)
&& field.objects.iter().all(|o| match o {
InlineObject::Field { instr, .. } => {
let f = FieldCode::parse(instr);
f.name == "TOC" && !f.is_table_of_figures()
}
InlineObject::BookmarkStart { .. } | InlineObject::BookmarkEnd { .. } | InlineObject::ControlEnd => true,
_ => false,
});
if field.section.is_none() || !blank {
return None;
}
let i = start.checked_sub(1)?;
let title = s.doc.body.get(i)?.as_para()?;
(title.props.style.as_deref() == Some("TOCHeading") && title.section.is_none() && toc_field(title).is_none()).then_some(i)
}
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
/// with the table.
/// with the table. When that heading is above a section break that the field's otherwise blank
/// paragraph ends (Word's table over entries in columns), the blank paragraph and its break go
/// too, as Word's Remove Table of Contents takes everything in its control (#4041): the text
/// before then belongs to the section after.
pub(super) fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
if let Some(title) = title_above_break(s, start) {
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
s.doc.remove_block(StoryRef::Body, &Path::top(title))?;
return Ok(title);
}
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
Ok(p) if p.section.is_some() => {
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
@@ -355,6 +386,14 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
regenerate_toc(s, None, &tabs)?;
return sel_result(s);
}
// Word's table with its title above a section break (entries in columns): replacing it
// keeps that layout, as Word's Insert Table of Contents does (#4041). The title stays
// above the break (retitled when a title is given), the field takes the new settings in
// its paragraph ending the title's section, and the new entries follow in their columns.
Some((start, _)) if title_above_break(s, start).is_some() => {
replace_toc_above_break(s, v, start, &tabs)?;
return sel_result(s);
}
Some((start, end)) => {
let start = remove_toc(s, start, end)?;
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
@@ -390,6 +429,36 @@ pub(super) fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Replaces a TOC whose title sits above the section break its field's paragraph ends
/// (`title_above_break`): the field takes `v`'s settings, the title `v`'s `title` if one is given
/// (otherwise it is kept, as Word's Insert Table of Contents keeps it), and the entries are rebuilt.
fn replace_toc_above_break(s: &mut Session, v: &Value, start: usize, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
let instr = toc_instr(v);
let field = s.doc.para_mut(StoryRef::Body, &Path::top(start))?;
for o in &mut field.objects {
if let InlineObject::Field { instr: old, result, dirty, .. } = o {
let f = FieldCode::parse(old);
if f.name == "TOC" && !f.is_table_of_figures() {
old.clone_from(&instr);
result.clear();
*dirty = false;
}
}
}
field.touch();
if let (Some(title), Some(i)) = (p::str(v, "title"), start.checked_sub(1)) {
let old = s.doc.para_mut(StoryRef::Body, &Path::top(i))?;
let mut head = Paragraph::with_text(title, old.props_of_char(0).clone());
head.props = old.props.clone();
head.mark = old.mark.clone();
*old = head;
}
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
regenerate_toc(s, Some(leader), tabs)?;
s.clamp_selection();
Ok(())
}
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
/// reusing one already there; its name.
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
+139 -30
View File
@@ -293,8 +293,12 @@ pub fn install(locale: &str, cancel: &Cancel, progress: &mut dyn FnMut(u64, u64)
/// Remove the installed pack for `locale`.
pub fn remove(locale: &str) -> Result<(), String> {
let b = backend().ok_or_else(no_store)?;
if jobs().installs.iter().any(|(l, ..)| l == locale) {
return Err(format!("the {locale} pack is being installed: cancel it first"));
if let Some(job) = jobs().installs.iter().find(|j| j.locale == locale) {
return Err(if job.stopping() {
format!("the {locale} pack's cancelled install is still stopping: try again in a moment")
} else {
format!("the {locale} pack is being installed: cancel it first")
});
}
if !b.installed().iter().any(|p| p.locale == locale) {
return Err(format!("no language pack is installed for {locale}"));
@@ -315,11 +319,33 @@ pub type Notify = Arc<dyn Fn() + Send + Sync>;
static BACKGROUND: RwLock<Option<Notify>> = RwLock::new(None);
/// An install running on a thread.
struct InstallJob {
locale: String,
/// Bytes done.
done: u64,
/// Bytes in all.
total: u64,
/// Stops this run. A cancelled job stays listed (hidden from [`installing`]) until its thread
/// notices and ends, so two downloads of a locale never write its folder at once.
cancel: Cancel,
/// Install was asked again after a cancel, before the cancelled run ended: the thread starts
/// over (with the fresh `cancel`) instead of ending.
restart: bool,
}
impl InstallJob {
/// Cancelled, and its thread hasn't ended yet.
fn stopping(&self) -> bool {
self.cancel.is_cancelled()
}
}
/// Installs and fetches running on a thread, and how the finished ones ended.
#[derive(Default)]
struct Jobs {
/// Running installs by locale: (bytes done, bytes in all, its Cancel).
installs: Vec<(String, u64, u64, Cancel)>,
/// Running installs, one per locale.
installs: Vec<InstallJob>,
fetching: bool,
/// The last failure of each locale's install ("" for the list), until it is tried again.
errors: Vec<(String, String)>,
@@ -358,9 +384,9 @@ pub struct Progress {
pub total: u64,
}
/// The installs running now.
/// The installs running now (not the cancelled ones still stopping).
pub fn installing() -> Vec<Progress> {
jobs().installs.iter().map(|(l, d, t, _)| Progress { locale: l.clone(), done: *d, total: *t }).collect()
jobs().installs.iter().filter(|j| !j.stopping()).map(|j| Progress { locale: j.locale.clone(), done: j.done, total: j.total }).collect()
}
/// Whether the list is being fetched.
@@ -383,10 +409,12 @@ pub fn finished() -> u64 {
jobs().finished
}
/// Stop `locale`'s running install.
/// Stop `locale`'s running install. The person's own choice: it ends without an error, and
/// [`installing`] stops listing it at once (Install shows again).
pub fn cancel(locale: &str) {
if let Some((_, _, _, c)) = jobs().installs.iter().find(|(l, ..)| l == locale) {
c.cancel();
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
job.restart = false;
job.cancel.cancel();
}
}
@@ -400,7 +428,9 @@ fn set_error(locale: &str, e: Option<String>) {
/// Start installing `locale` ([`install`]): on a thread when [`runs_in_background`], else now.
/// What's wrong up front (no pack, downloading off, outside the write root) is an error at once.
/// Returns whether it is still running (`false`: done, or it already was running).
/// Returns whether it is still running (`false`: done). Asked again while it runs, it carries on;
/// right after a cancel, before the cancelled download has stopped, it starts over once that
/// one ends (never two downloads of a pack at once).
pub fn start_install(locale: &str) -> Result<bool, String> {
let b = backend().ok_or_else(no_store)?;
let pack = find(locale).ok_or_else(|| format!("there is no language pack for {locale}"))?;
@@ -418,34 +448,55 @@ pub fn start_install(locale: &str) -> Result<bool, String> {
let cancel = Cancel::new();
{
let mut j = jobs();
if j.installs.iter().any(|(l, ..)| l == locale) {
j.errors.retain(|(l, _)| l != locale);
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale) {
if job.stopping() {
// Cancelled and not ended yet: its thread starts over when it does.
job.done = 0;
job.total = pack.size;
job.cancel = cancel;
job.restart = true;
}
return Ok(true);
}
j.installs.push((locale.to_string(), 0, pack.size, cancel.clone()));
j.errors.retain(|(l, _)| l != locale);
j.installs.push(InstallJob { locale: locale.to_string(), done: 0, total: pack.size, cancel: cancel.clone(), restart: false });
}
let key = locale.to_string();
let locale = locale.to_string();
let started = spawn(move || {
let progress_notify = notify.clone();
let r = install(&locale, &cancel, &mut |done, total| {
if let Some(job) = jobs().installs.iter_mut().find(|(l, ..)| *l == locale) {
job.1 = done;
job.2 = total;
let mut cancel = cancel;
loop {
let progress_notify = notify.clone();
let r = install(&locale, &cancel, &mut |done, total| {
if let Some(job) = jobs().installs.iter_mut().find(|j| j.locale == locale) {
job.done = done;
job.total = total;
}
progress_notify();
});
let mut j = jobs();
if let Some(job) = j.installs.iter_mut().find(|j| j.locale == locale && j.restart) {
job.restart = false;
cancel = job.cancel.clone();
drop(j);
notify();
continue;
}
progress_notify();
});
let mut j = jobs();
j.installs.retain(|(l, ..)| *l != locale);
if let Err(e) = r {
j.errors.push((locale, e));
j.installs.retain(|j| j.locale != locale);
// The person's own cancel isn't a failure: nothing to show.
if let Err(e) = r
&& !cancel.is_cancelled()
{
j.errors.push((locale, e));
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
break;
}
j.finished = j.finished.wrapping_add(1);
drop(j);
notify();
});
if let Err(e) = started {
jobs().installs.retain(|(l, ..)| *l != key);
jobs().installs.retain(|j| j.locale != key);
return Err(e);
}
Ok(true)
@@ -579,12 +630,47 @@ pub fn proofable(tag: &str) -> bool {
pub fn missing_dictionary(tag: Option<&str>) -> Option<&'static str> {
let name = wordcraft_proof::missing_dictionary(tag)?;
let lang = wordcraft_proof::ProofLang::from_tag(tag);
if lang.pack_locale().is_some() && pack_for_tag(tag.unwrap_or(""), &available()).is_none() {
if lang.pack_locale().is_some() && !has_pack_for_tag(tag.unwrap_or("")) {
return None;
}
Some(name)
}
/// The last answer of whether the index has a pack for a tag, for the index generation it was
/// worked out for: the status bar asks every frame while the caret is in a pack language, and
/// working it out copies the whole index.
#[derive(Default)]
struct PackLookup {
/// (index generation, tag, has a pack).
entry: Option<(u64, String, bool)>,
}
impl PackLookup {
/// The remembered answer for `tag` at `generation`, else `compute`'s (then remembered).
fn get(&mut self, generation: u64, tag: &str, compute: impl FnOnce() -> bool) -> bool {
if let Some((g, t, has)) = &self.entry
&& *g == generation
&& t == tag
{
return *has;
}
let has = compute();
self.entry = Some((generation, tag.to_string(), has));
has
}
}
static PACK_LOOKUP: Mutex<PackLookup> = Mutex::new(PackLookup { entry: None });
/// Whether the index has a pack for `tag` ([`pack_for_tag`]), worked out again only when the
/// index changes ([`index_generation`]) or the tag does.
fn has_pack_for_tag(tag: &str) -> bool {
// Read before [`available`], which may fill the index and bump it: the answer is then worked
// out once more, never kept for a list it wasn't worked out from.
let generation = index_generation();
PACK_LOOKUP.lock().unwrap_or_else(PoisonError::into_inner).get(generation, tag, || pack_for_tag(tag, &available()).is_some())
}
/// The packs the document's text asks for that are available and not installed: what the
/// missing-pack bar offers. Never a language without a pack, nor one proofing can't use.
pub fn missing(s: &Session) -> Vec<Pack> {
@@ -630,7 +716,7 @@ impl FixedBackend {
locale: locale.into(),
name: name.into(),
version: "0000000".into(),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-1/{locale}.tar.zst"),
url: format!("https://github.com/storytold/craft-i18n/releases/download/language-packs-2/{locale}.tar.zst"),
source: Some("https://github.com/LibreOffice/dictionaries".into()),
size,
licence: licence.into(),
@@ -713,6 +799,29 @@ mod tests {
}
}
/// FU.3: the status bar's "(no dictionary)" asks every frame whether the index has a pack
/// for the caret's language; the answer is reused until the index changes (a new
/// generation) or the tag does, so the index isn't copied every frame.
#[test]
fn the_pack_lookup_is_reused_until_the_index_changes() {
let mut lookup = super::PackLookup::default();
let mut computed = 0;
let mut ask = |lookup: &mut super::PackLookup, generation: u64, tag: &str, answer: bool| {
lookup.get(generation, tag, || {
computed += 1;
answer
})
};
assert!(ask(&mut lookup, 1, "ja-JP", true));
// Same generation and tag: the remembered answer, even where the list would now say no.
for _ in 0..10 {
assert!(ask(&mut lookup, 1, "ja-JP", false));
}
assert!(!ask(&mut lookup, 2, "ja-JP", false), "a new generation works it out again");
assert!(ask(&mut lookup, 2, "th-TH", true), "another tag too");
assert_eq!(computed, 3);
}
#[test]
fn the_default_build_embeds_only_english() {
const CARRY_OVER: [&str; 5] = ["de", "pl", "pt", "it", "ru"];
+4 -4
View File
@@ -9,13 +9,13 @@ use craft_font_store::{FontStore, Network, PackEntry, PackIndex, PackIndexPin};
use super::{Cancel, Pack};
/// The pack index this build trusts: craft-i18n's first Language pack release, language-packs-1
/// (61 packs, built from craft-i18n `4504e288`), pinned by the sha256 of its `index.json`.
/// The pack index this build trusts: craft-i18n's second Language pack release,
/// language-packs-2 (75 packs, built from craft-i18n `20467b63`), pinned by the sha256 of its `index.json`.
/// `CRAFT_LANGUAGE_PACK_INDEX` (read by the font store itself) points WordCraft at a local or test
/// index instead, for trying packs before they are released.
pub const PACK_INDEX: PackIndexPin = PackIndexPin {
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-1/index.json",
sha256: "531465b50126d0594bb89a9c4512f7ae67d581259636040a410190641ec1428c",
url: "https://github.com/storytold/craft-i18n/releases/download/language-packs-2/index.json",
sha256: "b35c7aef0591177f18233b551da16035dcd615549ff874e169ffd19f16fea74b",
};
pub(super) struct FontStoreBackend {
+33 -3
View File
@@ -80,12 +80,12 @@ impl Utterance {
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
let story = from.story;
// Spans to leave out: whole range fields, both markers included.
let skipped: Vec<(Pos, Pos)> = if skip_citations {
let skipped = Spans::new(if skip_citations {
doc.field_ranges(story).into_iter().filter(|r| is_citation_code(&r.instr)).map(|r| (r.start, r.end)).collect()
} else {
Vec::new()
};
let in_skipped = |p: &Pos| skipped.iter().any(|(a, b)| a <= p && p <= b);
});
let in_skipped = |p: &Pos| skipped.contains(p);
let mut out = Vec::new();
for path in doc.para_paths(story) {
if path < from.path {
@@ -156,6 +156,36 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
out
}
/// Spans of the document (both ends included), sorted and merged so a position is looked up by
/// binary search: checking every character against every citation was quadratic in a document
/// with many of them.
struct Spans(Vec<(Pos, Pos)>);
impl Spans {
fn new(mut spans: Vec<(Pos, Pos)>) -> Self {
spans.retain(|(a, b)| a <= b);
spans.sort();
let mut merged: Vec<(Pos, Pos)> = Vec::with_capacity(spans.len());
for (a, b) in spans {
match merged.last_mut() {
Some(last) if a <= last.1 => {
if b > last.1 {
last.1 = b;
}
}
_ => merged.push((a, b)),
}
}
Spans(merged)
}
/// Is `p` inside one of the spans?
fn contains(&self, p: &Pos) -> bool {
let after = self.0.partition_point(|(a, _)| a <= p);
after.checked_sub(1).and_then(|i| self.0.get(i)).is_some_and(|(_, b)| p <= b)
}
}
/// The language of `text` from its script, as a BCP 47 primary tag: Cyrillic is Russian (Ukrainian
/// with one of its letters `іїєґ`), Greek, Arabic, Hebrew, Thai and Hangul map to `el`, `ar`, `he`, `th`
/// and `ko`, kana to Japanese, Han letters alone to Chinese. `None` for Latin text (the script
+32
View File
@@ -364,3 +364,35 @@ fn macos_voice_matches_the_language() {
assert_eq!(mac_voice("ru", "", "en"), None);
assert_eq!(mac_voice("ru", "garbage line\n\n \n", "en"), None);
}
#[test]
fn skipped_spans_match_a_scan_of_every_span() {
// Overlapping, nested, touching, empty-range and reversed spans.
let raw = vec![
(Pos::body(0, 5), Pos::body(0, 9)),
(Pos::body(0, 7), Pos::body(0, 12)),
(Pos::body(0, 20), Pos::body(0, 20)),
(Pos::body(0, 30), Pos::body(2, 3)),
(Pos::body(1, 4), Pos::body(1, 8)),
(Pos::body(3, 9), Pos::body(3, 2)),
(Pos::body(4, 0), Pos::body(4, 1)),
(Pos::body(4, 2), Pos::body(4, 3)),
];
let spans = Spans::new(raw.clone());
for block in 0..6 {
for off in 0..40 {
let p = Pos::body(block, off);
let scan = raw.iter().any(|(a, b)| *a <= p && p <= *b);
assert_eq!(spans.contains(&p), scan, "{p:?}");
}
}
}
#[test]
fn many_citations_are_looked_up_quickly() {
// 50,000 disjoint spans and 200,000 lookups: a scan of every span per position would take
// ten billion comparisons.
let spans = Spans::new((0..50_000).map(|i| (Pos::body(0, i * 4), Pos::body(0, i * 4 + 1))).collect());
let hits = (0..200_000).filter(|&off| spans.contains(&Pos::body(0, off))).count();
assert_eq!(hits, 100_000);
}
+222 -2
View File
@@ -4043,7 +4043,8 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
blocks = Some(s.doc.body.len());
}
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
// The field's paragraph ends the one-column section: replacing the TOC keeps it, and
// removing it takes the break with the title, as Word does (#4041).
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
assert_eq!(columns(&s), [1, 2, 1]);
// The heading goes with the table: one "Contents" after Replace, none after Remove.
@@ -4052,10 +4053,130 @@ fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table()
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(titles(&s), 1, "{}", text(&s));
run(&mut s, "references.removeToc", json!({}));
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
assert_eq!(columns(&s), [2, 1], "{}", text(&s));
assert_eq!(titles(&s), 0, "{}", text(&s));
}
/// #4041: Word's TOC over entries in columns (#3889): its control holds the TOC Heading title in a
/// one-column section, a blank paragraph ending that section, and the entries in a continuous
/// two-column section. `title_style` is the title paragraph's style. Update Table has run.
fn word_toc_above_a_section_break(title_style: &str) -> Session {
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
let sect = |cols: &str, kind: &str| format!(r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:cols {cols} w:space="708"/></w:sectPr>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let toc = [
p(title_style, &t("Contents")),
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
p("TOC1", &format!("{begin}{}", t("Chapter One\t2"))),
p("TOC2", &t("Section\t2")),
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
]
.concat();
let body = [
format!(
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
),
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, r#"<w:type w:val="continuous"/>"#)),
p("Heading1", &t("Chapter One")),
p("Heading2", &t("Section")),
sect("", ""),
]
.concat();
let styles = [
style_xml("Heading1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
style_xml("Heading2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
style_xml("TOCHeading", "TOC Heading", "", ""),
style_xml("TOC1", "toc 1", "", ""),
style_xml("TOC2", "toc 2", r#"<w:ind w:left="220"/>"#, ""),
]
.concat();
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
run(&mut s, "references.updateToc", json!({}));
s
}
/// The body's paragraphs as (style, ends a section, text), and each section's column count.
fn toc_layout(s: &Session) -> (Vec<(String, bool, String)>, Vec<u32>) {
let paras = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.props.style.clone().unwrap_or_default(), p.section.is_some(), p.plain_text()))
.collect();
(paras, s.doc.sections().iter().map(|(_, x)| x.columns.count).collect())
}
/// #4041: replacing Word's TOC whose title sits above a section break keeps the title above the
/// break and the entries in their columns, as Word's Insert Table of Contents does (observed in
/// Word for Mac: the field takes the new settings; the title, the blank paragraph and its break
/// stay). Replacing again finds the same title; a given title renames it; undo restores the table.
#[test]
fn replacing_a_toc_keeps_its_title_above_the_section_break() {
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
let laid_out = |title: &str| {
vec![
row("TOCHeading", false, title),
row("", true, ""),
row("TOC1", false, "Chapter One\t2"),
row("TOC2", false, "Section\t2"),
row("", true, ""),
row("Heading1", false, "Chapter One"),
row("Heading2", false, "Section"),
]
};
let mut s = word_toc_above_a_section_break("TOCHeading");
let before = toc_layout(&s);
assert_eq!(before, (laid_out("Contents"), vec![1, 2, 1]), "{}", text(&s));
run(&mut s, "references.toc", json!({"levels": 1}));
let mut one_level = laid_out("Contents");
one_level.remove(3);
assert_eq!(toc_layout(&s), (one_level, vec![1, 2, 1]), "{}", text(&s));
// The field took the new settings.
let instr = s.doc.body.get(1).and_then(|b| b.as_para()).and_then(|p| {
p.objects.iter().find_map(|o| match o {
wordcraft_doc::para::InlineObject::Field { instr, .. } => Some(instr.clone()),
_ => None,
})
});
assert!(instr.as_deref().is_some_and(|i| i.contains(r#"\o "1-1""#)), "{instr:?}");
run(&mut s, "references.toc", json!({"levels": 2, "title": "Table of Contents"}));
assert_eq!(toc_layout(&s), (laid_out("Table of Contents"), vec![1, 2, 1]), "{}", text(&s));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(toc_layout(&s), before, "{}", text(&s));
}
/// #4041: removing Word's TOC whose title sits above a section break takes the title, the blank
/// paragraph ending its section and that break with the entries, as Word's Remove Table of
/// Contents does (observed in Word for Mac): the empty paragraph after the table, ending the
/// two-column section, stays, so the headings keep their own section. A Normal paragraph above
/// the break isn't the TOC's title: it and the break stay.
#[test]
fn removing_a_toc_takes_its_title_and_the_section_break_under_it() {
let row = |st: &str, sect: bool, t: &str| (st.to_string(), sect, t.to_string());
let mut s = word_toc_above_a_section_break("TOCHeading");
let before = toc_layout(&s);
run(&mut s, "references.removeToc", json!({}));
let headings = vec![row("", true, ""), row("Heading1", false, "Chapter One"), row("Heading2", false, "Section")];
assert_eq!(toc_layout(&s), (headings, vec![2, 1]), "{}", text(&s));
run(&mut s, "edit.undo", json!({}));
assert_eq!(toc_layout(&s), before, "{}", text(&s));
// Replace, then Remove: the same.
run(&mut s, "references.toc", json!({"levels": 2}));
run(&mut s, "references.removeToc", json!({}));
let (paras, columns) = toc_layout(&s);
assert_eq!(columns, vec![2, 1], "{}", text(&s));
assert!(paras.iter().all(|(st, _, t)| !st.starts_with("TOC") && t != "Contents"), "{paras:?}");
let mut s = word_toc_above_a_section_break("Normal");
run(&mut s, "references.removeToc", json!({}));
let (paras, columns) = toc_layout(&s);
assert_eq!(columns, vec![1, 2, 1], "{}", text(&s));
assert!(paras.first().is_some_and(|(_, _, t)| t == "Contents"), "{paras:?}");
}
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
/// range TOC doesn't claim the menu, so it never refreshes a different table.
#[test]
@@ -11761,3 +11882,102 @@ fn text_from_file_brings_the_files_pictures() {
assert_ne!(listed[0], our_list);
assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet));
}
#[test]
fn page_down_and_up_move_by_a_page_in_the_continuous_views() {
// Web Layout, Draft and Outline are one long page: Page Down/Up move as far as they do
// between the pages of Print Layout (#5290).
let after_page_down = |view: &str| {
let mut s = s();
for i in 0..200 {
run(&mut s, "text.insert", json!({"text": format!("Paragraph {i}")}));
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, view, json!({}));
run(&mut s, "caret.docStart", json!({}));
let start = s.sel.focus.clone();
run(&mut s, "caret.pageDown", json!({}));
let down = s.sel.focus.clone();
run(&mut s, "caret.pageUp", json!({}));
assert_eq!(s.sel.focus, start, "{view}: Page Up goes back");
run(&mut s, "caret.docEnd", json!({}));
let end = s.sel.focus.clone();
run(&mut s, "caret.pageDown", json!({}));
assert_eq!(s.sel.focus, end, "{view}: Page Down stops at the end");
down
};
let print = after_page_down("view.printLayout");
assert!(print.path.0[0] > 10, "Print Layout: Page Down goes to the next page: {print:?}");
for view in ["view.draft", "view.outline", "view.webLayout"] {
assert_eq!(after_page_down(view), print, "{view}");
}
}
#[test]
fn page_down_and_up_never_stay_on_a_line_in_the_continuous_views() {
// A page with less than an inch of text area: the views move by the one-inch minimum. The
// caret ends a long line and the paragraphs after it are narrow (a large right indent): the
// line a page away, not the one the caret is on, is where Page Down goes, however far right
// of it the caret is.
for view in ["view.draft", "view.outline", "view.webLayout"] {
let mut s = s();
run(&mut s, "layout.margins", json!({"top": 370, "bottom": 370}));
let long = "Lorem ipsum dolor sit amet ".repeat(4);
run(&mut s, "text.insert", json!({"text": long}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "para.indents", json!({"right": 700}));
for i in 0..60 {
if i > 0 {
run(&mut s, "text.newParagraph", json!({}));
}
run(&mut s, "text.insert", json!({"text": "a"}));
}
run(&mut s, view, json!({}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "caret.end", json!({}));
assert_eq!(s.sel.focus.off, long.chars().count(), "{view}: the first paragraph is one line");
let mut at = s.sel.focus.clone();
for step in 0..3 {
run(&mut s, "caret.pageDown", json!({}));
assert!(s.sel.focus > at, "{view}: Page Down {step} moves on from {at:?}");
at = s.sel.focus.clone();
}
for step in 0..3 {
run(&mut s, "caret.pageUp", json!({}));
assert!(s.sel.focus < at, "{view}: Page Up {step} moves back from {at:?}");
at = s.sel.focus.clone();
}
}
}
#[test]
fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
// Selecting a collapsed heading and its content ends the selection in hidden text; Page Down
// still extends it from where it shows (the heading), keeping its anchor.
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First"}));
for i in 0..5 {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": format!("Hidden {i}")}));
}
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
for i in 0..200 {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": format!("Shown {i}")}));
}
run(&mut s, "view.outline", json!({}));
for block in [0, 6] {
run(&mut s, "caret.set", json!({"pos": {"block": block}}));
run(&mut s, "outline.promoteToHeading1", json!({}));
}
run(&mut s, "outline.collapse", json!({"block": 0}));
run(&mut s, "outline.selectSubtree", json!({"block": 0}));
let anchor = s.sel.anchor.clone();
assert_eq!(s.sel.focus.path.0, [5], "the selection ends in the last hidden paragraph");
assert!(s.layout().caret(&s.sel.focus).is_none(), "the outline doesn't show its end");
run(&mut s, "caret.pageDown", json!({"extend": true}));
assert_eq!(s.sel.anchor, anchor, "the anchor stays");
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
+3 -1
View File
@@ -56,7 +56,9 @@ fn craft_fonts(dir: &std::path::Path) -> Result<(String, Vec<String>), 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}"))?;
+1 -1
View File
@@ -35,7 +35,7 @@ pub(crate) use names::face_names;
pub use names::{LocalName, base_style, name_language_matches, system_font_dirs};
use names::{MAX_FILE_FACES, enumerate_faces, make_face};
#[cfg(test)]
use names::{file_face_names, font_faces, mini_font, name};
use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
+10 -3
View File
@@ -61,8 +61,16 @@ pub(super) struct CatalogEntry {
/// The longest name a document's font table gives an embedded font that faces take on.
pub(super) const MAX_FAMILY_NAME: usize = 128;
/// The longest name read from a font's `name` table, in characters. A record can hold 64K
/// characters, and each face copies its family into its localized names and into every named
/// instance, so a small font could claim hundreds of megabytes of names. Real names run to a
/// few dozen characters.
pub(super) const MAX_NAME_CHARS: usize = 128;
pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
ids.iter().find_map(|id| {
font.localized_strings(*id).english_or_first().map(|s| s.chars().take(MAX_NAME_CHARS).collect::<String>()).filter(|s| !s.is_empty())
})
}
/// The family's names in other languages: localized typographic family names (name ID 16) stand
@@ -70,7 +78,6 @@ pub(super) fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<Strin
/// font has no typographic names). Bounded: a damaged font can list any number of records.
fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<LocalName> {
const MAX_NAMES: usize = 64;
const MAX_CHARS: usize = 128;
let typographic = f.localized_strings(StringId::TYPOGRAPHIC_FAMILY_NAME).next().is_some();
let legacy = if typographic { id1 } else { Some(family) };
let mut out: Vec<LocalName> = Vec::new();
@@ -81,7 +88,7 @@ fn local_names(f: &skrifa::FontRef<'_>, family: &str, id1: Option<&str>) -> Vec<
if lang == "en" || lang.starts_with("en-") {
continue;
}
let name: String = s.chars().take(MAX_CHARS).collect::<String>().trim().to_string();
let name: String = s.chars().take(MAX_NAME_CHARS).collect::<String>().trim().to_string();
if name.is_empty() || name.eq_ignore_ascii_case(english) || out.iter().any(|n| n.lang == lang && n.name == name) {
continue;
}
+34 -4
View File
@@ -245,15 +245,24 @@ fn postscript_names_select_faces() {
/// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis
/// and `instances` named instances, each with its own style name.
fn variable_font(instances: u16) -> Vec<u8> {
let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
variable_font_named("Var Test", &[], instances)
}
/// [`variable_font`] with family `family`, and family names in other languages `local`
/// (Windows language ids).
fn variable_font_named(family: &str, local: &[(u16, &str)], instances: u16) -> Vec<u8> {
let mut names: Vec<(u16, u16, String)> = vec![(0x409, 1, family.into()), (0x409, 2, "Regular".into())];
names.extend(local.iter().map(|(lang, n)| (*lang, 1, n.to_string())));
names.extend((0..instances).map(|i| (0x409, 256 + i, format!("Style {i}"))));
// Records sorted by language, then name id.
names.sort_by_key(|(lang, id, _)| (*lang, *id));
let mut name = vec![0, 0];
name.extend_from_slice(&(names.len() as u16).to_be_bytes());
name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes());
let mut strings = Vec::new();
for (id, text) in &names {
for (lang, id, text) in &names {
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
for v in [3, 1, *lang, *id, utf16.len() as u16, strings.len() as u16] {
name.extend_from_slice(&v.to_be_bytes());
}
strings.extend(utf16);
@@ -357,6 +366,27 @@ fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() {
assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements());
}
#[test]
fn long_names_are_cut_before_faces_copy_them() {
// A name record can hold 64K characters, and every named instance copies the family and its
// localized names: the names are cut when read, so the copies stay small.
let long = "W".repeat(14_000);
let local = "Ж".repeat(14_000);
let font = variable_font_named(&long, &[(0x419, &local)], 32);
let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0);
assert_eq!(faces.len(), 32);
for f in &faces {
assert_eq!(f.family.chars().count(), MAX_NAME_CHARS);
assert!(f.legacy.chars().count() <= MAX_NAME_CHARS + " Style 31".len(), "{}", f.legacy.len());
assert_eq!(f.local.len(), 1);
assert_eq!(f.local[0].name.chars().count(), MAX_NAME_CHARS);
assert_eq!(f.local[0].english, f.family);
}
// Short names are kept whole.
let faces = font_faces(&skrifa::FontRef::new(&variable_font_named("Var Test", &[(0x419, "Вар Тест")], 2)).unwrap(), 0);
assert_eq!((faces[0].family.as_str(), faces[0].local[0].name.as_str()), ("Var Test", "Вар Тест"));
}
#[test]
fn named_instances_and_collection_members_are_capped() {
// #3716: a file's faces are capped before they are built, however the file multiplies them.
+98 -20
View File
@@ -154,6 +154,7 @@ pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
unwrap_web_font(|| wuff::decompress_woff1(bytes))?
}
FontFormat::Woff2 => {
check_woff2_collection(bytes)?;
let mut too_many_points = false;
let unwrapped = unwrap_web_font(|| {
wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| {
@@ -221,8 +222,29 @@ fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool {
/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file`
/// (tables follow each other in directory order).
fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [u8]>> {
let (tables, _) = woff2_directory(file)?;
let (mut offset, mut glyf) = (0usize, Vec::new());
for t in tables {
let end = offset.checked_add(t.len)?;
if t.transformed_glyf {
glyf.push(stream.get(offset..end)?);
}
offset = end;
}
Some(glyf)
}
/// A WOFF2 table directory entry: whether it is a transformed `glyf` table, and its length in
/// the decompressed stream.
struct Woff2Table {
transformed_glyf: bool,
len: usize,
}
/// The WOFF2 table directory in `file`, and where it ends (the collection directory follows).
fn woff2_directory(file: &[u8]) -> Option<(Vec<Woff2Table>, usize)> {
let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?);
let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new());
let (mut at, mut out) = (48usize, Vec::with_capacity(usize::from(tables)));
for _ in 0..tables {
let flags = *file.get(at)?;
at += 1;
@@ -238,13 +260,81 @@ fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [
// glyf and loca are transformed at version 0, other tables at any other version.
let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 };
let len = if transformed { base128(file, &mut at)? } else { orig };
let end = offset.checked_add(len)?;
if is_glyf && transformed {
glyf.push(stream.get(offset..end)?);
}
offset = end;
out.push(Woff2Table { transformed_glyf: is_glyf && transformed, len });
}
Some(glyf)
Some((out, at))
}
/// Largest table directories a WOFF2 collection may rebuild, in bytes. Each font's directory is
/// 16 bytes per table it lists, while the file spends as little as one byte per listed table, so
/// a collection listing the same tables over and over rebuilt hundreds of megabytes of
/// directories (twice: built, then copied into the font) before the decoder's output cap was
/// checked. Real collections need kilobytes.
const MAX_WOFF2_COLLECTION_HEADER: usize = 16 << 20;
/// Why a WOFF2 collection whose directories are too large, or list a table twice, is refused.
const BAD_COLLECTION: &str = "the collection's font directories are too large or list a table twice";
/// WOFF2 collections: the table directories the decoder rebuilds for its fonts must fit
/// [`MAX_WOFF2_COLLECTION_HEADER`], and no font may list a table twice (OpenType allows one
/// table per tag in a font). Checked before decoding, since the decoder builds them before it
/// checks its output size. A directory that doesn't parse is left to the decoder to report.
fn check_woff2_collection(file: &[u8]) -> Result<(), UserFontError> {
if file.get(4..8) != Some(b"ttcf".as_slice()) {
return Ok(());
}
match woff2_collection_fits(file) {
Some(false) => Err(UserFontError::BadWebFont(BAD_COLLECTION.into())),
_ => Ok(()),
}
}
/// Do the collection's rebuilt table directories fit, with no table listed twice in one font?
/// None when the directories don't parse.
fn woff2_collection_fits(file: &[u8]) -> Option<bool> {
let (tables, mut at) = woff2_directory(file)?;
let version = u32::from_be_bytes(file.get(at..at.checked_add(4)?)?.try_into().ok()?);
at += 4;
let fonts = read_255_u16(file, &mut at)?;
// TTC header (with the DSIG fields of version 2) and one offset per font.
let dsig = if version == 0x0002_0000 { 12 } else { 0 };
let mut size = 12 + dsig + 4 * fonts;
let mut indices: Vec<usize> = Vec::new();
for _ in 0..fonts {
let n = read_255_u16(file, &mut at)?;
size = size.saturating_add(12 + 16 * n);
if size > MAX_WOFF2_COLLECTION_HEADER {
return Some(false);
}
at = at.checked_add(4)?; // flavor
indices.clear();
for _ in 0..n {
let i = read_255_u16(file, &mut at)?;
if i >= tables.len() {
return None;
}
indices.push(i);
}
indices.sort_unstable();
if indices.windows(2).any(|w| w[0] == w[1]) {
return Some(false);
}
}
Some(true)
}
/// WOFF2's 255UInt16.
fn read_255_u16(b: &[u8], at: &mut usize) -> Option<usize> {
let mut byte = || {
*at += 1;
b.get(*at - 1).copied().map(usize::from)
};
Some(match byte()? {
253 => byte()? << 8 | byte()?,
254 => byte()? + 253 * 2,
255 => byte()? + 253,
v => v,
})
}
/// WOFF2's UIntBase128.
@@ -278,19 +368,7 @@ fn glyf_points_fit(t: &[u8]) -> Option<bool> {
let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?);
let mut total = 0usize;
for _ in 0..if n == 0xFFFF { 0 } else { n } {
// 255UInt16
let code = *points.get(at)?;
at += 1;
let mut byte = || {
at += 1;
points.get(at - 1).copied().map(usize::from)
};
total += match code {
253 => byte()? << 8 | byte()?,
254 => byte()? + 253 * 2,
255 => byte()? + 253,
v => usize::from(v),
};
total += read_255_u16(points, &mut at)?;
if total > 1 << 16 {
return Some(false);
}
+47
View File
@@ -455,3 +455,50 @@ fn fonts_stored_at_once_under_one_name_each_get_their_own_file() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A WOFF2 collection of `tables` untransformed tables whose fonts list the tables in `fonts`.
fn woff2_collection(tables: usize, fonts: &[Vec<u16>]) -> Vec<u8> {
fn u255(v: u16, out: &mut Vec<u8>) {
match v {
0..=252 => out.push(v as u8),
_ => {
out.push(253);
out.extend_from_slice(&v.to_be_bytes());
}
}
}
let mut dir = Vec::new();
for i in 0..tables {
dir.push(63);
dir.extend_from_slice(&(i as u32).to_be_bytes()); // a made-up tag per table
base128(4, &mut dir);
}
dir.extend_from_slice(&0x0001_0000u32.to_be_bytes());
u255(fonts.len() as u16, &mut dir);
for font in fonts {
u255(font.len() as u16, &mut dir);
dir.extend_from_slice(&[0, 1, 0, 0]);
for &i in font {
u255(i, &mut dir);
}
}
woff2_file(b"ttcf", tables, &dir, &vec![0; 4 * tables], 4 * tables + 1024)
}
#[test]
fn a_woff2_collection_rebuilding_huge_directories_is_refused_before_decoding() {
// Each font's rebuilt directory takes 16 bytes per table it lists, the file about two: 600
// fonts listing 2,000 tables each come to 19 MB of directories from a 2.4 MB file.
let all: Vec<u16> = (0..2_000).collect();
let file = woff2_collection(2_000, &vec![all; 600]);
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
}
#[test]
fn a_woff2_collection_font_listing_a_table_twice_is_refused() {
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 1]]);
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
// Fonts sharing tables are what collections are for: passed on to the decoder.
let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 2]]);
assert_ne!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into())));
}
+41 -1
View File
@@ -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<Item = &'static CraftFont> {
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());
+33
View File
@@ -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<SystemUiFont> {
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<N>`),
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.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);
}
+23 -8
View File
@@ -207,7 +207,8 @@ fn worst_coefficients(bytes: &[u8]) -> u64 {
}
/// The compression of a classic TIFF's first image (the one the image crate decodes): 1 when the
/// tag is missing. None when the header can't be read.
/// tag is missing. None when the header can't be read, or when the tag appears more than once:
/// the decoder keeps the last copy, so a harmless first one must not hide a JPEG one after it.
fn tiff_compression(bytes: &[u8]) -> Option<u16> {
let le = match bytes.get(..4)? {
b"II*\0" => true,
@@ -223,21 +224,22 @@ fn tiff_compression(bytes: &[u8]) -> Option<u16> {
Some(if le { u32::from_le_bytes(b) } else { u32::from_be_bytes(b) })
};
let ifd = usize::try_from(u32_at(4)?).ok()?;
let mut found = None;
for i in 0..usize::from(u16_at(ifd)?) {
let entry = ifd.checked_add(2)?.checked_add(i.checked_mul(12)?)?;
if u16_at(entry)? != 259 {
continue;
}
if u32_at(entry.checked_add(4)?)? != 1 {
if found.is_some() || u32_at(entry.checked_add(4)?)? != 1 {
return None;
}
return match u16_at(entry.checked_add(2)?)? {
3 => u16_at(entry.checked_add(8)?),
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok(),
_ => None,
};
found = Some(match u16_at(entry.checked_add(2)?)? {
3 => u16_at(entry.checked_add(8)?)?,
4 => u16::try_from(u32_at(entry.checked_add(8)?)?).ok()?,
_ => return None,
});
}
Some(1)
Some(found.unwrap_or(1))
}
/// A DIB with a V4 or V5 header in the layout the decoder reads. A 32-bit one whose alpha mask
@@ -410,6 +412,19 @@ mod tests {
assert_eq!(tiff_compression(&be), Some(7), "big-endian: the short is left-justified");
be.truncate(12);
assert_eq!(tiff_compression(&be), None, "cut short");
// A second compression tag (the one the decoder keeps) can't hide behind an allowed first.
let mut twice = b"II*\0".to_vec();
twice.extend(8u32.to_le_bytes());
twice.extend(2u16.to_le_bytes());
for compression in [1u32, 7] {
twice.extend(259u16.to_le_bytes());
twice.extend(3u16.to_le_bytes());
twice.extend(1u32.to_le_bytes());
twice.extend(compression.to_le_bytes());
}
twice.extend(0u32.to_le_bytes());
assert_eq!(tiff_compression(&twice), None, "duplicate compression tags");
assert!(matches!(probe(&twice), Err(Refusal::Unsupported(_))));
// An uncompressed TIFF reads as before.
let tiff = encode(image::RgbImage::from_pixel(5, 3, image::Rgb([9, 8, 7])), ImageFormat::Tiff);
assert_eq!(tiff_compression(&tiff), Some(1));
+23 -12
View File
@@ -285,6 +285,13 @@ pub fn page_lines(page: &Page, story: StoryRef) -> Vec<LineHit<'_>> {
v
}
/// Whether line `l` is above (`dir < 0`) or below the caret `c`: past the caret's middle, and
/// not the caret's own line.
fn beyond(l: &LineHit, c: &Caret, dir: i32) -> bool {
let (mid, cur_mid) = ((l.top + l.bottom) / 2.0, c.top + c.height / 2.0);
if dir < 0 { mid < cur_mid - 1.0 && l.bottom <= cur_mid } else { mid > cur_mid + 1.0 && l.top >= cur_mid }
}
/// Of `lines`, the one nearest the caret moving up (`dir < 0`) or down: the lowest above it or
/// the highest below it.
fn nearest<'a, 'b>(lines: &[&'b LineHit<'a>], dir: i32) -> Option<&'b LineHit<'a>> {
@@ -635,18 +642,7 @@ impl DocLayout {
let gx = goal_x.unwrap_or(c.x);
let page = self.pages.get(c.page)?;
let lines = page_lines(page, pos.story);
let cur_mid = c.top + c.height / 2.0;
let cands: Vec<&LineHit> = lines
.iter()
.filter(|l| {
let mid = (l.top + l.bottom) / 2.0;
if dir < 0 {
mid < cur_mid - 1.0 && l.bottom <= c.top + c.height * 0.5
} else {
mid > cur_mid + 1.0 && l.top >= c.top + c.height * 0.5
}
})
.collect();
let cands: Vec<&LineHit> = lines.iter().filter(|l| beyond(l, &c, dir)).collect();
let across: Vec<&LineHit> = cands.iter().copied().filter(|l| gx >= l.left - 2.0 && gx <= l.right + 2.0).collect();
let target = match nearest(&across, dir) {
// The nearest line across `gx`, unless lines between the caret's paragraph and that
@@ -676,6 +672,21 @@ impl DocLayout {
Some((Pos { story: l.story, path: l.path.clone(), off }, gx))
}
/// Page Up (`dir < 0`) or Page Down within the caret's page: of the lines above (or below)
/// the caret at `pos`, the one nearest page height `y`, on a tie the one horizontally nearest
/// page x `x`; the position at `x` on it. Unlike [`DocLayout::hit`], how far the lines are
/// sideways never keeps the caret on its own line. `None` when no line is that way.
pub fn line_toward(&self, pos: &Pos, page_hint: usize, x: f32, y: f32, dir: i32) -> Option<Pos> {
let c = self.caret_on(pos, page_hint)?;
let page = self.pages.get(c.page)?;
let lines = page_lines(page, pos.story);
let dy = |l: &LineHit| band_dist(y, l.top, l.bottom - l.top);
let dx = |l: &LineHit| (l.left - x).max(x - l.right).max(0.0);
let l = lines.iter().filter(|l| beyond(l, &c, dir)).min_by(|a, b| dy(a).total_cmp(&dy(b)).then(dx(a).total_cmp(&dx(b))))?;
let off = l.para.off_at_x(l.li, x - l.x);
Some(Pos { story: l.story, path: l.path.clone(), off })
}
/// Start and end of the visual line holding `pos` (Home / End).
pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
+54 -2
View File
@@ -46,8 +46,37 @@ pub fn paragraph_issues(
}
let text = proof_text(p);
let spans: Vec<(std::ops::Range<usize>, 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<usize>, 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());
}
}
+3
View File
@@ -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,
+14 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+159 -85
View File
@@ -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<PersonalTemp
/// click makes a new document from it.
fn personal_tile(ui: &mut Ui, app: &mut WordApp, p: &PersonalTemplate) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
crate::rtl::vertical(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.set_max_width(150.0);
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
@@ -568,15 +595,27 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
let names = shown.iter().map(|p| file_name(p)).collect::<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)]));
});
}
+1 -1
View File
@@ -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 };
+24 -18
View File
@@ -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!({}));
+293 -250
View File
@@ -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()));
}
}
})
});
});
}
+11
View File
@@ -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");
}
+4 -4
View File
@@ -497,14 +497,14 @@ fn body(ui: &mut Ui, d: &mut ColorsDialog) -> Option<bool> {
});
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<bool> {
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 {
+1 -1
View File
@@ -230,7 +230,7 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
// 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("");
+34 -30
View File
@@ -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();
}
});
})
});
}
+6 -6
View File
@@ -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();
});
});
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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());
@@ -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();
+1 -1
View File
@@ -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) {
@@ -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);
});
@@ -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));
@@ -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)));
@@ -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;
}
+1 -1
View File
@@ -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) {
@@ -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| {
@@ -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();
@@ -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<String>) {
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();
@@ -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;
}
+16 -14
View File
@@ -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));
+11 -8
View File
@@ -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") {
+7 -7
View File
@@ -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<bool> 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<Rgb>, 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<Rg
}
});
ui.add_space(8.0);
ui.vertical(|ui| {
crate::rtl::vertical(ui, |ui| {
ui.label(tl!("Style:"));
let current = STYLES.iter().find(|(s, _)| *s == l.style).map(|(_, n)| *n).unwrap_or("Single");
craft_ui::ComboBox::from_id_salt((id, "style")).selected_text(tl!(current)).width(120.0).show_ui(ui, |ui| {
@@ -650,7 +650,7 @@ fn lines(ui: &mut Ui, id: &str, l: &mut LineForm, theme: &[Rgb], fill: Option<Rg
});
});
ui.add_space(8.0);
ui.vertical(|ui| {
crate::rtl::vertical(ui, |ui| {
ui.label(egui::RichText::new(tl!("Preview")).font(semibold(12.5)));
if let Some(side) = preview(ui, l, fill, target) {
l.toggle(side);
@@ -105,7 +105,7 @@ pub(super) fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) ->
});
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();
@@ -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();
@@ -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;
}
@@ -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<u
});
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)) {
next = Next::Close;
}
@@ -145,7 +145,7 @@ pub(super) fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, wa
// A solid scroll bar takes its own room beside the list instead of floating over the level boxes.
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().max_height(180.0).show(ui, |ui| {
egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("toc_styles").num_columns(2).spacing(vec2(10.0, 3.0)), |ui| {
for (name, level) in f.style_levels.iter_mut() {
// Built-in styles read in the interface language; the document keeps the English name.
let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name);
+12 -12
View File
@@ -67,7 +67,7 @@ fn buttons(ui: &mut Ui, 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"))),
@@ -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;
+12 -12
View File
@@ -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() {
@@ -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);
@@ -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() {
@@ -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();
});
});
+167 -129
View File
@@ -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) {
+3 -3
View File
@@ -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);
+7 -7
View File
@@ -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);
+4 -5
View File
@@ -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);
+15 -11
View File
@@ -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;
}
});
});
}
+6
View File
@@ -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<String>, 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,
};
+11
View File
@@ -22,6 +22,17 @@ pub(crate) struct Parked {
}
impl WordApp {
/// Replace the document the window opened with before anything else happens (the first blank
/// document, made again on the region File › Options chose): the session's document is
/// swapped and the window follows it at once. Replacing it with `session.set_document`
/// alone leaves the window showing the old document, so the first frame took the change for
/// a switch and dropped the dialog a command line file had just asked for, a password box
/// among them (#5299).
pub fn replace_startup_document(&mut self, doc: wordcraft_doc::Document) {
self.session.set_document(doc);
self.sync_document();
}
/// Follow the session's active document. When it isn't the one the window last showed, the
/// shown document's own state is put aside (or dropped, if it was closed or replaced), the
/// new one's comes back, and the window's state about the page that was shown starts afresh
+9 -9
View File
@@ -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<char> = chars.chars().collect();
let start = ui.data(|d| d.get_temp::<usize>(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,
+16 -4
View File
@@ -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<Option<PathBuf>>
} 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<Option<PathBuf>>
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();
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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<Fill> {
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<GradientKind> {
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) {
+59 -3
View File
@@ -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::<Vec<_>>().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
+234
View File
@@ -597,3 +597,237 @@ fn disabled_reasons() -> std::collections::BTreeSet<String> {
}
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<String> =
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);
}
+110
View File
@@ -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}");
}
+133 -8
View File
@@ -32,10 +32,33 @@ fn shown_text(text: &str) -> String {
shown_keeping(text, usize::MAX, true)
}
/// Most characters of a failure the window shows: the font store's errors can quote the index
/// URL or the server's answer, which aren't ours to trust either.
const MAX_ERROR: usize = 500;
/// A failure from the engine or the font store as shown: [`shown`], at most [`MAX_ERROR`]
/// characters.
fn shown_error(e: &str) -> String {
shown(e, MAX_ERROR)
}
fn shown_keeping(text: &str, max: usize, lines: bool) -> String {
// Line and paragraph separators (U+2028/9) break lines even where `lines` is off, and tag
// characters (U+E0000–U+E007F) are invisible text an index could hide words in.
let format = |c: char| {
(c.is_control() && !(lines && matches!(c, '\n' | '\t')))
|| matches!(c, '\u{061C}' | '\u{00AD}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}')
|| matches!(
c,
'\u{061C}'
| '\u{00AD}'
| '\u{180E}'
| '\u{200B}'..='\u{200F}'
| '\u{2028}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FEFF}'
| '\u{FFF9}'..='\u{FFFB}'
| '\u{E0000}'..='\u{E007F}'
)
};
let clean: String = text.chars().filter(|c| !format(*c)).collect();
if clean.chars().count() <= max {
@@ -165,9 +188,9 @@ fn error_of(app: &WordApp, locale: &str) -> Option<String> {
}
let e = packs::error(locale)?;
Some(if locale.is_empty() {
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)])
crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))])
} else {
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)])
crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))])
})
}
@@ -184,7 +207,7 @@ fn consent(app: &mut WordApp, ctx: &egui::Context, key: &str) -> bool {
return false;
}
if let Err(e) = backend.enable_network() {
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(key.to_string(), crate::i18n::fmt(tl!("Couldn't turn on downloading: {error}"), &[("error", &shown_error(&e))]));
return false;
}
true
@@ -197,7 +220,7 @@ fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) {
return;
}
if let Err(e) = packs::start_install(&pack.locale) {
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(pack.locale.clone(), crate::i18n::fmt(tl!("Couldn't install: {error}"), &[("error", &shown_error(&e))]));
}
app.packs_rt.missing = None;
}
@@ -210,7 +233,9 @@ fn start_fetch(app: &mut WordApp, ctx: &egui::Context) {
return;
}
if let Err(e) = packs::start_refresh() {
app.packs_rt.errors.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &e)]));
app.packs_rt
.errors
.insert(String::new(), crate::i18n::fmt(tl!("Couldn't get the list of languages: {error}"), &[("error", &shown_error(&e))]));
}
}
@@ -221,7 +246,7 @@ fn remove(app: &mut WordApp, locale: &str) {
app.packs_rt.missing = None;
}
Err(e) => {
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &e)]));
app.packs_rt.errors.insert(locale.to_string(), crate::i18n::fmt(tl!("Couldn't remove: {error}"), &[("error", &shown_error(&e))]));
}
}
}
@@ -312,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);
@@ -382,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));
}
@@ -556,11 +583,109 @@ mod tests {
assert_eq!(super::language_of("ja", "日本語"), "Japanese");
}
/// Tests that set the process-wide pack backend take turns.
static BACKEND_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Characters a pack index or a server's answer could use to hide, reorder or break text.
fn hostile(c: char) -> bool {
matches!(c, '\u{2028}' | '\u{2029}' | '\u{202E}' | '\u{E0000}'..='\u{E007F}') || (c.is_control() && c != '\n' && c != '\t')
}
/// FU.2: tag characters (U+E0000–U+E007F) and line/paragraph separators (U+2028/9) are
/// stripped like the other format characters, from names and from licence texts alike.
#[test]
fn shown_strips_tag_characters_and_separators() {
let name = "Ned\u{E0001}\u{E0041}\u{E007F}erlands\u{2028}\u{2029}";
assert_eq!(super::shown(name, 100), "Nederlands");
assert_eq!(super::shown_text("one\u{2028}two\nthree\u{2029}\tfour\u{E0020}"), "onetwo\nthree\tfour");
// A cut never splits what's left, and counts characters, not bytes.
assert_eq!(super::shown("\u{E0041}ab\u{2028}cd", 3), "ab…");
}
/// A store that fails with a hostile message: the index URL with a right-to-left override,
/// separators, tag characters, and far too long.
struct Hostile {
inner: FixedBackend,
message: String,
}
impl Backend for Hostile {
fn index(&self) -> Result<Vec<packs::Pack>, String> {
self.inner.index()
}
fn fetch_index(&self, _: &packs::Cancel) -> Result<Vec<packs::Pack>, String> {
Err(self.message.clone())
}
fn network_on(&self) -> bool {
self.inner.network_on()
}
fn enable_network(&self) -> Result<(), String> {
Err(self.message.clone())
}
fn install(&self, _: &packs::Pack, _: &packs::Cancel, _: &mut dyn FnMut(u64, u64)) -> Result<(), String> {
Err(self.message.clone())
}
fn remove(&self, _: &str) -> Result<(), String> {
Err(self.message.clone())
}
fn packs_dir(&self) -> Option<std::path::PathBuf> {
None
}
}
/// FU.2: the font store's failures (which can quote the index URL and the server's answer)
/// are shown through the same sanitiser as the index's text, whether the window had them
/// (turning downloading on) or the engine did (an install, the list).
#[test]
fn font_store_errors_are_sanitised() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
let _reset = Reset;
let message = format!("https://evil.example/\u{202E}gnp.exe\u{2028}second line\u{E0041}\u{E0042}{}", "x".repeat(5000));
let backend = std::sync::Arc::new(Hostile { inner: FixedBackend::sample(), message });
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let ctx = egui::Context::default();
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let clean = |shown: &str| {
assert!(!shown.chars().any(hostile), "{shown:?}");
assert!(shown.contains("https://evil.example/gnp.exe"), "{shown:?}");
assert!(shown.chars().count() < super::MAX_ERROR + 100, "capped: {} characters", shown.chars().count());
};
// Turning downloading on fails: the window's own failure.
assert!(!super::consent(&mut a, &ctx, "nl"));
clean(&super::error_of(&a, "nl").expect("a failure is shown"));
// An install and the list fail in the engine. Another test's window, drawn meanwhile,
// can move them onto the engine's thread (`tick`): the call then starts the job, and its
// failure is recorded when the job ends.
let failed = |r: Result<bool, String>| {
if r == Ok(true) {
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
while super::busy() && std::time::Instant::now() < until {
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
r != Ok(false)
};
a.packs_rt.errors.clear();
backend.inner.network.store(true, Ordering::Relaxed);
assert!(failed(packs::start_install("nl")));
clean(&super::error_of(&a, "nl").expect("the install's failure is shown"));
assert!(failed(packs::start_refresh()));
clean(&super::error_of(&a, "").expect("the list's failure is shown"));
}
/// CP4.3 review: a click the control channel made (MCP's `click`, `ui.click`) on the bar's
/// Install never turns on downloading, which is the font store's one setting for fonts too;
/// it says why instead. The person's own click on the same button does, and installs.
#[test]
fn a_faked_click_never_turns_on_downloading() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
+8
View File
@@ -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)>,
+1 -1
View File
@@ -354,7 +354,7 @@ pub(crate) fn highlight_button_appearance(name: &str) -> (&'static str, Option<e
/// No Color too, becomes the split button's colour, as in Word: after No Color a click on the
/// button removes highlighting (#2105).
pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("mini_hl").spacing(Vec2::new(3.0, 3.0)), |ui| {
for (i, h) in wordcraft_doc::props::Highlight::ALL.iter().enumerate().skip(1) {
let Some(c) = h.rgb() else { continue };
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
+4 -2
View File
@@ -563,7 +563,8 @@ fn an_inline_picture_drags_to_the_insertion_cursor_and_stays_inline() {
/// page: its preview has its red outline and nothing dark inside.
#[test]
fn a_dragged_shape_leaves_the_text_behind_it() {
use crate::table_resize::tests::{H, button, steps};
use crate::table_resize::tests::{H, button, gpu_turn, steps};
let _gpu = gpu_turn();
let text = "MMMMMMMMMMMM ".repeat(12);
let mut a = WordApp::new(wordcraft_engine::Session::new(Document::from_text(&text)), Default::default());
let shape = InlineObject::Shape {
@@ -758,7 +759,8 @@ fn rotation_window(app: WordApp) -> 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
+119 -73
View File
@@ -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());
@@ -396,7 +421,14 @@ fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
ProofLang::Es419 => tl!("Spanish (Latin America)"),
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa | ProofLang::Bg | ProofLang::De | ProofLang::Pack(_) | ProofLang::Other => "",
ProofLang::En
| ProofLang::EnGb
| ProofLang::EnCa
| ProofLang::EnAu
| ProofLang::Bg
| ProofLang::De
| ProofLang::Pack(_)
| ProofLang::Other => "",
}
}
@@ -442,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() {
@@ -530,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() {
@@ -671,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:"));
@@ -728,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() };
+4 -2
View File
@@ -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<Pane> 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<Pane> 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 {
+3
View File
@@ -74,6 +74,8 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest<Pane>) {
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<Value, String
if let Some(dock) = dock {
app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock);
}
// Report the physical side the pane occupies (matches what was stored / requested).
let name = match pane_state(app, pane).place {
Place::Docked(Edge::Start) => "left",
Place::Floating => "float",
+1 -1
View File
@@ -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() {
+3 -3
View File
@@ -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();
+35 -8
View File
@@ -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).
+31
View File
@@ -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]
+73 -3
View File
@@ -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<usize> = (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");
}
}
+50 -14
View File
@@ -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));
}
});
});
+1
View File
@@ -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);
+219 -170
View File
@@ -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<TabSpec> = 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<TabSpec> = 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::<f32>(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::<f32>(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();
}
});
+10 -8
View File
@@ -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}));
}
})
});
});
}
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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();

Some files were not shown because too many files have changed in this diff Show More