From e90f7882f19c4c2d8ac8037bc4fcb43c70478386 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 12:30:31 -0600 Subject: [PATCH] Migrate PdfCraft to egui 0.37 (#4591) * Record successful PdfCraft web application check * E21: bound startup requests and validate font and license inputs * E21: separate pending control completion and gesture regression phases * M-egui037: enforce control admission and per-family font notices * M-egui037: qualify capture fixes and document migration behavior * egui-037: identify current and historical qualification results --- .github/workflows/ci.yml | 5 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 13 +- ATTRIBUTION.toml | 32 +- Cargo.lock | 294 ++- Cargo.toml | 38 +- NOTICE | 18 + ROADMAP.md | 10 +- apps/pdfcraft-web/Cargo.toml | 2 +- apps/pdfcraft/Cargo.toml | 4 +- apps/pdfcraft/src/main.rs | 119 +- apps/pdfcraft/tests/it/main.rs | 1 + apps/pdfcraft/tests/it/vendor_graph.rs | 81 + .../tests/it/winit_macos_emoji_picker.rs | 8 +- .../tests/it/winit_macos_ime_commit.rs | 8 +- .../tests/it/winit_macos_ime_discard.rs | 7 +- .../tests/it/winit_macos_korean_first_key.rs | 7 +- .../tests/it/winit_macos_korean_space.rs | 7 +- .../tests/it/winit_macos_title_bar_drag.rs | 8 +- .../pdfcraft/tests/it/winit_wayland_scroll.rs | 24 +- .../tests/it/winit_wayland_text_input.rs | 7 +- .../it/winit_windows_hidden_maximized.rs | 7 +- .../tests/it/winit_windows_ime_cursor.rs | 7 +- apps/pdfcraft/tests/it/winit_x11_dpi.rs | 6 +- apps/pdfcraft/tests/it/winit_x11_ime_focus.rs | 7 +- crates/fonts/build.rs | 111 +- crates/fonts/src/cjk_scripts.rs | 78 + crates/fonts/src/craft.rs | 10 +- crates/fonts/tests/build_input.rs | 4 + crates/print/src/tests.rs | 29 +- crates/redact/src/tests.rs | 68 + crates/ui-egui/Cargo.toml | 17 +- crates/ui-egui/src/actions_ui.rs | 14 +- crates/ui-egui/src/all_tools_ui.rs | 21 +- crates/ui-egui/src/appearance.rs | 6 +- crates/ui-egui/src/bidi.rs | 234 +- crates/ui-egui/src/bulk_fields.rs | 2 +- crates/ui-egui/src/calc_order_ui.rs | 7 +- crates/ui-egui/src/camera_ui.rs | 4 +- crates/ui-egui/src/canvas.rs | 733 +++++- crates/ui-egui/src/chrome.rs | 59 +- crates/ui-egui/src/combine_grid.rs | 40 +- crates/ui-egui/src/combine_ui.rs | 23 +- crates/ui-egui/src/comments.rs | 23 +- crates/ui-egui/src/comments_panel.rs | 33 +- crates/ui-egui/src/content_ui.rs | 14 +- crates/ui-egui/src/control.rs | 1998 ++++++++++++++++- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 94 +- crates/ui-egui/src/edit_text_ui.rs | 19 +- crates/ui-egui/src/field_table_ui.rs | 15 +- crates/ui-egui/src/fill_sign.rs | 28 +- crates/ui-egui/src/fling.rs | 28 +- crates/ui-egui/src/folders_ui.rs | 13 +- crates/ui-egui/src/format_text_ui.rs | 4 +- crates/ui-egui/src/forms_ui.rs | 32 +- crates/ui-egui/src/home.rs | 17 +- crates/ui-egui/src/i18n/ar.tsv | 15 +- crates/ui-egui/src/i18n/bg.tsv | 9 + crates/ui-egui/src/i18n/ca.tsv | 9 + crates/ui-egui/src/i18n/catalog.rs | 9 - crates/ui-egui/src/i18n/ckb.tsv | 15 +- crates/ui-egui/src/i18n/cs.tsv | 9 + crates/ui-egui/src/i18n/de.tsv | 9 + crates/ui-egui/src/i18n/el.tsv | 9 + crates/ui-egui/src/i18n/es.tsv | 9 + crates/ui-egui/src/i18n/fa.tsv | 6 +- crates/ui-egui/src/i18n/fi.tsv | 9 + crates/ui-egui/src/i18n/fr.tsv | 9 + crates/ui-egui/src/i18n/hi.tsv | 9 + crates/ui-egui/src/i18n/hu.tsv | 9 + crates/ui-egui/src/i18n/id.tsv | 9 + crates/ui-egui/src/i18n/it.tsv | 9 + crates/ui-egui/src/i18n/ja.tsv | 9 + crates/ui-egui/src/i18n/kk.tsv | 9 + crates/ui-egui/src/i18n/ko.tsv | 9 + crates/ui-egui/src/i18n/lo.tsv | 9 + crates/ui-egui/src/i18n/lt.tsv | 9 + crates/ui-egui/src/i18n/mod.rs | 126 +- crates/ui-egui/src/i18n/nb.tsv | 9 + crates/ui-egui/src/i18n/pl.tsv | 9 + crates/ui-egui/src/i18n/pt-br.tsv | 9 + crates/ui-egui/src/i18n/ro.tsv | 9 + crates/ui-egui/src/i18n/ru.tsv | 9 + crates/ui-egui/src/i18n/sl.tsv | 9 + crates/ui-egui/src/i18n/sq.tsv | 9 + crates/ui-egui/src/i18n/te.tsv | 9 + crates/ui-egui/src/i18n/th.tsv | 9 + crates/ui-egui/src/i18n/tr.tsv | 9 + crates/ui-egui/src/i18n/uk.tsv | 9 + crates/ui-egui/src/i18n/vi.tsv | 9 + crates/ui-egui/src/i18n/zh-hans.tsv | 9 + crates/ui-egui/src/i18n/zh-hant.tsv | 9 + crates/ui-egui/src/js_ui.rs | 185 +- crates/ui-egui/src/lib.rs | 167 +- crates/ui-egui/src/link_ui.rs | 2 +- crates/ui-egui/src/measure_ui.rs | 15 +- crates/ui-egui/src/motion.rs | 144 +- crates/ui-egui/src/object_ui.rs | 6 + crates/ui-egui/src/organize_panel.rs | 2 +- crates/ui-egui/src/page_pointer.rs | 2 +- crates/ui-egui/src/page_selection_ui.rs | 2 +- crates/ui-egui/src/palette.rs | 52 +- crates/ui-egui/src/panels.rs | 30 +- crates/ui-egui/src/prepare.rs | 16 +- crates/ui-egui/src/print_ui.rs | 79 +- crates/ui-egui/src/protect.rs | 2 +- crates/ui-egui/src/recovery.rs | 60 +- crates/ui-egui/src/redact_ui.rs | 5 +- crates/ui-egui/src/scroll_speed.rs | 8 +- crates/ui-egui/src/search_ui.rs | 4 +- crates/ui-egui/src/side_panels.rs | 4 +- crates/ui-egui/src/sign_ui.rs | 3 +- crates/ui-egui/src/system_fonts.rs | 8 +- crates/ui-egui/src/theme.rs | 154 +- crates/ui-egui/src/wheel_boost.rs | 26 +- crates/ui-egui/src/widgets.rs | 176 +- crates/ui-egui/src/window_controls.rs | 9 +- crates/ui-egui/tests/it/actions.rs | 18 + crates/ui-egui/tests/it/attachment_row.rs | 9 +- crates/ui-egui/tests/it/bookmarks.rs | 6 +- crates/ui-egui/tests/it/combine_grid.rs | 32 +- crates/ui-egui/tests/it/commands.rs | 26 +- crates/ui-egui/tests/it/comments.rs | 5 +- crates/ui-egui/tests/it/control.rs | 97 +- crates/ui-egui/tests/it/dialog_chrome.rs | 8 +- crates/ui-egui/tests/it/editing.rs | 5 +- crates/ui-egui/tests/it/editor_taps.rs | 2 +- crates/ui-egui/tests/it/fonts.rs | 99 +- crates/ui-egui/tests/it/forms.rs | 6 +- crates/ui-egui/tests/it/pointer.rs | 27 +- crates/ui-egui/tests/it/prepare.rs | 4 +- crates/ui-egui/tests/it/print.rs | 30 +- crates/ui-egui/tests/it/recovery.rs | 17 + crates/ui-egui/tests/it/text_select.rs | 16 +- crates/ui-egui/tests/it/theme.rs | 4 +- crates/ui-egui/tests/it/ui.rs | 3 + crates/ui-egui/tests/it/view.rs | 20 +- crates/ui-egui/tests/it/viewer.rs | 170 +- .../tests/snapshots/panel_comments.txt | 4 +- deny.toml | 8 +- docs/egui-037-migration-plan.md | 812 +++++++ docs/gaps.md | 36 +- docs/localization-parity.md | 51 +- docs/localization.md | 47 +- docs/releasing.md | 13 +- docs/roadmap.md | 14 +- docs/target-app-parity.md | 7 +- .../linux/flatpak/ai.storyteller.pdfcraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/font-notices.ps1 | 48 + packaging/windows/package.ps1 | 52 +- packaging/windows/pdfcraft.wxs | 11 + packaging/windows/test-font-notices.ps1 | 43 + packaging/windows/test-msi.ps1 | 32 +- parity/acrobat-features.toml | 8 +- vendor/README.md | 31 +- 159 files changed, 6759 insertions(+), 1181 deletions(-) create mode 100644 apps/pdfcraft/tests/it/vendor_graph.rs create mode 100644 crates/fonts/tests/build_input.rs create mode 100644 docs/egui-037-migration-plan.md create mode 100644 packaging/windows/font-notices.ps1 create mode 100644 packaging/windows/test-font-notices.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac276f13c..9d168c7aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Windows font notice regressions + shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 - name: Git dependencies come from storytold run: | set -euo pipefail @@ -73,7 +76,7 @@ jobs: # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} # Same craft-fonts commit as release.yml and freebsd.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # egui/winit/rfd, and the independent oracles (crates/oracle). packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fba20b1cc..268da70ca 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,4 +26,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c3..a5c2ba7f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: pdfcraft app-title: PdfCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 40626dc6b..c5be9d7bc 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -298,14 +298,15 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -## Compiled in through dependencies (5) +## Compiled in through dependencies (6) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| -| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) | -| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 | ## Downloaded at build time (22) @@ -343,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 383272f3f..8b2186d5b 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2439,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Ubuntu-Light.ttf" title = "Ubuntu Light" author = "Canonical Ltd. / Dalton Maag" @@ -2451,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Hack-Regular.ttf" title = "Hack Regular" author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)" @@ -2463,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/NotoEmoji-Regular.ttf" title = "Noto Emoji (monochrome)" author = "Google Inc." source = "https://github.com/googlefonts/noto-emoji" licence = "OFL-1.1" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/emoji-icon-font.ttf" title = "emoji-icon-font" author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca" +[[bundled]] +crate = "epaint_default_fonts" +version = "0.37.0" +path = "fonts/egui-icons.ttf" +title = "egui icons (monochrome emoji-icon-font subset)" +author = "John Slegers" +source = "https://github.com/jslegers/emoji-icon-font" +licence = "MIT" +kind = "font" +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" + [[asset]] path = "vendor/hayro-cmap/assets/cmaps.brotli" title = "Adobe CMap resources (predefined CJK CMaps)" @@ -3372,13 +3384,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" [[build_input]] name = "craft-fonts" option = "CRAFT_FONTS_DIR" -title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic" -author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)" -source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" +title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic" +author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)" +source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)" licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" diff --git a/Cargo.lock b/Cargo.lock index 07b8e4ef0..0a7e03a82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,57 +15,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -235,6 +230,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -1150,8 +1146,9 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ + "craft-winprint-gdi", "miniz_oxide 0.8.9", "roxmltree", "tempfile", @@ -1162,7 +1159,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ "dispatch2", "egui", @@ -1174,7 +1171,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=ea4c823abb46fa9d21361604ed1c6d21c54e9ca8#ea4c823abb46fa9d21361604ed1c6d21c54e9ca8" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ "egui", "log", @@ -1184,6 +1181,14 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1539,9 +1544,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1572,9 +1576,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1598,6 +1601,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1610,9 +1614,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1632,7 +1635,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1650,32 +1654,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1688,9 +1695,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1702,9 +1708,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1746,9 +1751,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1776,22 +1780,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1828,9 +1832,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1847,16 +1850,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1891,7 +1895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2072,6 +2076,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2322,7 +2332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2334,18 +2344,18 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -2509,6 +2519,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2517,7 +2536,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2528,7 +2547,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3069,12 +3088,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3308,7 +3327,7 @@ dependencies = [ "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3850,6 +3869,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.14.0" @@ -4411,6 +4440,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "winsafe", "xattr", ] @@ -4500,6 +4530,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4508,7 +4549,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4519,6 +4559,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4973,7 +5014,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5523,7 +5564,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5593,7 +5634,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5723,6 +5764,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -6248,7 +6295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6470,7 +6517,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6804,6 +6851,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6839,7 +6897,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7029,9 +7087,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7057,14 +7115,14 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -7553,7 +7611,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7836,6 +7894,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7934,6 +7993,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -8374,3 +8451,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 6a91547f7..cb69da41a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,11 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "ea4c823abb46fa9d21361604ed1c6d21c54e9ca8", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal @@ -75,11 +75,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" +# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass). +web-time = "1.1" [workspace.lints.rust] unsafe_code = "forbid" @@ -118,6 +120,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" } hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } -winit = { path = "vendor/winit" } -egui-wgpu = { path = "vendor/egui-wgpu" } vello_common = { path = "vendor/vello_common" } +# The egui and winit crates below are pinned to immutable storytold git revisions (egui +# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's +# earlier patches and their source guards, not path patches; the other `vendor/` entries above +# remain real path patches. See vendor/README.md and the migration plan. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8cacb0b58..63a8fe672 100644 --- a/NOTICE +++ b/NOTICE @@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006). Vendored (vendor/, patched; see vendor/README.md): - hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). - hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index edc0ad434..165fccc5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) @@ -165,10 +165,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | +| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | | Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | | Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (layout not mirrored) | +| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | | French | `fr` | 99.3% | partial | | Portuguese (Brazil) | `pt-br` | 100% of current union | partial | | Indonesian | `id` | 0% | none | @@ -188,7 +188,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -676,6 +676,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dc..ad8243d59 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 6943ad6e4..02d6bc56c 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -18,8 +18,8 @@ pdfcraft-ui-egui = { workspace = true } pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } -egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 96725cd8d..8bc4d32f7 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -357,6 +357,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's + // provider would read every installed face, including proprietary ones, into every UI pass. + native.system_font_fallback = false; if renderer == eframe::Renderer::Wgpu { configure_gpu(&mut native); } @@ -795,6 +798,41 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D #[cfg(test)] mod tests { + /// The lockfile package block for `name`, or a panic naming the missing package. + fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) + } + + /// Interim graph check for the window stack while the reviewed fork revision is not yet + /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the + /// published manifest; local integration supplies the reviewed worktrees through an external + /// Cargo config patch. A git source must name the public storytold fork; a path source is the + /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested + /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is + /// pinned. No public revision is invented here. + fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package + } + use pdfcraft_ui_egui::control::Endpoint; fn endpoint() -> std::io::Result { @@ -1063,28 +1101,19 @@ mod tests { } } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. #[test] - fn egui_wgpu_carries_the_surface_size_fit() { - // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and + fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that - // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would - // bring the crash back: re-apply the patch, or drop the copy once an egui release has a - // fix (vendor/README.md). + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - // `surface_fit` is tested above; these keep it in the paths that size a surface. - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - for patch in [ - "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);", - "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());", - "pixels_per_point: pixels_per_point * surface_state.render_scale,", - "old_state.window_width,\n old_state.window_height,", - ] { - assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1140,24 +1169,20 @@ mod tests { assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. #[test] - fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() { - // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of - // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then - // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure - // panics before the app is created, and the OpenGL retry never runs. + fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface"); - for patch in [ - "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })", - "return Err(crate::WgpuError::ConfigureSurface(error));", - ] { - assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1191,6 +1216,8 @@ mod tests { } } + /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy + /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another @@ -1199,13 +1226,23 @@ mod tests { // dependency bump that resolves winit from crates.io again, or a re-vendored copy without // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); - assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); - let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; - assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); } const NVIDIA: (u32, u32) = (0x10de, 0x2684); diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs index bc05099a5..b09ace5f4 100644 --- a/apps/pdfcraft/tests/it/main.rs +++ b/apps/pdfcraft/tests/it/main.rs @@ -7,6 +7,7 @@ mod event { pub use winit::event::TouchPhase; } +mod vendor_graph; mod winit_macos_emoji_picker; mod winit_macos_ime_commit; mod winit_macos_ime_discard; diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs new file mode 100644 index 000000000..a6a4d4b29 --- /dev/null +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -0,0 +1,81 @@ +//! Dependency-graph verification for the egui 0.37 window stack. +//! +//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` / +//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app +//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained +//! copies are not path patches. These tests check the resolved graph and the retained evidence +//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch +//! behaviour. + +/// Return the workspace root above the PdfCraft app crate. +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`. +fn locked_packages(name: &str) -> Vec { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock"); + let needle = format!("name = \"{name}\""); + lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect() +} + +/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something +/// to resolve by taking the first match. +fn locked(name: &str) -> String { + let mut packages = locked_packages(name); + assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len()); + packages.remove(0) +} + +/// The resolved package must be the exact canonical storytold git source at the reviewed +/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source` +/// line) is refused: it would make the graph depend on a developer's checkout. A published path +/// patch is refused as well. +fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { + let package = locked(name); + let expected_version = format!("version = \"{version}\""); + assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}"); + let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect(); + assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}"); + let expected_source = format!("\"git+{url}?rev={rev}#{rev}\""); + assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]); + + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); +} + +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. +#[test] +fn vendor_provenance_window_stack_graph_is_resolved() { + assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. + let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); + for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { + assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}"); + } + let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance"); + assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base"); + for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] { + assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork"); + } +} + +/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches. +#[test] +fn renderer_and_security_vendors_remain() { + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + for vendor in [ + "vendor/hayro", + "vendor/hayro-interpret", + "vendor/hayro-syntax", + "vendor/hayro-cmap", + "vendor/hayro-jbig2", + "vendor/hayro-postscript", + "vendor/lopdf", + "vendor/vello_common", + ] { + assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch"); + } +} diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index 6cedf57e3..b95058ba9 100644 --- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (8) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] -fn winit_commits_macos_text_inserted_outside_a_key_press() { +fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without // a composition. winit 0.30.13 reports `insertText` only as part of a key press or a // composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index 8701fc20a..877f9e2a2 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (6) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] -fn winit_commits_macos_compositions_that_end_without_marked_text() { +fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input // methods that end a composition with `unmarkText` first lost the committed text, and Apple // Korean, which clears its marked text before inserting the key typed after a syllable (`한` diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index 52e65622f..89efbd3d8 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (7) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] -fn winit_discards_the_macos_composition_when_ime_is_disallowed() { +fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text // but doesn't tell the input method, which keeps the composition and continues it once IME is // allowed again: the abandoned syllable comes back in the next field. vendor/winit calls diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index 76bb32ccb..f18dde0f2 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (10) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] -fn winit_retries_the_first_apple_korean_key_once() { +fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the // raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave // ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 30c9d4779..f5f2aada2 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (9) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] -fn winit_sends_a_key_committed_through_macos_ime_only_once() { +fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, // `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so // the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index 5422af59b..5c8e892f2 100644 --- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (12) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] -fn winit_lets_macos_title_bar_drags_reach_the_app() { +fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit // moved the whole window for a drag that began on a document tab, and tabs could not be diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs index 96686f7ec..8087219b2 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -1,3 +1,5 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. #![cfg(target_os = "linux")] use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; @@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; let input = RawInput { time: Some(frame as f64 / 60.0), - events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + events: vec![Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui_phase(phase), + modifiers, + source: egui::MouseWheelSource::Unknown, + }], ..Default::default() }; ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); @@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] -fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { +fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { let seen = wheel_trace(source, false); assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); @@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] -fn discrete_wheels_still_follow_current_modifiers() { +fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); assert!(seen[1].0 > 1.0); assert_eq!(seen[2].0, 1.0); assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] -fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { +fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. let seen = wheel_trace(AxisSource::Finger, false); assert!(seen[1].0 > 1.0); @@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] -fn unknown_sources_keep_the_existing_phase_policy() { +fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 691659e5a..6469354cd 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (2) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] -fn winit_keeps_the_wayland_text_input_until_the_seat_goes() { +fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a // touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when // a capability is added, so IME stops working in a running PdfCraft until it is restarted. diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index a1bdd1184..a8009591b 100644 --- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (4) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] -fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { +fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the // window once its first frame is drawn. winit 0.30.13 applies the maximized state with // `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index aef55877d..1df11d8a6 100644 --- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (3) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] -fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { +fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether // or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so // the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs index fed0cf3e4..a9bde6a1e 100644 --- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,9 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] -fn winit_carries_the_x11_server_dpi_fallback() { +fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). // This guards PdfCraft's vendored copy, which workspace tests do not test itself. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index f244d1387..61e5c8ebf 100644 --- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (5) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] -fn winit_focuses_the_x11_input_context_it_replaces() { +fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. // On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the // new one to the next focus event, which never comes while PdfCraft's window keeps the focus: diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index bc87a1663..980c19570 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -2,7 +2,9 @@ //! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads //! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular //! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting -//! limits (Cloudflare Pages: 25 MiB per file). +//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional +//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm. +//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required. use std::fmt::Write as _; use std::path::PathBuf; @@ -10,18 +12,21 @@ use std::path::PathBuf; #[path = "src/cjk_scripts.rs"] mod cjk_scripts; +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match craft_fonts(&dir, required) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + } else if required { + println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing"); } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -30,26 +35,66 @@ fn main() { } } -/// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let manifest = dir.join("fonts/manifest.txt"); - println!("cargo::rerun-if-changed={}", manifest.display()); - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; +/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional +/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can +/// never pass the Chinese UI tests only by skipping them. +fn craft_fonts(dir: &std::path::Path, required: bool) -> Result { let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?; + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + match chinese.try_exists() { + Ok(false) if required => { + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + Ok(false) => {} + Ok(true) => match manifest(dir, &chinese, false, required) { + Ok(entries) => out.push_str(&entries), + // A broken optional addition must never discard the main Japanese/symbol/RTL faces. + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + }, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()), + } + } + Ok(out) +} + +/// One `CraftFont { .. }` initialiser per manifest line. +/// +/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not +/// fit its cap must fail the build rather than be silently left out, so a required build cannot +/// pass without the Chinese face it promises. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result { + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let mut out = String::new(); + // A required native Chinese manifest must add the Hans face itself, not merely some other + // CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised + // Simplified Chinese fallback is still absent. + let mut has_hans = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app - // moves to craft-fonts-build (storytold/craft-libs#213). + // Keep the upstream general CJK size cap. The separately pinned native Chinese UI + // face has its own bound; web builds retain the smaller general limit. A required + // Chinese face over its cap fails the build instead of being silently omitted. let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); - if cjk - && !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family) - && std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES) - { - continue; + if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) { + match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) { + cjk_scripts::CjkSizeDecision::Embed => {} + cjk_scripts::CjkSizeDecision::Skip => continue, + cjk_scripts::CjkSizeDecision::Reject => { + let cap = cjk_scripts::cjk_cap_bytes(family, wasm); + return Err(format!( + "{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it" + )); + } + } } // Arabic interface faces are small, so the web build keeps them too. let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); @@ -68,6 +113,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { if wasm && !web_face { continue; } + has_hans |= cjk_scripts::has_script_tag(scripts, "Hans"); let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); @@ -78,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } + if strict_cap && !has_hans { + return Err(format!( + "{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)", + manifest.display() + )); + } Ok(out) } + +#[cfg(test)] +mod manifest_tests { + use super::*; + + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. + #[test] + fn required_native_manifest_rejects_non_hans_faces_after_parsing() { + let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap(); + let path = root.join("manifest.txt"); + for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] { + std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap(); + assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}"); + assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable"); + } + std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap(); + assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {")); + std::fs::remove_file(root.join("face.ttf")).unwrap(); + assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs index 16a863c68..1058c6e61 100644 --- a/crates/fonts/src/cjk_scripts.rs +++ b/crates/fonts/src/cjk_scripts.rs @@ -8,6 +8,11 @@ pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", " /// Any other CJK face over this size is left out. pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; +/// Reviewed native-only UI fallback from the separate Chinese manifest. +/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap. +pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"]; +pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20; + /// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; @@ -17,3 +22,76 @@ pub fn is_cjk_script(tag: &str) -> bool { let code = tag.trim().split('-').next().unwrap_or_default(); CJK_SCRIPTS.contains(&code) } + +/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces. +/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag +/// such as `Hans-HK` is not a `Hans` entry. +pub fn has_script_tag(scripts: &str, script: &str) -> bool { + scripts.split(',').any(|tag| tag.trim() == script) +} + +/// What `build.rs` does with one CJK face from a manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CjkSizeDecision { + /// The face fits its cap (or is a Japanese family embedded whole). + Embed, + /// An optional face over its cap: leave it out and keep the rest. + Skip, + /// A required face over its cap: fail the build rather than silently omit it. + Reject, +} + +/// The size cap that applies to `family` in this build. +pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 { + if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES } +} + +/// Decide whether a CJK face is embedded, skipped, or rejected. +/// +/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap; +/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build, +/// where an over-cap face must fail instead of quietly disappearing. +pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision { + if WHOLE_CJK_FAMILIES.contains(&family) { + return CjkSizeDecision::Embed; + } + if bytes <= cjk_cap_bytes(family, wasm) { + CjkSizeDecision::Embed + } else if required { + CjkSizeDecision::Reject + } else { + CjkSizeDecision::Skip + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. + #[test] + fn a_required_chinese_manifest_needs_an_exact_hans_entry() { + assert!(has_script_tag("Hans,Latn", "Hans")); + assert!(has_script_tag(" Latn , Hans ", "Hans")); + assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face"); + assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag"); + assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); + } + + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. + #[test] + fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { + let at_cap = MAX_NATIVE_UI_CJK_BYTES; + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip); + // The web build keeps the smaller general cap even for the native Chinese family. + assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject); + // Japanese document/UI families are embedded whole. + assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index e778a42ad..dff96762a 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -315,12 +315,13 @@ mod tests { } /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to - /// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the + /// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate + /// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts /// are 24.1 MB. #[test] fn cjk_faces_follow_the_size_rule() { - use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script}; + use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script}; // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); @@ -328,7 +329,10 @@ mod tests { let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); for face in &other { let len = face.bytes.len(); - assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name()); + let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family); + assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload"); + let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }; + assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name()); } let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); diff --git a/crates/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 000000000..832cc2c16 --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Required manifest checks execute the same parser as the production build script. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index bc0454c99..e6432d8ae 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -283,8 +283,8 @@ fn spooler_arguments_and_printer_list() { assert_eq!( parse_lpstat(out), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() } ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); @@ -327,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() { assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() }, ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); + assert_eq!( + spool::printers_parsed(queues, None), + [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }] + ); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -414,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); - assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); - assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); - assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() }); } /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index fa39853a7..8b9461214 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2265,6 +2265,74 @@ fn unselected_pages_keep_inherited_forms() { assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); } +#[test] +fn nested_resource_less_forms_keep_live_resources_after_redacting_another_page() { + for own_resources in [false, true] { + for still_drawn in [false, true] { + let original = b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"; + let mut extra = vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", original), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", if still_drawn { b"/Fm Do".as_slice() } else { b"q Q".as_slice() }), + ]; + if own_resources { + extra.push(stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 7 0 R /Relay 8 0 R >> >>", + b"/Relay Do", + )); + } + let resources = + if own_resources { "/XObject << /Fm 7 0 R /Relay 8 0 R /Wrapper 9 0 R >>" } else { "/XObject << /Fm 7 0 R /Relay 8 0 R >>" }; + let mut doc = one_page(b"/Fm Do", resources, extra); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let inherited = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let second_content = + doc.add(Object::Stream(Stream::from_raw(Dict::new(), if own_resources { b"/Wrapper Do".to_vec() } else { b"/Relay Do".to_vec() }))); + page.set(b"Contents".to_vec(), Object::Ref(second_content)); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), inherited); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + + let region = [[0.0, 190.0, 300.0, 220.0]]; + let surviving = if still_drawn { 6 } else { 0 }; + assert_eq!(under(&mut doc, 0, ®ion), 6, "the marked occurrence is real text"); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "the relay's original nested content is visible"); + mark(&mut doc, 0, ®ion, ""); + let report = apply(&mut doc, Some(&[0])).unwrap(); + assert_eq!((report.marks, report.glyphs), (1, 6)); + assert_eq!(under(&mut doc, 0, ®ion), 0); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "redacting another page must preserve live nested content"); + + // A full saved document must retain both the reachable resource and its content, + // without relying on an old incremental revision's otherwise-unreachable objects. + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(under(&mut saved, 0, ®ion), 0); + assert_eq!(under(&mut saved, 1, ®ion), surviving); + let page = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut holder = xo.as_dict().unwrap().clone(); + if own_resources { + let wrapper = saved.resolve(holder.get(b"Wrapper").unwrap()); + let Object::Stream(wrapper) = &*wrapper else { panic!("a real wrapper form") }; + let res = saved.resolve(wrapper.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + holder = xo.as_dict().unwrap().clone(); + } + assert_eq!(holder.contains(b"Fm"), still_drawn, "keep only resources a surviving nested form actually uses"); + if still_drawn { + let form = saved.resolve(holder.get(b"Fm").unwrap()); + let Object::Stream(form) = &*form else { panic!("the preserved original form") }; + assert_eq!(form.decoded().unwrap(), original); + } + } + } +} + /// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. fn inheriting_pages(n: usize) -> (Document, Vec) { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 39eaff607..b155d117b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,30 +11,31 @@ workspace = true [dependencies] craft-ui = { workspace = true } -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. -craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } -egui = { workspace = true, features = ["bytemuck"] } +egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } +# Monotonic deadlines for work that runs in `logic` with no UI pass (native and web). +web-time = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" base64 = "0.22" -# Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree. +# Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns +# display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" # Checks that an installed fallback font parses and has the glyphs, and reads the vertical metrics # that put fallback faces on the interface's baseline (#1676); the same skrifa egui uses. @@ -53,6 +54,9 @@ pollster = "1.0" fontconfig-parser = "0.5.8" [target.'cfg(target_os = "macos")'.dependencies] +# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. +# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only. +craft-quit = { workspace = true, features = ["egui"] } # Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding. # The Objective-C wrappers and block support are already in the tree through arboard and rfd. objc2 = "0.6" @@ -81,6 +85,9 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] pdfcraft-xfa = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +# Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must +# be enabled on the test graph itself (a dependency's dev-features do not propagate). +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index 89cf2569e..ddd8a0736 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -301,16 +301,18 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool close } +/// Draw the custom-action editor: name, description and ordered steps; Save stays disabled until the name is +/// unique and non-empty and at least one step exists. fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Action name:")); - ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")); + let name_label = ui.label(tl!("Action name:")); + ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")).labelled_by(name_label.id); ui.end_row(); - ui.label(tl!("Description:")); - ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")); + let description_label = ui.label(tl!("Description:")); + ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")).labelled_by(description_label.id); ui.end_row(); }); ui.add_space(6.0); @@ -324,9 +326,9 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { for (i, s) in a.steps.iter_mut().enumerate() { ui.push_id(i, |ui| { ui.horizontal(|ui| { - ui.label(format!("{}. {}", i + 1, tl!(s.label()))); + let step_label = ui.label(format!("{}. {}", i + 1, tl!(s.label()))); if let Some(arg) = s.arg_mut() { - ui.add(crate::widgets::line(arg).desired_width(220.0)); + ui.add(crate::widgets::line(arg).desired_width(220.0)).labelled_by(step_label.id); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(tl!("Remove")).clicked() { diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs index c8c510a11..9b09c887d 100644 --- a/crates/ui-egui/src/all_tools_ui.rs +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -13,9 +13,8 @@ const TILE: egui::Vec2 = vec2(148.0, 118.0); const MATCHES_SHOWN: usize = 2; /// Whether `query` (already trimmed and lower-cased) is in `label` or its translation. -/// A right-to-left language's translations are in display order (their words run the other way) -/// while the query is in typing order, so there several words match one by one, as in the -/// command palette. +/// Translations and queries stay in logical order. For right-to-left labels, also accept +/// multiword queries in a different word order, as in the command palette. fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool { let translated = translated.to_lowercase(); label.to_lowercase().contains(query) @@ -115,7 +114,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { /// short shows in full on hover. fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); crate::home::card_lift(ui, t, rect, press); ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); @@ -186,11 +185,13 @@ mod tests { #[test] fn right_to_left_labels_match_a_query_typed_in_logical_order() { - let shown = crate::bidi::display_rtl("تنظيم الصفحات"); - assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display"); - assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true)); - assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word"); - assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there"); - assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact"); + let arabic = crate::i18n::Lang::from_code("ar").expect("Arabic is registered"); + let translated = crate::i18n::tr(arabic, "Organize pages"); + assert_eq!(translated, "تنظيم الصفحات", "the catalogue keeps logical text"); + assert!(label_hit("Organize pages", translated, "تنظيم الصفحات", true)); + assert!(label_hit("Organize pages", translated, "الصفحات", true), "one word"); + assert!(label_hit("Organize pages", translated, "الصفحات تنظيم", true), "RTL word-order fallback"); + assert!(!label_hit("Organize pages", translated, "تنظيم الملفات", true), "every word must be there"); + assert!(!label_hit("Organize pages", translated, "الصفحات تنظيم", false), "left-to-right labels stay exact"); } } diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index 536d8e4d9..1393c9d93 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -118,8 +118,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Appearance mode")); - egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { + let label = ui.label(tl!("Appearance mode")); + let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { app.execute(mode_command(preference)); @@ -127,6 +127,8 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } } }); + // The visible label names the combo box for assistive technology and tests. + combo.response.labelled_by(label.id); }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 50cae547e..c5809c601 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,18 +1,18 @@ -//! Visual order for single-line interface text that may hold right-to-left script (file names, -//! document titles). +//! Wrapping right-to-left text into logical lines, and test-only display-order helpers. //! -//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic -//! word join and run right to left, but it places the runs themselves left to right in logical -//! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display -//! order and leaves the letters of each word alone for the shaper. -//! -//! [`display_rtl`] does the same for the labels of a right-to-left interface language (the Arabic -//! catalog), whose base direction is right to left. +//! egui 0.37's epaint shapes text with the Unicode bidirectional algorithm (UAX #9), so +//! production code passes it logical (typing-order) text: [`wrap`] breaks a comment into logical +//! lines and lets epaint reorder each one. [`visual`], [`visual_auto`] and [`display_rtl`] are +//! kept for the tests that prove the old preordering is gone; they must not be used for painting +//! with egui 0.37, where they would reverse mixed-direction text a second time. +#[cfg(test)] use std::borrow::Cow; use std::ops::Range; -use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; +use unicode_bidi::BidiInfo; +#[cfg(test)] +use unicode_bidi::{Level, ParagraphInfo}; /// `text` rearranged for an egui call that lays out one line. Text without right-to-left /// characters comes back borrowed and unchanged. @@ -20,6 +20,7 @@ use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; /// The base direction is left to right, as the interface is: a file name keeps its extension /// at the end, the way the window title shows it. Use it for painting only; accessibility /// labels and anything stored keep the logical text. +#[cfg(test)] pub fn visual(text: &str) -> Cow<'_, str> { if !text.chars().any(is_rtl_script) { return Cow::Borrowed(text); @@ -30,6 +31,7 @@ pub fn visual(text: &str) -> Cow<'_, str> { } /// `text` in display order for the paragraph direction `base`, appended to `out`. +#[cfg(test)] fn push_visual(out: &mut String, text: &str, base: Level) { let info = BidiInfo::new(text, Some(base)); for para in &info.paragraphs { @@ -39,6 +41,7 @@ fn push_visual(out: &mut String, text: &str, base: Level) { /// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui /// needs it, appended to `out`. +#[cfg(test)] fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: Range) { let (levels, runs) = info.visual_runs(para, line); // First the true display order, every character left to right as it appears on screen. @@ -62,6 +65,7 @@ fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &Paragraph /// each such stretch goes back to logical order. What lies between them (spaces, punctuation, /// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include /// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right. +#[cfg(test)] fn push_for_shaper(out: &mut String, shown: &str) { let mut stretch: Vec = Vec::new(); for c in shown.chars() { @@ -78,7 +82,7 @@ fn push_for_shaper(out: &mut String, shown: &str) { /// One line of wrapped text (see [`wrap`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { - /// The line in display order, for one egui call that lays out one line. + /// The line in logical (reading) order. epaint shapes and reorders it for display. pub text: String, /// The paragraph reads right to left: the line belongs against the right edge. pub rtl: bool, @@ -89,14 +93,44 @@ pub fn has_rtl(text: &str) -> bool { text.chars().any(is_rtl_script) } +/// The paragraph direction for translated interface text: explicitly right to left for Arabic, +/// Kurdish and Persian, and `TextDirection::Auto` otherwise. A label whose first strong character is +/// Latin or a `{placeholder}` must not fall back to a left-to-right paragraph in an RTL locale. +pub fn ui_direction() -> egui::epaint::text::TextDirection { + if crate::i18n::current().rtl() { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::Auto } +} + +/// Lay out one line of translated interface text with the locale's explicit base direction. +pub fn layout_ui(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, ui_direction()) +} + +/// Lay out one line of file or path text left to right, so an Arabic file name keeps `.pdf` at +/// its visual end exactly as a Latin one does. The text stays logical for accessibility. +pub fn layout_ltr(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, egui::epaint::text::TextDirection::LeftToRight) +} + +fn layout_one_line( + ui: &egui::Ui, + text: impl Into, + font: egui::FontId, + color: egui::Color32, + direction: egui::epaint::text::TextDirection, +) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + job.direction = direction; + ui.painter().layout_job(job) +} + /// Multi-line text with right-to-left script (a comment in Persian, Arabic, Hebrew), broken -/// into lines that `fits` accepts (it measures a line in logical order) and each put in display -/// order by the Unicode bidirectional algorithm (UAX #9). +/// into logical-order lines that `fits` accepts (it measures a line in logical order). /// -/// Every paragraph takes its direction from its first strong character, as `dir="auto"` does, -/// so a Persian comment reads right to left with its Latin words and numbers left to right -/// inside it. Lines break between words, in reading order, and a word wider than a line -/// between characters. Use it for painting; copying and accessibility keep the logical text. +/// The lines keep reading order and each line's paragraph direction ([`Line::rtl`]): egui 0.37's +/// epaint already shapes every line with the Unicode bidirectional algorithm (UAX #9), so +/// preordering here would reorder mixed Persian/Latin text a second time. Lines break between +/// words, in reading order, and a word wider than a line between characters. Copying and +/// accessibility use the same logical text. pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let info = BidiInfo::new(text, None); let mut lines = Vec::new(); @@ -109,12 +143,7 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let content = para.range.start..para.range.start.saturating_add(whole.trim_end_matches(is_paragraph_separator).len()); let rtl = para.level.is_rtl(); for line in break_lines(text, content, &mut fits) { - let mut out = String::new(); - // unicode-bidi indexes the first level of a line, so an empty one would panic. - if !line.is_empty() { - push_line(&mut out, text, &info, para, line); - } - lines.push(Line { text: out, rtl }); + lines.push(Line { text: text.get(line).unwrap_or_default().to_owned(), rtl }); } } lines @@ -122,12 +151,18 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { /// One line of `text` in display order, its direction from its first strong character (an /// author's name). Text without right-to-left characters comes back borrowed and unchanged. +/// +/// Only the bidi unit tests use this now: interface labels keep logical text and let the egui +/// fork reorder, so no production call site preorders a label. +#[cfg(test)] pub fn visual_auto(text: &str) -> Cow<'_, str> { if !has_rtl(text) { return Cow::Borrowed(text); } - let lines: Vec = wrap(text, |_| true).into_iter().map(|l| l.text).collect(); - Cow::Owned(lines.join("\n")) + let base = BidiInfo::new(text, None).paragraphs.first().map_or(Level::ltr(), |para| para.level); + let mut out = String::with_capacity(text.len()); + push_visual(&mut out, text, base); + Cow::Owned(out) } /// The byte ranges of the lines `range` of `text` breaks into: as many words as `fits` accepts @@ -209,7 +244,9 @@ fn is_paragraph_separator(c: char) -> bool { /// - A short caption's closing colon stays on the right too, next to the widget it captions. /// /// Text without right-to-left characters comes back unchanged. Like [`visual`], this is for -/// painting one line; a label egui wraps breaks at the wrong end. +/// painting one line; a label egui wraps breaks at the wrong end. Kept for the tests that prove +/// the old preordering is gone; production catalogs stay logical. +#[cfg(test)] pub fn display_rtl(text: &str) -> String { if !text.chars().any(is_rtl_script) { return text.to_owned(); @@ -225,13 +262,17 @@ pub fn display_rtl(text: &str) -> String { } /// The most words a caption may have for its closing colon to stay on the right. +#[cfg(test)] const CAPTION_WORDS: usize = 3; /// First of the private-use characters that stand for placeholders while a line is reordered. /// They are strong left-to-right, like the names they replace. +#[cfg(test)] const PLACEHOLDER_BASE: u32 = 0xE000; /// Placeholders protected per line; a line with more keeps the rest as plain text. +#[cfg(test)] const MAX_PLACEHOLDERS: usize = 64; +#[cfg(test)] fn push_rtl_line(out: &mut String, line: &str) { let edge = |c: char| c.is_whitespace() || c == '·'; let core = line.trim_start_matches(edge); @@ -266,6 +307,7 @@ fn push_rtl_line(out: &mut String, line: &str) { /// `text` with each `{name}` placeholder and each keyboard shortcut (`⇧⌘+`) replaced by one /// stand-in character, and what they replaced, in order. A shortcut's symbols have no direction /// of their own, so left alone they would be drawn in reverse. +#[cfg(test)] fn mask_placeholders(text: &str) -> (String, Vec<&str>) { let mut masked = String::with_capacity(text.len()); let mut names: Vec<&str> = Vec::new(); @@ -286,6 +328,7 @@ fn mask_placeholders(text: &str) -> (String, Vec<&str>) { } /// The byte range of the first placeholder or keyboard shortcut in `text`. +#[cfg(test)] fn next_atom(text: &str) -> Option<(usize, usize)> { let placeholder = text.find('{').and_then(|open| { let len = text.get(open..)?.find('}')?; @@ -308,6 +351,7 @@ fn next_atom(text: &str) -> Option<(usize, usize)> { } /// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃. +#[cfg(test)] fn is_modifier_key(c: char) -> bool { matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}') } @@ -320,15 +364,18 @@ fn is_rtl_script(c: char) -> bool { /// The zero-width (non-)joiners inside a word (Persian `می‌روم`): egui shapes them with the /// letter before them. +#[cfg(test)] fn is_joiner(c: char) -> bool { matches!(c, '\u{200C}' | '\u{200D}') } /// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn. +#[cfg(test)] fn is_bidi_control(c: char) -> bool { matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } +#[cfg(test)] fn mirrored(c: char) -> char { match c { '(' => ')', @@ -474,50 +521,57 @@ mod tests { Line { text: text.to_owned(), rtl: false } } + /// Check that a Persian paragraph, including words joined by a zero-width non-joiner, reaches epaint as + /// one logical right-to-left run. #[test] - fn persian_paragraph_reads_right_to_left() { - // "I go to school": the words come out in reverse for egui's left-to-right placement. - assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("می‌روم مدرسه به من")]); + fn persian_paragraph_keeps_logical_order_for_epaint() { + // The line stays in reading order; epaint shapes and reorders it (see `on_screen`). + assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("من به مدرسه می‌روم")]); // The zero-width non-joiner keeps a word whole, so the shaper gets it as one run. assert_eq!(wrap_at("کتاب‌ها", 80), [rtl("کتاب‌ها")]); } + /// Check that Arabic text wraps into lines in logical reading order rather than visual order. #[test] fn arabic_lines_break_in_reading_order() { - // The first words go on the first line, each line reordered on its own. - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("اثنين واحد"), rtl("أربعة ثلاثة")]); - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("أربعة ثلاثة اثنين واحد")]); + // The first words go on the first line, in logical order for epaint to reorder. + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("واحد اثنين"), rtl("ثلاثة أربعة")]); + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("واحد اثنين ثلاثة أربعة")]); } + /// Check that each paragraph takes its base direction from its first strong character, not from leading + /// numbers or neutral text. #[test] fn base_direction_comes_from_the_first_strong_character() { // Latin first: a left-to-right paragraph with an Arabic phrase inside it. - assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See اثنين واحد here")]); - // Persian first: right to left, the Latin words and the number left to right inside it. - assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("است 2 نسخه PDF فایل")]); - assert_eq!(wrap_at("سلام Hello World", 80), [rtl("Hello World سلام")]); + assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See واحد اثنين here")]); + // Persian first: right to left, with the Latin words and number in reading order. + assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("فایل PDF نسخه 2 است")]); + assert_eq!(wrap_at("سلام Hello World", 80), [rtl("سلام Hello World")]); // Leading neutrals and numbers don't decide it. - assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("اثنين واحد .12")]); + assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("12. واحد اثنين")]); // Each paragraph finds its own direction. - assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("اثنين واحد"), ltr("OK then"), ltr(""), rtl("עולם שלום")]); + assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("واحد اثنين"), ltr("OK then"), ltr(""), rtl("שלום עולם")]); } + /// Check that right-to-left punctuation and brackets stay in logical order for epaint to mirror when + /// displaying. #[test] fn rtl_punctuation_and_brackets() { - // The sentence's full stop ends up on the left, brackets flip. - assert_eq!(wrap_at("سلام دنیا.", 80), [rtl(".دنیا سلام")]); - assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("ثلاثة (اثنين) واحد")]); - assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("است؟ آماده آیا")]); + // Logical text: epaint mirrors punctuation and brackets for display. + assert_eq!(wrap_at("سلام دنیا.", 80), [rtl("سلام دنیا.")]); + assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("واحد (اثنين) ثلاثة")]); + assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("آیا آماده است؟")]); } + /// Check that Persian and Arabic-Indic digits stay in logical order inside a right-to-left paragraph. #[test] fn persian_digits_go_to_the_shaper_reversed() { - // Persian and Arabic-Indic digits are Arabic script, which egui's shaper lays out right to - // left; they read left to right, so they are handed over reversed. - assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("شمسی ۲۰۴۱ سال")]); - assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("٢١ صفحه")]); - // Glued to a word, number and word swap places within one shaped run. - assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۲۱")]); + // Persian and Arabic-Indic digits stay in logical order; epaint's shaper preserves their + // left-to-right reading inside the right-to-left paragraph. + assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("سال ۱۴۰۲ شمسی")]); + assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("صفحه ١٢")]); + assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۱۲")]); } #[test] @@ -546,8 +600,30 @@ mod tests { let output = ctx.run_ui(egui::RawInput::default(), |ui| { for line in wrap(text, |_| true) { let galley = ui.painter().layout_no_wrap(line.text, egui::FontId::proportional(13.0), egui::Color32::WHITE); - let glyphs = galley.rows.iter().flat_map(|row| row.glyphs.iter()); - shown.push(glyphs.filter(|g| g.advance_width > 0.0).map(|g| g.chr).collect()); + let atlas = ui.fonts_mut(|fonts| fonts.image()); + let mut visual = String::new(); + for row in &galley.rows { + // 0.37 keeps glyphs in logical order for selection. Read their actual + // positions to test visual order instead of assuming vector order. + let mut glyphs: Vec<_> = row.glyphs.iter().filter(|g| g.advance_width > 0.0).collect(); + glyphs.sort_by(|a, b| a.pos.x.total_cmp(&b.pos.x)); + visual.extend(glyphs.into_iter().map(|g| match g.chr { + // `chr` remains logical even when the shaper mirrors the painted + // bracket. Read the bitmap's middle-row ink to check what is visible. + '(' | ')' => { + let [x0, y0] = g.uv_rect.min.map(usize::from); + let [x1, y1] = g.uv_rect.max.map(usize::from); + let y = usize::midpoint(y0, y1); + let ink: Vec = (x0..x1).map(|x| f32::from(atlas[(x, y)].a())).collect(); + let total: f32 = ink.iter().sum(); + assert!(total > 0.0 && !ink.is_empty(), "a bracket must have visible ink"); + let center = ink.iter().enumerate().map(|(i, alpha)| i as f32 * alpha).sum::() / total; + if center < (ink.len() - 1) as f32 / 2.0 { '(' } else { ')' } + } + _ => g.chr, + })); + } + shown.push(visual); } }); output.drop_without_applying_deltas(); @@ -573,6 +649,64 @@ mod tests { assert_eq!(on_screen("واحد (اثنين)."), [format!(".({}) {}", rev("اثنين"), rev("واحد"))]); } + /// A file name keeps its extension at the visual end even when the first strong word is + /// Arabic: with an explicit `LeftToRight` base, `.pdf` is right of every Arabic glyph. + #[test] + fn file_names_are_laid_out_left_to_right() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let mut positions: Vec<(char, f32)> = Vec::new(); + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ltr(ui, "واحد اثنين.pdf", egui::FontId::proportional(13.0), egui::Color32::WHITE); + positions = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + }) + .drop_without_applying_deltas(); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + let leftmost_extension = positions.iter().filter(|(c, _)| matches!(*c, '.' | 'p' | 'd' | 'f')).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(rightmost_arabic < leftmost_extension, "the extension must stay to the right: {positions:?}"); + let last = positions.iter().filter(|(c, _)| !c.is_whitespace()).max_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(last.map(|(c, _)| *c), Some('f'), "the painted line ends in the extension"); + } + + /// Arabic/Kurdish labels get an explicit right-to-left paragraph, even when their first + /// strong character is Latin. With English the same text stays left to right. + #[test] + fn arabic_ui_labels_use_an_explicit_paragraph_direction() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let read = |lang: &str| { + crate::i18n::set_current(crate::i18n::Lang::from_code(lang).expect("registered language")); + let mut got = None; + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ui(ui, "PDF واحد", egui::FontId::proportional(13.0), egui::Color32::WHITE); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + got = Some((galley.job.direction, positions)); + }) + .drop_without_applying_deltas(); + got.expect("one layout") + }; + let (direction, positions) = read("ar"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::RightToLeft); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "in an RTL paragraph the first Latin run is at the right: {positions:?}"); + + let (direction, positions) = read("en"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::Auto); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let leftmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(p < leftmost_arabic, "with English the Latin run stays at the left: {positions:?}"); + } + #[test] fn single_line_with_its_own_direction() { assert!(matches!(visual_auto("Ada Lovelace"), Cow::Borrowed("Ada Lovelace"))); diff --git a/crates/ui-egui/src/bulk_fields.rs b/crates/ui-egui/src/bulk_fields.rs index 3101724cf..fe401f057 100644 --- a/crates/ui-egui/src/bulk_fields.rs +++ b/crates/ui-egui/src/bulk_fields.rs @@ -283,7 +283,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool egui::Grid::new("bulk-field-properties").num_columns(3).spacing([16.0, 10.0]).show(ui, |ui| match d.tab { Tab::General => { row(ui, "Tooltip:", &mut d.tooltip, |ui, v| { - ui.add(egui::TextEdit::singleline(v).desired_width(280.0)); + ui.add(egui::TextEdit::singleline(v).desired_width(280.0)).accessible_name(tl!("Tooltip")); }); bool_row(ui, "Read Only", &mut d.read_only); bool_row(ui, "Required", &mut d.required); diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 70a6dec2c..4cbfa2817 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -566,7 +566,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let is_selected = selected.as_deref() == Some(f.name.as_str()); resp.widget_info(|| { let label = if row.value.is_empty() { format!("{}. {}", k + 1, f.name) } else { format!("{}. {}: {}", k + 1, f.name, row.value) }; - egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, label) + egui::WidgetInfo::selected(egui::Role::Button, true, is_selected, label) }); if reveal.as_deref() == Some(f.name.as_str()) { resp.scroll_to_me(Some(egui::Align::Center)); @@ -631,8 +631,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { [(up, "chevron-up", true, tl!("Earlier in calculation order")), (down, "chevron-down", false, tl!("Later in calculation order"))] { let to = if earlier { k.checked_sub(1) } else { Some(k + 1).filter(|&to| to < count) }; - let b = ui.interact(r, ui.id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, to.is_some(), format!("{tip}: {}", f.name))); + let b = + ui.interact(r, ui.scope_id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 1cacc4e16..d47037807 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -141,7 +141,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, view: /// way, choose its look (original, colour, greyscale, black and white) and clean-up level, /// Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`). pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Tokens, allowed: bool) { - egui::Area::new(egui::Id::new("camera-bar")) + egui::Area::new(egui::Id::unique("camera-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -183,7 +183,7 @@ pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Token let icon = |ui: &mut egui::Ui, name: &str, tip: &str| { let r = ui.add_enabled(allowed, egui::Button::image(crate::icons::image(name, 18.0, t.text)).frame(false)); // Named for screen readers (and tests), not only on hover. - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, allowed, tip)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allowed, tip)); r.on_hover_text(tip).clicked() }; if icon(ui, "rotate-ccw", tl!("Rotate counterclockwise")) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fc0ee07c6..4f00aa6be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -234,6 +234,18 @@ pub struct Find { /// (page, glyph range) in document order. pub matches: Vec<(usize, Range)>, pub current: Option, + /// The page a search started from while its match is picked automatically: a text layer + /// that arrives out of order can still supply an earlier match, and the automatic choice + /// follows this page. Cleared when the user picks a match or moves the view themselves. + pub auto_from: Option, + /// The page the automatic choice last put the view on (the search's starting page until it + /// chose anything). Compared with the viewed page, it tells an automatic scroll from one + /// the reader made themselves; `None` with `auto_from` once the choice is the user's. + pub auto_page: Option, + /// The reader ended automatic following (by navigating, scrolling, or picking a match). + /// This is distinct from a search that never followed: a late match must be listed without + /// being revealed by scrolling, even when the reader moved before the first match arrived. + pub follow_ended: bool, pub focus: bool, pub case_query: String, /// Find options (Acrobat's: case-sensitive, whole words only). @@ -243,6 +255,51 @@ pub struct Find { pub in_panel: bool, } +/// What a text layer arriving after a search began may do with the automatic choice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Follow { + /// The reader has not moved the view since the automatic choice: it may follow `from`. + Active(usize), + /// The reader navigated, scrolled or picked a match, so no automatic choice may reveal. + Ended, + /// No automatic choice is being followed (the search has not started, or the reader chose). + Never, +} + +impl Find { + /// The first match at or after `page`, or the first match when none follows (the search + /// wraps round the document). + fn first_at_or_after(&self, page: usize) -> Option { + self.matches.iter().position(|(p, _)| *p >= page).or_else(|| (!self.matches.is_empty()).then_some(0)) + } + + /// How a text layer arriving now relates to the automatic choice. Once the reader has moved + /// the view (page box, Pages panel, outline, view history or any scrolling), automatic + /// following ends: a later layer must not pull them back to an anchor-derived match. + /// `Ended` is deliberately distinct from `Never`, so a first match arriving after that move + /// is not revealed by a flash meant only for a search that is still following. + fn follow_page(&mut self, viewed: usize) -> Follow { + if self.follow_ended { + return Follow::Ended; + } + match (self.auto_from, self.auto_page) { + (Some(from), Some(page)) if page == viewed => Follow::Active(from), + (Some(_), _) | (_, Some(_)) => { + self.stop_following(); + Follow::Ended + } + (None, None) => Follow::Never, + } + } + + /// Ends automatic following: the selected match is now the user's choice to keep. + fn stop_following(&mut self) { + self.auto_from = None; + self.auto_page = None; + self.follow_ended = true; + } +} + /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] struct Selection { @@ -730,6 +787,30 @@ impl DocView { self.auto_scroll.panning() } + /// Abandon only pointer previews: clearing egui's held button must not commit an app-owned + /// grab through a tool's `!primary_down` fallback. Keep selections, text drafts and edits + /// that were already completed before cancellation. + pub(crate) fn cancel_pointer_gestures(&mut self) { + self.marquee = None; + self.marquee_done = None; + self.comments.gesture = None; + self.links.drag = None; + self.sign.drag = None; + self.redact_drag = None; + self.crop_drag = None; + self.block_drag = None; + self.org_drag = None; + self.panel_drag = None; + self.selection_drag = false; + self.auto_scroll.cancel(); + self.prepare.cancel_pointer_gesture(); + self.content.cancel_pointer_gesture(); + self.objects.cancel_pointer_gesture(); + if let Some(image) = &mut self.image_selection { + image.cancel_pointer_gesture(); + } + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -1035,6 +1116,10 @@ impl DocView { self.last_queue.clear(); self.selection = None; self.all_text = false; + // A content change is not reader navigation: keep the automatic anchor so the + // re-extracted layers still follow it, and keep `follow_ended` so a reader who already + // took over stays in control. `document_changed` clamps `current`; if the view no longer + // sits where the automatic choice left it, `follow_page` ends it. if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1225,6 +1310,16 @@ impl DocView { f.case_query = f.query.clone(); f.matches.clear(); f.current = None; + // Refining the query keeps the page the reader started searching from, even when an + // automatic choice has already scrolled them to a match. Only a view the reader moved + // themselves re-anchors the search on the page now shown. + let from = match (f.auto_from, f.auto_page) { + (Some(from), Some(page)) if !f.follow_ended && page == self.current => from, + _ => self.current, + }; + f.auto_from = Some(from); + f.auto_page = Some(self.current); + f.follow_ended = false; if f.case_query.trim().is_empty() { return; } @@ -1236,12 +1331,12 @@ impl DocView { } } f.matches.sort_by_key(|(p, r)| (*p, r.start)); - if !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); + // First match at or after the page the search started from. + if let Some(i) = f.first_at_or_after(from) + && let Some(&(p, _)) = f.matches.get(i) + { f.current = Some(i); - let (p, _) = f.matches[i]; + f.auto_page = Some(p); self.flash_match(p); } } @@ -1251,21 +1346,33 @@ impl DocView { if f.case_query.trim().is_empty() { return; } - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + // An automatic choice follows the page it was anchored on, so a page whose text + // arrives late can still supply an earlier match. Once the reader has moved the view, + // this arrival must not pull them back; the choice then becomes theirs to keep. + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != page); f.matches.extend(text.find_opts(&f.case_query, f.case_sensitive, f.whole_words).into_iter().map(|r| (page, r))); f.matches.sort_by_key(|(p, r)| (*p, r.start)); - f.current = current_key.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); - if f.current.is_none() && !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); - f.current = Some(i); - let (p, _) = f.matches[i]; - if current_key.is_none() { + if let Follow::Active(from) = follow { + f.current = f.first_at_or_after(from); + let selected = f.current.and_then(|i| f.matches.get(i).cloned()); + // Follow a changed automatic choice; the view is still where the last one left it. + if selected != previous + && let Some((p, _)) = selected + { + f.auto_page = Some(p); self.flash_match(p); } + return; } + // A match the user picked keeps its identity, exactly as before automatic following. + // When it is gone (or none was picked), the first match at or after the viewed page is + // selected for the panel, but the view stays put: `Ended` and `Never` have no automatic + // choice to reveal, and a reader who moved before the first layer arrived must not be + // pulled back by that late match. + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); } /// Go to match `i` (the Search panel's results). @@ -1273,6 +1380,7 @@ impl DocView { let Some(f) = self.find.as_mut() else { return }; let Some(&(p, _)) = f.matches.get(i) else { return }; f.current = Some(i); + f.stop_following(); self.go_to_page(p); self.goto_point = None; self.flash_match(p); @@ -1288,6 +1396,7 @@ impl DocView { let c = f.current.unwrap_or(0); let next = if forward { (c + 1) % n } else { (c + n - 1) % n }; f.current = Some(next); + f.stop_following(); let p = f.matches[next].0; // Only explicit search navigation joins history; background refresh uses flash_match alone. self.go_to_page(p); @@ -1341,8 +1450,18 @@ impl DocView { self.wheel_boost_y.reset(); } + /// Ends automatic search following after the reader navigated or scrolled the view. A + /// programmatic search flash never comes through here; it sets `goto` and `current` directly + /// so that its own scroll cannot be mistaken for the reader's. + fn end_find_following(&mut self) { + if let Some(f) = self.find.as_mut() { + f.stop_following(); + } + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); + self.end_find_following(); if page != self.current { // The view history (Previous view / Next view). if self.back.last() != Some(&self.current) { @@ -1471,7 +1590,7 @@ impl DocView { /// Page-editor focus and drafts belong to a document, even while another tab is active. pub(crate) fn page_input_id(&self) -> egui::Id { - egui::Id::new(("page-input", self.id.0)) + egui::Id::unique(("page-input", self.id.0)) } /// The idle page box and unchanged-input lookup share the same label fallback. @@ -1504,6 +1623,11 @@ impl DocView { /// View ▸ Page navigation ▸ Previous view (`false`) / Next view (`true`). pub fn view_history(&mut self, forward: bool) -> bool { + let has_target = if forward { !self.forward.is_empty() } else { !self.back.is_empty() }; + if !has_target { + return false; + } + self.end_find_following(); let (from, to) = if forward { (&mut self.forward, &mut self.back) } else { (&mut self.back, &mut self.forward) }; let Some(page) = from.pop() else { return false }; to.push(self.current); @@ -2127,12 +2251,17 @@ impl DocView { log::warn!("page {} text: {}", r.request.page + 1, r.error.unwrap_or_default()); self.text_failed.insert(r.request.page); if let Some(f) = self.find.as_mut() { - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != r.request.page); - f.current = current_key.and_then(|k| f.matches.iter().position(|m| *m == k)); - if f.current.is_none() && !f.matches.is_empty() { - let cur = self.current; - f.current = Some(f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0)); + match follow { + // An automatic choice keeps following its anchor; a failure never + // flashes, so the view stays exactly where it was. + Follow::Active(from) => f.current = f.first_at_or_after(from), + Follow::Ended | Follow::Never => { + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); + } } } } @@ -2708,7 +2837,7 @@ fn page_scroll( id: egui::Id, draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, ) -> egui::scroll_area::ScrollAreaOutput { - ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner + ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a @@ -2717,6 +2846,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } // The Search panel closed: its search moves to the find bar. let search_open = app.right == Some(crate::RightPanel::Search); if let Some(f) = app.views[index].find.as_mut() @@ -2913,7 +3045,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // even while the page can't turn. ui.input(|i| { for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, phase, modifiers } = e { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; if fits { @@ -2964,8 +3096,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { line_base.y = 0.0; boost_extra.y = 0.0; } - if now < view.precise_scroll_until { - if ui.input(|i| i.is_scrolling()) { + // egui smooths a Point event that moved at least 8 points, keeping part of it for later + // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, + // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held + // point input measured 33 points for a 30-point event). Keep hiding the eased copy until + // egui says the action is over, then forget the suppression. + let scrolling = ui.input(|i| i.is_scrolling()); + if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { + if scrolling { // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace // avoids a delayed duplicate without prolonging suppression after the tail is gone. view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); @@ -2987,10 +3125,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } else { precise_scroll = Vec2::ZERO; - if line_base != Vec2::ZERO || has_page { - // Selection handling below uses zero to distinguish an inactive window. - view.precise_scroll_until = 0.0; - } + // Selection handling below uses zero to distinguish an inactive window. Clearing a past + // deadline also keeps a later line/page action from being mistaken for the old tail. + view.precise_scroll_until = 0.0; } if has_page && paging_owns_vertical { // Page input belongs to egui on the free axis; the pager still owns vertical motion. @@ -3012,6 +3149,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // Any reader-owned motion ends automatic search following, even when it stays on the same + // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the + // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so + // its programmatic scroll is never mistaken for a reader action. + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. let row = shown_row(view); @@ -3061,6 +3206,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { ui.input(|i| i.pointer.delta()) }; + if moved != Vec2::ZERO { + view.end_find_following(); + } // Within the content, as egui keeps it: past an end the page would show there for a // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); @@ -3203,7 +3351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| { + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -4010,6 +4158,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { + view.end_find_following(); ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); // Keep scrolling while the pointer rests there and sends no events. ui.ctx().request_repaint(); @@ -4113,7 +4262,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::new("copy-image-under-menu"); + let key = egui::Id::unique("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); let hit = match cached { Some(c) => c.2, @@ -4144,7 +4293,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // below the window's top). Within a point, as for wheel paging, so layout rounding // can't hide an edge. let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.offset.y <= MARGIN - view.page_gap() + 1.0, out.state.offset.y >= max_y - 1.0) + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); view.viewport_screen = out.inner_rect; @@ -4159,7 +4308,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; // pages with nothing on screen yet still queue. - let scroll_offset = out.state.offset; + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } let now_settle = ui.input(|i| i.time); if (scroll_offset - view.last_scroll).length() > 2.0 { view.pan_hold_until = now_settle + 0.25; @@ -4292,7 +4454,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. - let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_max_width(320.0); @@ -4566,26 +4728,29 @@ fn selection_actions( let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; - egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); + egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + for (label, action) in [ + (tl!("Add comment"), SelectionAction::Note), + (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + (tl!("Replace text"), SelectionAction::Replace), + ] { + if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { + chosen = Some(action); + } } - } - if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { - chosen = Some(SelectionAction::Copy); - } + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { + chosen = Some(SelectionAction::Copy); + } + }); }); - }); - }); + }, + ); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { // Copying closes the bar but keeps the words selected, for ⌘C or a second action. @@ -4754,7 +4919,7 @@ fn page_size_hint(view: &DocView, info: &DocInfo, units: crate::units::Unit, are return; } let text = units.size(f64::from(page.width), f64::from(page.height)); - egui::Area::new(egui::Id::new("page-size-hint")) + egui::Area::new(egui::Id::unique("page-size-hint")) .order(egui::Order::Foreground) .interactable(false) .pivot(egui::Align2::LEFT_BOTTOM) @@ -4781,7 +4946,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: let mut close = false; let mut step: Option = None; let searched = view.texts.len() + view.text_failed.len(); - egui::Area::new(egui::Id::new("find-bar")) + egui::Area::new(egui::Id::unique("find-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -4796,11 +4961,11 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: ui.horizontal(|ui| { ui.add(icons::image("search", 16.0, t.text_muted)); let edit = egui::TextEdit::singleline(&mut find.query) - .id(egui::Id::new("find-input")) + .id(egui::Id::unique("find-input")) .hint_text(tl!("Find text")) .desired_width(220.0) .frame(egui::Frame::NONE); - let r = ui.add(edit); + let r = ui.add(edit).accessible_name(tl!("Find text")); if find.focus { r.request_focus(); find.focus = false; @@ -5111,7 +5276,7 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let pos = area.left_top() + vec2(14.0, 14.0); - egui::Area::new(egui::Id::new("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.border)) @@ -5173,7 +5338,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5252,7 +5417,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5386,7 +5551,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)) .frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { view.grid_zoom_request = Some(1.0); } @@ -5449,8 +5614,8 @@ fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool { /// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { - let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.scope_id().with(("org-gap", gap)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.clone())); let hot = resp.hovered(); if hot { ui.painter().line_segment([pos2(at.x, at.y - height / 2.0), pos2(at.x, at.y + height / 2.0)], Stroke::new(3.0, t.accent)); @@ -5514,7 +5679,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let Some(p) = info.pages.get(i) else { break }; let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell); view.need_thumbnail(i, c.intersects(ui.clip_rect())); - let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); + let resp = ui.interact(c, ui.scope_id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); cells.push((i, c)); if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); @@ -5533,7 +5698,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool drop = true; } let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), page_label.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); let size = pr.size(); let selected = view.selected.contains(&i) || (view.selected.is_empty() && i == view.current); @@ -5586,7 +5751,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let check_rect = Rect::from_min_size(c.min + vec2(8.0, 8.0), Vec2::splat(20.0)); let check = ui.push_id(("org-check", i), |ui| ui.put(check_rect, widgets::checkbox_without_text(&mut checked))).inner; let label = crate::i18n::fmt(tl!("Select page {label}"), &[("label", &p.label)]); - check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label.clone())); + check.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, checked, label.clone())); if check.on_hover_text(label).changed() { view.click_page(i, egui::Modifiers::COMMAND, false); // The document shows the page picked here when the grid closes, as after a @@ -6148,14 +6313,445 @@ trailer << /Root 1 0 R >> assert_eq!(v.goto, Some((3, 0.5))); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); - v.current = 0; v.find.as_mut().unwrap().current = None; - v.refresh_find_page(3); - assert_eq!(v.current, 3); - assert!(v.back.is_empty()); + v.texts.insert(1, Arc::new(find_test_text("match"))); + v.refresh_find_page(1); + assert_eq!(v.current, 1, "an automatic late match still follows its anchor"); + assert!(v.back.is_empty(), "automatic search updates do not record history"); assert_eq!(v.forward, vec![4]); } + /// One glyph per character on one line, for the search-order tests below. + fn find_test_text(s: &str) -> PageText { + PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + } + } + + /// A text-layer result for `page`, for the search tests below. + fn find_text_result(page: usize, text: &str) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }, + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + } + } + + /// Text layers arrive in whatever order the workers finish, not in page order. A search + /// started on page 1 must settle on page 1's match even when page 2's text arrives first, + /// instead of keeping the later match picked only because page 1 had not been read yet. + #[test] + fn automatic_search_match_follows_the_viewed_page_when_text_arrives_out_of_order() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!(v.find.as_ref().unwrap().current, None, "no text yet, so no match"); + + let ctx = egui::Context::default(); + let request = |page| RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + let result = |page, text: &str| pdfcraft_render::RenderedPage { + request: request(page), + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + }; + v.last_queue = vec![request(1), request(0)]; + // Page 2's text finishes first, as it can when the workers race. + v.receive_result(&ctx, result(1, "Second page sample text")); + assert_eq!(v.find.as_ref().unwrap().current, Some(0), "the only known match is picked while page 1 is pending"); + + v.receive_result(&ctx, result(0, "Panel sample draft notes")); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(0), "page 1's match takes over once its text arrives"); + assert_eq!(v.current, 0, "the view follows the first match at or after the page being viewed"); + } + + /// A match the user picked (a click in the Search panel, or ⌘G) is not moved by a text + /// layer that arrives later: only automatic choices follow the viewed page. + #[test] + fn a_user_picked_search_match_stays_put_when_earlier_text_arrives() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.texts.insert(1, Arc::new(find_test_text("Second page sample text"))); + v.refresh_find_page(1); + v.go_to_match(0); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "an explicit choice is not automatic"); + + v.texts.insert(0, Arc::new(find_test_text("Panel sample draft notes"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(1), "the picked match keeps its identity after page 1 is inserted"); + assert_eq!(v.current, 1, "the user's page does not change under them"); + } + + /// The automatic choice may move the view only while the reader has left it where that + /// choice put it. Navigating by the page box/Pages panel/outline or by view history is the + /// reader's decision: a text layer arriving afterwards must not pull them back to an + /// anchor-derived match. (The harness covers same-page wheel and pointer scrolling.) + #[test] + fn a_late_automatic_match_does_not_override_the_readers_navigation() { + enum Nav { + Page, + History, + } + for nav in [Nav::Page, Nav::History] { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (2, Some(2)), "the automatic match was shown"); + match nav { + // The page box, the Pages panel and outline destinations all end here. + Nav::Page => v.go_to_page(1), + // View ▸ Page navigation ▸ Previous view writes `current` directly. + Nav::History => { + v.back = vec![1]; + assert!(v.view_history(false)); + } + } + assert_eq!(v.current, 1); + v.texts.insert(0, Arc::new(find_test_text("First page sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, None, "the reader's navigation ends automatic following"); + assert_eq!(f.auto_page, None); + assert_eq!(v.current, 1, "a late match cannot pull the reader back"); + } + } + + /// Typing on while the results arrive must not re-anchor the search on a page the automatic + /// flash picked. The anchor stays the page the reader was on when they started searching. + #[test] + fn refining_the_query_keeps_the_page_the_search_started_from() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample text"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (2, Some(0)), "the first query's match scrolled the view"); + + // Page 1 has a match for the refined query, and so has page 3. Anchored on the page the + // automatic flash reached, the search would pick page 3; the starting page picks page 1. + v.texts.insert(0, Arc::new(find_test_text("Panel draft text"))); + v.find.as_mut().unwrap().query = "text".into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader searched from"); + assert_eq!(f.matches.first().map(|(p, _)| *p), Some(0)); + assert_eq!(f.current, Some(0)); + assert_eq!(v.current, 0); + } + + /// ⌘G / Ctrl+G goes through `find_step`, an explicit choice like a Search-panel click: a + /// text layer arriving later must not undo it. + #[test] + fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (1, Some(0))); + + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::G, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + }); + shortcuts(&mut v, &ctx, Default::default()); + + assert_eq!(v.current, 2, "the keyboard step chose the next match"); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); + assert_eq!(v.back, vec![1], "an explicit search jump records its origin"); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the chosen match keeps the view"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(2)); + } + + /// A page whose text extraction failed keeps the automatic anchor: the failure selects the + /// next match without moving the view, and a later text layer can still follow the anchor. + #[test] + fn a_failed_text_layer_keeps_the_automatic_anchor_without_jitter() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1))); + + let goto = v.goto; + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![2]); + assert_eq!(f.current, Some(0), "the remaining match is selected"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)), "a failure does not end automatic following"); + assert_eq!(v.current, 1, "a failure never moves the view"); + assert_eq!(v.goto, goto, "a failure never flashes the match"); + + // The search still follows its original page when that page's text arrives. + v.last_queue = vec![RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the anchor's earlier match takes over"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0)); + } + + /// A reader who navigated before a page's text failed is not pulled back by a later + /// matching layer: the failure path must preserve the same `Ended` state as a successful + /// one, and the result list may select the late match without revealing it. + #[test] + fn a_failed_text_layer_after_navigation_does_not_reveal_a_late_match() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended, "the reader navigated before any match arrived"); + + let ctx = egui::Context::default(); + let failed = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![failed]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request: failed, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a failure after navigation does not restart following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "a failure never moves the view"); + assert_eq!(v.goto, moved); + + let arrived = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![arrived]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "the late matching layer does not pull the reader back"); + assert_eq!(v.goto, moved, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// The reader can navigate before any text layer has supplied a match. That navigation + /// still ends automatic following, so the first late match is listed but not revealed by + /// a flash that would pull the reader back. + #[test] + fn navigation_before_the_first_match_is_not_overridden_by_a_late_text_layer() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current, None, "no text layer has arrived yet"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + v.go_to_page(2); + assert_eq!(v.goto, Some((2, 0.0)), "the reader moved to page 3"); + assert!(v.find.as_ref().unwrap().follow_ended, "moving the view ends automatic following"); + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(1, "late sample")); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the late first match does not pull the reader back"); + assert_eq!(v.goto, Some((2, 0.0)), "no flash was issued"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the result list still selects the match"); + } + + /// A page-box jump to the page already shown is still the reader's navigation: automatic + /// following must end, so a late earlier match is listed without moving the view. (The + /// harness covers wheel, scrollbar and pointer scrolling through the production paint path.) + #[test] + fn a_same_page_page_box_jump_ends_automatic_following() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + // Choosing the page already shown still ends following; it stays on page 2 at its top. + v.go_to_page(1); + let shown = v.goto; + assert_eq!(shown, Some((1, 0.0))); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late earlier match does not move the reader within the page"); + assert_eq!(v.goto, shown, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the page-2 match stays selected"); + } + + /// A document-wide invalidation (a layer toggle, an edit, undo/redo) clears results for + /// re-extraction but is not the reader navigating. Automatic following keeps its anchor so + /// the re-extracted layers still reveal the automatic choice; a reader who already took + /// over stays in control. If `document_changed` clamps the view away from that anchor, the + /// next layer ends following instead of revealing a match from the stale page. + #[test] + fn content_invalidation_keeps_the_following_state() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.matches.is_empty() && f.current.is_none(), "results wait for the re-extracted text"); + assert!(!f.follow_ended, "a content change is not reader navigation"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1))); + + // Re-extracted text still follows the page the search started from. + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the re-extracted match is revealed by the still-active search"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + // A reader who navigated first keeps the view they chose across the invalidation. + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended); + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "late sample")); + assert_eq!(v.current, 2, "no delayed reveal after the reader navigated"); + assert_eq!(v.goto, moved, "no flash was issued"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// A document edit can shrink the page count. `document_changed` clamps `current` but leaves + /// the automatic anchor's page index alone; a re-extracted layer must then end following + /// rather than reveal a match by scrolling from a page that no longer exists. + #[test] + fn a_page_count_shrink_ends_following_before_a_late_reveal() { + let mut v = view(5, PageLayout::Single, true); + v.current = 0; + v.texts.insert(4, Arc::new(find_test_text("last sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (4, Some(4)), "the automatic choice is on the last page"); + + let info = pdfcraft_render::DocInfo { + pages: (0..2) + .map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + v.document_changed(&info); + assert_eq!(v.current, 1, "the view clamps to the last remaining page"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "the content change itself keeps the automatic state"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(4)), "the anchor still names the removed page"); + let goto = v.goto; + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a layer for the remaining pages ends the stale follow"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late layer cannot pull the view back"); + assert_eq!(v.goto, goto, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the match"); + } + + /// Typing refinements while text layers keep arriving must keep the page the search began + /// on, not re-anchor on whichever page an earlier automatic flash happened to show. + #[test] + fn rapid_query_edits_interleaved_with_text_results_keep_the_start_anchor() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sam".into(), ..Default::default() }); + v.rerun_find(); + assert!(v.find.as_ref().unwrap().current.is_none()); + + let edits = [("samp", 2, "Page three sample", 2), ("sampl", 1, "Page two sample", 1), ("sample", 0, "Page one sample", 0)]; + for (query, page, text, shown) in edits { + v.find.as_mut().unwrap().query = query.into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader started from"); + assert!(!f.follow_ended); + v.texts.insert(page, Arc::new(find_test_text(text))); + v.refresh_find_page(page); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, shown, "the late page follows the automatic anchor {shown}"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(shown))); + assert!(!f.follow_ended, "the automatic flash itself does not end following"); + } + assert!(v.back.is_empty(), "automatic search updates do not record history"); + } + #[test] fn text_layers_evict_far_pages_but_keep_visible_and_matches() { let info = thumbnail_info(100); @@ -6977,6 +7573,7 @@ trailer << /Root 1 0 R >> delta: vec2(0.0, -100_000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); // Well past egui's longest scroll animation (about 0.3 s). h.run_steps(40); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b7dd6b3ed..b97711246 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,7 +1,7 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; -use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; @@ -27,6 +27,8 @@ const TAB_STRIP: f32 = 38.0; const TAB_HEIGHT: f32 = 30.0; const STRIP_BUTTON: f32 = 28.0; +/// Draw the window's tab strip: home and open controls, shrinkable and scrollable document tabs with drag +/// reordering, and platform window controls. pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and @@ -39,7 +41,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); + let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); } @@ -93,7 +95,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let cap = tab_cap(&natural, tabs_width, gap, TAB_MIN_WIDTH); // Where the tabs were scrolled to last frame, and how far they can go. - let scroll_id = ui.id().with("tab_scroll"); + let scroll_id = ui.scope_id().with("tab_scroll"); let (offset, max_offset) = ui.data(|d| d.get_temp::<(f32, f32)>(scroll_id)).unwrap_or((0.0, 0.0)); let mut scroll_to = None; if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) { @@ -103,7 +105,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // the strip jumping to it. let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing()); let changed = ui.data_mut(|data| { - let id = ui.id().with("active_tab"); + let id = ui.scope_id().with("active_tab"); let changed = data.get_temp::<(Option, usize, bool)>(id) != Some(state); data.insert_temp(id, state); changed @@ -132,7 +134,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let file = (name != doc.name).then_some(doc.name.as_str()); let response = tab( ui, - ui.id().with(("document-tab", app.views[i].id)), + ui.scope_id().with(("document-tab", app.views[i].id)), &t, "file-text", &name, @@ -214,7 +216,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut close = None; let response = tab( ui, - ui.id().with("combine-tab"), + ui.scope_id().with("combine-tab"), &t, "files", tl!("Combine files"), @@ -240,7 +242,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); let max = (out.content_size.x - out.inner_rect.width()).max(0.0); - ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max))); + ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.clamped_offset().x, max))); // A tab dragged to either edge of a strip that scrolls brings the hidden tabs in. if let Some(x) = drag_x && max > 0.0 @@ -253,7 +255,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 0.0 }; if step != 0.0 { - ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max))); + ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.clamped_offset().x + step).clamp(0.0, max))); ui.ctx().request_repaint(); } } @@ -384,7 +386,7 @@ fn tab_name(name: &str) -> String { /// The width a tab takes when nothing has to shrink. fn tab_natural_width(ui: &egui::Ui, t: &Tokens, name: &str) -> f32 { - let label = crate::bidi::visual(&tab_name(name)).into_owned(); + let label = tab_name(name); ui.fonts_mut(|f| f.layout_no_wrap(label, theme::regular(13.0), t.text).size().x) + TAB_CHROME } @@ -412,6 +414,8 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option { None } +/// Draw one tab with its icon, elided label, close button and unsaved dot; records a close request and +/// returns the tab response for drag handling. #[allow(clippy::too_many_arguments)] fn tab( ui: &mut egui::Ui, @@ -433,13 +437,13 @@ fn tab( // A shrunk tab shows as much of its name as fits (the tooltip has all of it). if let Some(cap) = cap { let room = (cap - TAB_CHROME).max(0.0); - label = ui.fonts_mut(|f| { - crate::panels::ellipsized_prefix(&label, |s| f.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= room) - }); + label = crate::panels::ellipsized_prefix(&label, |s| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), t.text).size().x <= room); } - // Painted text only: the accessibility name below keeps the logical order. - let label = crate::bidi::visual(&label).into_owned(); - let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); + // Logical text, laid out explicitly left to right: the fork shapes and reorders it once, + // and an Arabic file name keeps its extension at the visual end. AccessKit names stay logical. + let label_color = if active { t.text } else { t.text_muted }; + let label_galley = crate::bidi::layout_ltr(ui, label.clone(), font.clone(), label_color); + let text_w = label_galley.size().x; let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap)); // Full strip height so the active tab sits on the mode bar instead of floating in the title bar. let height = ui.available_height().max(height); @@ -463,7 +467,7 @@ fn tab( 15.0, if active { t.accent } else { t.text_muted }, ); - ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted }); + ui.painter().galley(rect.min + vec2(28.0, (rect.height() - label_galley.size().y) / 2.0), label_galley.clone(), label_color); }); let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0)); let x = ui.interact(x_rect, id.with("close"), Sense::click()); @@ -479,14 +483,10 @@ fn tab( *close = Some(index); } let shown = match file { - Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))), - None => crate::bidi::visual(name), + Some(file) => format!("{name}\n{file}"), + None => name.to_string(), }; - theme::hand(resp).on_hover_text(if dirty { - crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) - } else { - shown.into_owned() - }) + theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown }) } pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -637,6 +637,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the +/// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -675,7 +677,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { items.push(item(RightPanel::Tags, "tag", "Tags", None)); let view = &mut app.views[index]; let mut page_display = None; - let rail = Rail::new("rail") + let rail = Rail::new(egui::Id::unique("rail")) .edge(Edge::End) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, &items, app.right.as_ref(), |ui| { @@ -710,13 +712,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; view.goto = Some((view.current, 0.0)); } - let id = egui::Id::new("zoom-input"); + let id = egui::Id::unique("zoom-input"); let draft = id.with("draft"); let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); let edit = egui::TextEdit::singleline(&mut typed) .id(id) .desired_width(40.0) - .horizontal_align(Align::Center) + .align(egui::Align2::CENTER_TOP) .font(theme::medium(11.0)) .text_color(t.text_muted) .frame(egui::Frame::NONE) @@ -782,7 +784,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let edit = egui::TextEdit::singleline(&mut view.page_input) .id(page_input_id) .desired_width(34.0) - .horizontal_align(Align::Center) + .align(egui::Align2::CENTER_TOP) .font(theme::medium(12.0)) .margin(vec2(2.0, 4.0)); let r = egui::Frame::NONE @@ -791,7 +793,8 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .corner_radius(CornerRadius::same(5)) .show(ui, |ui| ui.add(edit)) .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")) + .accessible_name(tl!("Current page")); if r.lost_focus() { if ui.input(|i| i.key_pressed(egui::Key::Enter)) { // A page label first (logical page numbers, as Acrobat), then a number. diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index ee3b03f69..f66ec0b84 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -614,7 +614,14 @@ pub(crate) fn grid( let over_grid = ui.rect_contains_pointer(viewport); let mut width = viewport.width(); let mut focused_card = None; - let output = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]).show_viewport(ui, |ui, clip| { + // A zoom in the previous frame asks for a new scroll position. 0.37 keeps `State`'s offset + // private, so the request is applied through the builder and consumed here. + let pending_offset = ctx.data_mut(|d| d.remove_temp::(pending_offset_id())); + let mut grid = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]); + if let Some(offset) = pending_offset { + grid = grid.vertical_scroll_offset(offset.y); + } + grid.show_viewport(ui, |ui, clip| { // Dragging near the top or bottom edge scrolls. if drag.is_some() && let Some(p) = pointer @@ -792,12 +799,10 @@ pub(crate) fn grid( let cols = columns_in(width, cell); let content = TOP + places.len().div_ceil(cols) as f32 * cell.y; let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0); - // Set as the scroll position itself, before the next frame draws anything: no + // Applied by the next frame's scroll area, before anything is drawn there: no // jump, and no scroll still animating (to a card revealed by the keys, say) can - // carry it elsewhere. - let mut state = egui::scroll_area::State::default(); - state.offset = vec2(0.0, y); - state.store(&ctx, output.id); + // carry it elsewhere. The request is consumed once. + ctx.data_mut(|d| d.insert_temp(pending_offset_id(), vec2(0.0, y))); } ctx.request_repaint(); } @@ -806,6 +811,11 @@ pub(crate) fn grid( action } +/// The scroll position a zoom asked the next frame's grid to start at. +fn pending_offset_id() -> egui::Id { + egui::Id::unique("pdfcraft-combine-grid-offset") +} + /// One card's place and what it shows. struct Card { /// The file, by its place in the list. @@ -826,8 +836,8 @@ struct Card { impl Card { fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { - None => ui.id().with(("combine-card", f.id)), - Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)), + None => ui.scope_id().with(("combine-card", f.id)), + Some((nth, ..)) => ui.scope_id().with(("combine-page", f.id, nth)), } } @@ -845,7 +855,7 @@ impl Card { }; // As the organize grid's pages: a check box that is on when selected; and, for screen // readers, selected as items in a list are. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, self.selected, &label)); let pages = match self.page { // For screen readers: which page of how many. Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]), @@ -969,9 +979,11 @@ impl Card { } None => (theme::medium(12.5), t.text, name_y), }; - let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w; - let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned(); - painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour); + let fits = |s: &str| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w; + let shown = fit_name(&f.name, fits); + // A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`. + let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour); + painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, colour); let note = if self.page.is_none() && !f.range.trim().is_empty() { match &self.check { Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)), @@ -1143,10 +1155,10 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { // No larger than the GPU takes, or the render would be refused. let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); let thumbs = &mut app.combine_thumbs; - let modal = egui::Modal::new(egui::Id::new("combine-preview")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| { ui.set_width(width); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual(&f.name)).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { close = true; diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 62d916315..e7f7f26a1 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -742,7 +742,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.close(); } for (i, name) in &open_docs { - if ui.button(crate::bidi::visual(name).as_ref()).clicked() { + if ui.button(name.as_str()).clicked() { app.add_open_documents(&[*i]); ui.close(); } @@ -921,7 +921,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default())); } let mut choice: Option = None; - let modal = egui::Modal::new(egui::Id::new("combine-skip")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-skip")).show(ctx, |ui| { ui.set_width(460.0); ui.horizontal(|ui| { ui.add(icons::image("triangle-alert", 22.0, ERROR)); @@ -932,7 +932,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) { ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(format!("• {}", crate::bidi::visual(name))).strong()); + ui.label(egui::RichText::new(format!("• {name}")).strong()); ui.label(egui::RichText::new(*why).color(t.text_muted)); }); } @@ -978,7 +978,7 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { }; let batch = files.len() > 1; let (mut submit, mut cancel) = (false, false); - let area = egui::Area::new(egui::Id::new("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { @@ -1189,7 +1189,7 @@ fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32 let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)).frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { app.combine_tab.zoom_request = Some(1.0); } @@ -1347,7 +1347,7 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b None => "", }; let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") }; - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &a11y)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a11y)); let being_dragged = egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|p| p.0 == key); if being_dragged || response.is_pointer_button_down_on() { ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed); @@ -1395,6 +1395,8 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b event } +/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata +/// and per-row warnings. #[allow(clippy::too_many_arguments)] fn table_body( app: &mut PdfCraftApp, @@ -1410,7 +1412,7 @@ fn table_body( let order = app.combine_columns.order; // The heading being dragged follows the pointer. if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::(ui.ctx()), ui.ctx().pointer_latest_pos()) { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("combine-heading-drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("combine-heading-drag"))); let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text); let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0)); painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); @@ -1454,7 +1456,7 @@ fn table_body( row.set_selected(rows.selected.get(i).copied().unwrap_or(false)); // Drag handle. row.col(|ui| { - let id = ui.id().with(("combine-drag", f.id)); + let id = ui.scope_id().with(("combine-drag", f.id)); ui.dnd_drag_source(id, i, |ui| { let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover()); grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint }); @@ -1467,7 +1469,8 @@ fn table_body( row.col(|ui| match kind { SortKey::Name => { ui.add(icons::image("file-text", 16.0, t.icon)); - ui.add(egui::Label::new(crate::bidi::visual(&f.name).as_ref()).truncate().selectable(false)); + // A file name is file/path chrome: explicitly LTR, extension last. + crate::widgets::label_ltr_truncated(ui, f.name.clone(), theme::regular(13.0), t.text); } // The range field and how many pages it takes. SortKey::Pages => { @@ -1581,7 +1584,7 @@ fn table_body( let r = response.rect; let y = if from > i { r.top() } else { r.bottom() }; if from != i { - response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("combine-drop-line"))).hline( + response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("combine-drop-line"))).hline( r.x_range(), y, Stroke::new(2.0, t.accent), diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 928d5cbf6..bf9b5fe72 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -1621,12 +1621,12 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, anchor = anchor.clamp(viewport.min, viewport.max); } let pos = pos2(anchor.x + 12.0, anchor.y); - let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(prefs.author.clone()).font(theme::semibold(13.0))); ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint)); }); ui.add_space(6.0); @@ -1636,8 +1636,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => tl!("Replacement text"), _ => tl!("Add a comment"), }; - let edit = - ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")); + let edit = ui + .add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")) + // 0.37 kittest rejects an unnamed text input; the hint is its logical name. + .accessible_name(hint); if c.focus { edit.request_focus(); c.focus = false; @@ -2056,8 +2058,9 @@ pub enum CanvasAction { /// How many custom colours the "Recent colours" row keeps. const RECENT_CUSTOM: usize = 5; +/// Return the stable egui id for the recent custom-colour palette. fn recent_custom_id() -> egui::Id { - egui::Id::new("pdfcraft-recent-custom-colours") + egui::Id::unique("pdfcraft-recent-custom-colours") } /// Custom colours picked with "Other colour…" this session, newest first (#1193). @@ -2091,7 +2094,7 @@ fn rgb_of(c: Color32) -> Rgb { /// One round swatch; `on` rings it as the current colour. fn swatch(ui: &mut egui::Ui, c: Rgb, on: bool, tooltip: &str) -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tooltip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tooltip)); ui.painter().circle_filled(r.center(), 9.0, color32(c)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40))); if on || resp.hovered() { @@ -2113,7 +2116,7 @@ fn other_colour_swatch(ui: &mut egui::Ui, open: bool) -> egui::Response { ui.painter().circle_stroke(c, 11.0, Stroke::new(1.5, SELECT_BLUE)); } let label = tl!("Other colour…"); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, open, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, open, label)); resp.on_hover_text(label) } @@ -2202,7 +2205,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let resp = resp.on_hover_text(tl!("Colour")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { if let Some(c) = swatch_grid(ui, Some(style.color)) { @@ -2266,7 +2269,7 @@ fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { Some(_) => tl!("Fill colour"), None => tl!("Fill colour: none"), }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); resp.on_hover_text(label) } @@ -2587,7 +2590,7 @@ mod tests { h.run(); h.get_by_label("Page").click_secondary(); h.run(); - h.get_by_label("Colour ⏵").click(); + h.get_by_label("Colour").click(); h.run(); h.get_by_label("Other colour…").click(); h.run(); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 627385b22..54ca9e515 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -124,7 +124,9 @@ pub(crate) fn show( let mut edit = None; // Search (the header's magnifier toggles it). if let Some(q) = view.comments.search.as_mut() { - let r = ui.add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")); + let r = ui + .add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")) + .accessible_name(tl!("Search comments")); if view.comments.search_focus { r.request_focus(); view.comments.search_focus = false; @@ -133,9 +135,14 @@ pub(crate) fn show( } // "Add a comment": a sticky note on the current page, near its top-right corner. if allowed { - let r = ui.add( - crate::widgets::line(&mut view.comments.add_box).hint_text(tl!("Add a comment")).desired_width(f32::INFINITY).id_salt("comment-add-box"), - ); + let r = ui + .add( + crate::widgets::line(&mut view.comments.add_box) + .hint_text(tl!("Add a comment")) + .desired_width(f32::INFINITY) + .id_salt("comment-add-box"), + ) + .accessible_name(tl!("Add a comment")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() { let page = view.current.min(info.pages.len().saturating_sub(1)); if let Some(p) = info.pages.get(page) { @@ -238,9 +245,7 @@ pub(crate) fn show( if g.is_some() && g != group { ui.add_space(6.0); let title = g.clone().unwrap_or_default(); - // Catalog labels are already in display order; an author's name is put in it here. - let shown = if sort == SortBy::Author && a.author.is_some() { crate::bidi::visual_auto(&title) } else { tl!(&title).into() }; - ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted)); + ui.label(egui::RichText::new(tl!(&title)).font(theme::semibold(12.5)).color(t.text_muted)); group = g; } } else if a.page != page { @@ -287,6 +292,8 @@ pub(crate) fn show( edit } +/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment +/// actions; returns an edit to apply. #[allow(clippy::too_many_arguments)] fn card( ui: &mut egui::Ui, @@ -322,7 +329,8 @@ fn card( let mut above = egui::Rect::NOTHING; let head = ui.horizontal(|ui| { above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); - let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto); + // The catalogue and author names stay logical: egui lays out RTL text itself. + let author = a.author.as_deref().map_or_else(|| tl!("Unknown author"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -402,7 +410,7 @@ fn card( ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { - let author = r.author.as_deref().map_or_else(|| tl!("Reply").into(), crate::bidi::visual_auto); + let author = r.author.as_deref().map_or_else(|| tl!("Reply"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.0))); if let Some(m) = &r.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -461,7 +469,7 @@ fn card( // clickable everywhere. let hit = if selected { frame.inner } else { rect }; let click = ui - .interact(hit, ui.id().with(("card", key)), Sense::click()) + .interact(hit, ui.scope_id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { @@ -472,7 +480,7 @@ fn card( if click.double_clicked() && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } - let menu_id = ui.id().with(("card-menu", key)); + let menu_id = ui.scope_id().with(("card-menu", key)); if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); } @@ -636,8 +644,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.separator(); ui.label(egui::RichText::new(tl!("Author")).small()); for a in &authors { - let name = crate::bidi::visual_auto(a); - toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { &name }); + toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a.as_str() }); } ui.separator(); ui.label(egui::RichText::new(tl!("Status")).small()); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 0d86f7dd8..0da3b235c 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -91,6 +91,11 @@ pub struct ContentView { } impl ContentView { + /// Forget an in-progress pointer grab for content editing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.grab = None; + } + pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { self.draft.take().and_then(|d| finish(self, d, added)) } @@ -585,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). - egui::Area::new(egui::Id::new(("added-text", view.id.0))) + egui::Area::new(egui::Id::unique(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(at) @@ -619,7 +624,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .font(font.clone()) .desired_width(width) .desired_rows(1) - .horizontal_align(halign) + .align(egui::Align2::new(halign, egui::Align::TOP)) + // Keep the page editor's original inset; the theme's larger button padding + // otherwise shifts narrow right-aligned boxes away from their text edge. + .margin(egui::Margin::symmetric(4, 2)) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) @@ -627,7 +635,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad if letter_spacing != 0.0 { edit = edit.layouter(&mut spaced); } - let resp = ui.add(edit); + let resp = ui.add(edit).accessible_name(tl!("Added text")); if t.focus { resp.request_focus(); t.focus = false; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 444afd73d..db6e2445d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -53,21 +53,14 @@ use serde_json::{Value, json}; pub type Reply = Result; /// Seconds to wait for frames (input to be handled, a screenshot to be taken) before reporting -/// that the window is not being drawn. While a window is hidden, minimized or fully covered, -/// eframe keeps calling `logic` but runs no egui pass, so input and screenshots wait. +/// that the window is not being drawn. `App::logic` also runs while a window is hidden, +/// minimized or fully covered, where egui's own input clock does not advance; every deadline +/// here uses a monotonic `web_time::Instant` so hidden logic cannot stall or restart it. const NOT_DRAWN_TIMEOUT: f64 = 5.0; -/// Wall-clock seconds (egui's own clock stops while the window is not drawn). -fn now_secs() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() - } - #[cfg(target_arch = "wasm32")] - { - js_sys::Date::now() / 1000.0 - } +/// Return the deadline used to reject control requests that wait for a drawn window. +fn timeout() -> std::time::Duration { + std::time::Duration::from_secs_f64(NOT_DRAWN_TIMEOUT) } fn not_drawn(what: &str) -> String { @@ -76,8 +69,23 @@ fn not_drawn(what: &str) -> String { ) } +/// Return the actionable error text for a control request whose window was not drawn in time. +fn still_starting() -> String { + format!("the app was still starting after {NOT_DRAWN_TIMEOUT:.0} s; the control request was not applied, retry when the window is drawn") +} + +/// The app finished starting, but only after this request's one admission deadline had already +/// passed. Report that instead of claiming the app is still starting. +fn startup_deadline_expired() -> String { + format!( + "the app finished starting after this control request's {NOT_DRAWN_TIMEOUT:.0} s deadline; the request was not applied, retry now that the window is drawn" + ) +} + /// One request from a client, answered on `reply`. pub struct ControlRequest { + /// Admission time on the sending thread, including time waiting for the next app tick. + pub since: web_time::Instant, pub method: String, pub params: Value, pub reply: Sender, @@ -94,7 +102,7 @@ impl ControlClient { /// Queue a request; the reply arrives on the returned receiver once the app has handled it. pub fn send(&self, method: &str, params: Value) -> Receiver { let (reply, rx) = channel(); - let req = ControlRequest { method: method.into(), params, reply }; + let req = ControlRequest { since: web_time::Instant::now(), method: method.into(), params, reply }; if let Err(e) = self.tx.send(req) { let _ = e.0.reply.send(Err("the app has closed".into())); } @@ -109,12 +117,99 @@ struct Shared { nodes: HashMap, root: Option, focus: Option, - /// Event batches to inject, one batch per frame. - inject: VecDeque>, - /// Real egui passes so far (counted in `output_hook`, which `logic`-only calls never reach). + /// Queued synthetic-input requests, each with one event batch per UI pass. + inject: VecDeque, + /// Accepted (not discarded) egui passes, counted in `output_hook`. passes: u64, + /// The document/focus generation the app last admitted input for. + epoch: u64, + /// Input requests whose queued events became stale before a UI pass consumed them. + dropped: Vec, + next_batch: u64, + /// A batch admitted before its deadline may finish this pass. Expiry runs before the next + /// input hook (or in hidden logic), never after egui has already calculated its interaction. + in_pass: bool, + expired: Vec, + cleanup: Vec, + cancel_pointer: bool, + cancel_gestures: bool, } +/// One queued synthetic-input request: its batches are injected one per UI pass. +struct RequestBatches { + request: u64, + /// The document/dialog/focus generation the request was admitted for. + epoch: u64, + /// The first batch was injected, so a focus/dialog change does not reject its own remaining + /// batches. The admission deadline still cancels a partially delivered gesture. + started: bool, + since: web_time::Instant, + /// Inverse presses still held by this request. Pointer entries are ownership markers for + /// cancellation only: replaying one on timeout would click or drop the abandoned gesture. + release: Vec, + batches: VecDeque>, +} + +/// Track only presses that reached egui and have not yet been released. A pointer release is +/// retained as an ownership marker, never as a timeout event. +fn note_pressed(release: &mut Vec, events: &[egui::Event]) { + for event in events { + match event { + egui::Event::Key { key, pressed: true, .. } + if !release.iter().any(|event| matches!(event, egui::Event::Key { key: seen, pressed: false, .. } if seen == key)) => + { + release.push(egui::Event::Key { key: *key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::PointerButton { pos, button, pressed: true, .. } + if !release + .iter() + .any(|event| matches!(event, egui::Event::PointerButton { button: seen, pressed: false, .. } if seen == button)) => + { + release.push(egui::Event::PointerButton { pos: *pos, button: *button, pressed: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::ModifiersChanged(modifiers) + if *modifiers != egui::Modifiers::NONE && !release.contains(&egui::Event::ModifiersChanged(egui::Modifiers::NONE)) => + { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + egui::Event::Key { key, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::Key { key: seen, .. } if seen == key)); + } + egui::Event::PointerButton { button, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::PointerButton { button: seen, .. } if seen == button)); + } + egui::Event::ModifiersChanged(modifiers) if *modifiers == egui::Modifiers::NONE => { + release.retain(|event| !matches!(event, egui::Event::ModifiersChanged(_))); + } + _ => {} + } + } +} + +/// Cancel expired requests before egui computes interaction. Hidden logic calls this too, so +/// replies do not wait for a visible frame. Never complete an abandoned pointer gesture. +fn expire_input(shared: &mut Shared, now: web_time::Instant) { + shared.inject.retain_mut(|request| { + if now.duration_since(request.since) <= timeout() { + return true; + } + shared.expired.push(request.request); + for event in request.release.drain(..) { + if matches!(event, egui::Event::PointerButton { .. }) { + shared.cancel_pointer = true; + shared.cancel_gestures = true; + } else { + shared.cleanup.push(event); + } + } + false + }); +} + +/// Synthetic input is bounded like screenshots: a local authenticated client must not be able to +/// queue unbounded batches (one frame each) and hold the control channel's memory for ever. +const MAX_QUEUED_BATCHES: usize = 512; + /// The egui plugin half of the control channel. pub struct ControlPlugin { shared: Arc>, @@ -125,19 +220,71 @@ impl egui::Plugin for ControlPlugin { "pdfcraft-control" } + /// Inject admitted control batches into the current pass, dropping stale requests, applying cancellations + /// and scheduling repaints. fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) { let Ok(mut s) = self.shared.lock() else { return }; - if let Some(batch) = s.inject.pop_front() { - input.events.extend(batch); + expire_input(&mut s, web_time::Instant::now()); + if std::mem::take(&mut s.cancel_pointer) { + // input_hook precedes Context::begin_pass. stop_dragging sets the OLD snapshot's + // drag_stopped; begin_pass replaces that snapshot without a release/click/drop. + ctx.input_mut(|i| i.pointer = Default::default()); + ctx.stop_dragging(); + egui::DragAndDrop::clear_payload(ctx); + } + input.events.append(&mut s.cleanup); + s.in_pass = true; + let epoch = s.epoch; + let Some(front) = s.inject.front_mut() else { return }; + if !front.started { + if front.epoch != epoch { + // The document, dialog or focus changed after this request was admitted, before + // any of its input landed. Drop only this request; later requests are unrelated. + let request = front.request; + s.inject.pop_front(); + s.dropped.push(request); + if !s.inject.is_empty() { + ctx.request_repaint(); + } + return; + } + front.started = true; + } + // A non-expired request's own earlier batch may have changed focus or opened a dialog. + // Let it finish normally; expiry above cancels held pointer state instead of completing + // an abandoned click/drop. + let events = front.batches.pop_front(); + if let Some(events) = &events { + // Remember the inverse of what this request presses as it is applied, so a timeout + // after this batch can still release exactly those inputs. + note_pressed(&mut front.release, events); + } + let done = front.batches.is_empty(); + if done { + s.inject.pop_front(); + } + if let Some(events) = events { + input.events.extend(events); } if !s.inject.is_empty() { ctx.request_repaint(); } } - fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) { + /// Record presented passes and the accesskit tree and focus after a pass, so control replies observe only + /// drawn frames. + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { let Ok(mut s) = self.shared.lock() else { return }; - s.passes += 1; + s.in_pass = false; + // A discarded pass is layout work, not a frame the agent can observe. Only count passes + // whose output is actually presented, so a request cannot be answered early. The last + // pass of a frame is presented even when its own discard request was refused because + // `max_passes` is reached; egui still reports that request in the output. + let max_passes = ctx.options(|o| o.max_passes.get()); + let final_pass = output.platform_output.num_completed_passes >= max_passes; + if output.platform_output.request_discard_reasons.is_empty() || final_pass { + s.passes += 1; + } let Some(update) = &output.platform_output.accesskit_update else { return }; s.nodes = update.nodes.iter().cloned().collect(); s.root = update.tree.as_ref().map(|t| t.root); @@ -145,30 +292,271 @@ impl egui::Plugin for ControlPlugin { } } +/// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. +const MAX_PENDING_SCREENSHOTS: usize = 4; +const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// One image's encode working copies are bounded too: the RGBA buffer fed to the PNG encoder, +/// the encoded PNG, and the base64 string in the reply. They are made one capture at a time, +/// after the retained image budget above has been checked. Peak encoding memory is therefore at +/// most the retained aggregate plus one crop, one PNG and one base64 string; it is not hidden +/// behind the retained-image limit. +const MAX_ENCODED_BYTES: usize = MAX_IMAGE_BYTES; +const MAX_BASE64_BYTES: usize = 192 * 1024 * 1024; + +/// Replace one pending capture's reservation with `bytes`, keeping the other reservations. +/// +/// The drain retries captures one at a time: using a single total taken before the loop would +/// let several retries each see room that no longer exists together, and the aggregate would +/// grow past [`MAX_TOTAL_BYTES`]. +fn retried_reservation(total: usize, old: usize, bytes: usize) -> Option { + let without_this = total.saturating_sub(old); + let new_total = without_this.saturating_add(bytes); + (bytes <= MAX_IMAGE_BYTES && new_total <= MAX_TOTAL_BYTES).then_some(new_total) +} + +/// The window content a screenshot request was admitted and issued for. +/// +/// Only the viewport's local size and scale matter: the window's position on the monitor does not +/// change a single captured pixel, and keyboard focus is not part of the captured content. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Geometry { + size: egui::Vec2, + pixels_per_point: f32, + /// Active document and open dialog/prompt identity, never the focused widget. + epoch: u64, +} + +impl Geometry { + /// Capture the current viewport size and effective pixels-per-point for the request epoch. + fn current(ctx: &egui::Context, epoch: u64) -> Self { + // Viewport info is refreshed by `run_logic`; the last UI pass's `viewport_rect` can be + // stale while the window is hidden. Fall back to it only when the host cannot say. Only + // its size is kept: `inner_rect` is in monitor space and its origin may move. + let (rect, native) = ctx.input(|i| { + let viewport = i.viewport(); + (viewport.inner_rect.unwrap_or_else(|| i.viewport_rect()), viewport.native_pixels_per_point) + }); + let native = native.or_else(|| ctx.native_pixels_per_point()).unwrap_or(1.0); + Self { size: rect.size(), pixels_per_point: native * ctx.zoom_factor(), epoch } + } + + /// Checked bytes an image of this geometry needs, before the capture is issued. + fn image_bytes(self) -> Option { + let width = (self.size.x * self.pixels_per_point).ceil(); + let height = (self.size.y * self.pixels_per_point).ceil(); + if !(width.is_finite() && height.is_finite()) || width <= 0.0 || height <= 0.0 { + return None; + } + checked_image_bytes(width as usize, height as usize) + } + + /// Whether `region` is a non-empty rectangle inside this viewport, in logical points. + fn contains_region(self, region: egui::Rect) -> bool { + region.min.x.is_finite() + && region.min.y.is_finite() + && region.max.x.is_finite() + && region.max.y.is_finite() + && region.min.x >= 0.0 + && region.min.y >= 0.0 + && region.max.x > region.min.x + && region.max.y > region.min.y + && region.max.x <= self.size.x + && region.max.y <= self.size.y + } +} + +/// Return the byte size of an RGBA image of the given dimensions, or `None` when the multiplication +/// overflows. +fn checked_image_bytes(width: usize, height: usize) -> Option { + width.checked_mul(height)?.checked_mul(core::mem::size_of::()) +} + enum Pending { - /// Answer after `frames` more frames (the injected input has been handled by then). + /// Answer after `frames` more accepted frames (the injected input has been handled by then). Frames { - /// Answer once this many egui passes have run (the injected input has been handled). + /// Answer once this many accepted UI passes have run. until_pass: u64, - since: f64, + /// The queued batches this request depends on; a stale one fails the request. + batches: Vec, + since: web_time::Instant, reply: Sender, value: Value, }, Screenshot { - tag: u64, + slot: craft_ui::capture::CaptureSlot, + /// The one-shot callback, kept until the settled pass that issues it. + callback: Option, region: Option, reply: Sender, - /// When it was requested (egui time, seconds). - since: f64, + since: web_time::Instant, + geometry: Geometry, + /// Bytes reserved in the aggregate budget for this request. + reserved: usize, + attempts: u8, }, } +/// A request that arrived while the app could not yet run a real UI pass (fonts not ready). +struct DeferredRequest { + request: ControlRequest, + /// When this request reached the control queue. One admission deadline runs from here through + /// startup, input injection, pending frames and screenshot issuance: dispatching it later must + /// not start a fresh 5 s in each phase, and a request that used up the deadline while the app + /// was starting is never applied against a later UI state. + since: web_time::Instant, +} + +/// A screenshot pending in the `finish_pending` pass, with ownership of its reply. +struct PendingCapture { + slot: craft_ui::capture::CaptureSlot, + callback: Option, + region: Option, + reply: Sender, + since: web_time::Instant, + geometry: Geometry, + reserved: usize, + attempts: u8, +} + +impl PendingCapture { + /// Convert a screenshot request into the pending form that later frames finish. + fn into_pending(self) -> Pending { + Pending::Screenshot { + slot: self.slot, + callback: self.callback, + region: self.region, + reply: self.reply, + since: self.since, + geometry: self.geometry, + reserved: self.reserved, + attempts: self.attempts, + } + } +} + +/// The state one `finish_pending` pass needs while resolving pending replies. +struct PendingFinisher { + now: web_time::Instant, + passes: u64, + current: Geometry, + in_pass: bool, + dropped: Vec, + expired: Vec, + reserved_total: usize, +} + +impl PendingFinisher { + /// Answer or keep one queued-input request, preserving its one arrival deadline. + fn resolve_frames(&self, until_pass: u64, batches: Vec, since: web_time::Instant, reply: Sender, value: Value) -> Option { + if batches.iter().any(|id| self.expired.contains(id)) { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + if batches.iter().any(|id| self.dropped.contains(id)) { + let _ = reply.send(Err("the document, dialog or focus changed before the input could be applied; nothing was done".into())); + return None; + } + if self.passes >= until_pass { + let _ = reply.send(Ok(value)); + return None; + } + if !self.in_pass && self.now.duration_since(since) > timeout() { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + Some(Pending::Frames { until_pass, batches, since, reply, value }) + } + + /// Give one reservation back to the running aggregate budget. + fn release_reservation(&mut self, reserved: usize) { + self.reserved_total = self.reserved_total.saturating_sub(reserved); + } + + /// Resolve one screenshot request, keeping it only while its capture is still pending. + fn finish_capture(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + // Completion cannot restart or bypass the admission deadline, even when the callback + // arrived before this delayed tick or a resize would retry it. + if self.now.duration_since(capture.since) > timeout() { + capture.slot.cancel(); + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(not_drawn("the screenshot"))); + return None; + } + match capture.slot.take() { + Some(Ok(image)) => self.finish_captured_image(ctx, capture, image), + Some(Err(error)) => { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(format!("the screenshot could not be captured: {error}"))); + None + } + None => Some(capture.into_pending()), + } + } + + /// Finish a screenshot whose callback produced an image. + fn finish_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture, image: Arc) -> Option { + if self.current.epoch != capture.geometry.epoch { + // The document/dialog changed: never save a capture of the previous context, and do + // not retry it into the new one. + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the document or dialog changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if self.current.size != capture.geometry.size || self.current.pixels_per_point != capture.geometry.pixels_per_point { + // A late completion after a resize/DPI change is never encoded: retry once with a + // fresh slot, else report a defined error. + return self.retry_captured_image(ctx, capture); + } + self.release_reservation(capture.reserved); + let _ = capture.reply.send(screenshot_png(&image, capture.region, capture.geometry)); + None + } + + /// Retry one resize/DPI-stale capture with the current geometry. + fn retry_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + if capture.attempts >= 1 { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window geometry changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if capture.region.as_ref().is_some_and(|region| !self.current.contains_region(*region)) { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the region must lie inside the window".into())); + return None; + } + let Some(bytes) = self.current.image_bytes() else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window has no capturable size".into())); + return None; + }; + let Some(new_total) = retried_reservation(self.reserved_total, capture.reserved, bytes) else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + return None; + }; + self.reserved_total = new_total; + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + Some(Pending::Screenshot { + slot, + callback: Some(callback), + region: capture.region, + reply: capture.reply, + since: capture.since, + geometry: self.current, + reserved: bytes, + attempts: capture.attempts + 1, + }) + } +} + /// The app half: the request queue and requests waiting for later frames. pub struct Control { rx: Receiver, shared: Arc>, pending: Vec, - next_tag: u64, + /// Requests admitted while the app cannot yet run a real UI pass (fonts not ready). + deferred: VecDeque, } /// Install the control channel on `ctx`: returns the app half and a client. @@ -177,112 +565,358 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { ctx.add_plugin(ControlPlugin { shared: shared.clone() }); ctx.enable_accesskit(); let (tx, rx) = channel(); - (Control { rx, shared, pending: Vec::new(), next_tag: 1 }, ControlClient { tx, ctx: ctx.clone() }) + (Control { rx, shared, pending: Vec::new(), deferred: VecDeque::new() }, ControlClient { tx, ctx: ctx.clone() }) } /// What a request needs from the app (implemented by `PdfCraftApp`). pub(crate) trait Host { + /// Return the UI-state snapshot reported by `ui.state`. fn state(&self) -> Value; + /// Run one registered command by id and return its reply. fn command(&mut self, id: &str) -> Reply; + /// Apply or reset the side panel layout from a control request. fn panel(&mut self, reset: bool, params: &Value) -> Reply; + /// List the commands available to the control channel. fn commands(&self) -> Value; + /// Set one application option from a control request. fn set(&mut self, key: &str, value: &str) -> Reply; + /// Open a document path requested through the control channel. fn open(&mut self, path: &str) -> Reply; + /// Request application exit; returns whether it closed without asking for confirmation. fn quit(&mut self, ctx: &egui::Context) -> bool; + + /// A generation that changes when the input target (document, dialog, focus) does. + /// + /// Synthetic input admitted for one generation must not be replayed into another. + fn input_epoch(&self) -> u64 { + 0 + } + + /// A generation for the captured content (document and dialog identity only). + /// + /// Screenshots do not depend on keyboard focus, so a field gaining focus must not discard a + /// pending capture. Hosts that do not distinguish the two reuse the input generation. + fn capture_epoch(&self) -> u64 { + self.input_epoch() + } + + /// Whether the app can apply a real UI pass yet (fonts installed, no early return). + fn accepts_input(&self) -> bool { + true + } + + /// Drop transient app-owned grabs before widgets observe the canceled pointer as up. + fn cancel_pointer_gestures(&mut self) {} } impl Control { - /// Handle new requests and finish waiting ones. Call at the start of every frame. + /// Handle new requests and finish waiting ones. Call at the start of every frame (`logic`). pub(crate) fn tick(&mut self, ctx: &egui::Context, host: &mut impl Host) { - self.finish_pending(ctx); - while let Ok(req) = self.rx.try_recv() { - match self.handle(ctx, host, &req.method, &req.params) { - Handled::Now(r) => { - let _ = req.reply.send(r); + self.set_epoch(host.input_epoch()); + self.finish_pending(ctx, host); + let ready = host.accepts_input(); + if !ready { + // Expiry applies while the app is still starting too: a request that waited through + // the startup gate must fail at the same deadline instead of running whenever fonts + // arrive. + let now = web_time::Instant::now(); + while self.deferred.front().is_some_and(|deferred| now.duration_since(deferred.since) > timeout()) { + if let Some(deferred) = self.deferred.pop_front() { + let _ = deferred.request.reply.send(Err(still_starting())); } - Handled::AfterFrames(frames, value) => { - let until_pass = self.passes() + u64::from(frames); - self.pending.push(Pending::Frames { until_pass, since: now_secs(), reply: req.reply, value }); - } - Handled::Screenshot(region) => { - let tag = self.next_tag; - self.next_tag += 1; - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(tag))); - self.pending.push(Pending::Screenshot { tag, region, reply: req.reply, since: now_secs() }); + } + } else { + // Deferred requests reached the queue before anything still in the channel, so they + // are dispatched first: one client's requests keep their arrival order. Each carries + // its own admission timestamp, so startup time counts against its deadline. + while let Some(deferred) = self.deferred.pop_front() { + if web_time::Instant::now().duration_since(deferred.since) > timeout() { + let _ = deferred.request.reply.send(Err(startup_deadline_expired())); + } else { + self.dispatch(ctx, host, deferred.request, deferred.since); } } } - if !self.pending.is_empty() { + while let Ok(req) = self.rx.try_recv() { + // One admission timestamp per request: queued input, pending frames and an admitted + // screenshot all measure their deadline from the sending thread, including time + // waiting in this channel, never from a later phase. + let since = req.since; + if web_time::Instant::now().duration_since(since) > timeout() { + let _ = req.reply.send(Err(if ready { startup_deadline_expired() } else { still_starting() })); + continue; + } + if !ready { + if self.deferred.len() < 32 { + self.deferred.push_back(DeferredRequest { request: req, since }); + } else { + let _ = req.reply.send(Err("the app is still starting; too many control requests queued".into())); + } + continue; + } + self.dispatch(ctx, host, req, since); + } + if !self.pending.is_empty() || !self.deferred.is_empty() { ctx.request_repaint(); + // A hidden or occluded window runs `logic` without a UI pass. Ask for one so queued + // input and screenshots can be applied; the screenshot command itself also paints. + // + // The shared fork's web host does not implement `RequestPaintWhileHidden` yet: a + // hidden browser tab still times out rather than falsely succeeding. Recorded as a + // pending fork prerequisite, not qualified here. + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } } } - fn finish_pending(&mut self, ctx: &egui::Context) { - let shots: Vec<(u64, Arc)> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|tag| (*tag, image.clone())) + /// Refresh the input-target generation before a pass consumes queued synthetic input. + /// + /// `raw_input_hook` runs before the plugin's `input_hook`, so a dialog opened by the + /// previous pass already invalidates a request that has not started yet, instead of a pass + /// later. Only that request is rejected; requests already started finish their batches. + pub(crate) fn set_epoch(&self, epoch: u64) { + if let Ok(mut s) = self.shared.lock() { + s.epoch = epoch; + } + } + + /// Issue admitted captures after a settled UI pass. Call at the end of `App::ui`. + pub(crate) fn finish_frame(&mut self, ctx: &egui::Context, host: &mut impl Host) { + if !host.accepts_input() || ctx.will_discard() { + return; + } + let current = Geometry::current(ctx, host.capture_epoch()); + let mut reserved_total = self.reserved_bytes(); + let mut kept = Vec::new(); + for pending in self.pending.drain(..) { + let mut entry = pending; + if let Pending::Screenshot { slot, callback, reply, region, geometry, reserved, attempts, since } = &mut entry + && callback.is_some() + { + if web_time::Instant::now().duration_since(*since) > timeout() { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err(not_drawn("the screenshot"))); + continue; + } + if geometry.epoch != current.epoch { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the document or dialog changed before the screenshot could be issued; it was not saved".into())); + continue; + } + if *geometry != current { + // Resize, DPI or zoom between admission and issuance: retry once with the + // real geometry rather than cropping with stale numbers. + if *attempts >= 1 { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window geometry changed while the screenshot was being prepared; it was not saved".into())); + continue; } - _ => None, - }) - .collect() - }); - let ppp = ctx.pixels_per_point(); - let now = now_secs(); - let passes = self.passes(); + if region.as_ref().is_some_and(|region| !current.contains_region(*region)) { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the region must lie inside the window".into())); + continue; + } + let Some(bytes) = current.image_bytes() else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window has no capturable size".into())); + continue; + }; + let Some(new_total) = retried_reservation(reserved_total, *reserved, bytes) else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + continue; + }; + reserved_total = new_total; + slot.cancel(); + let (new_slot, new_callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + *slot = new_slot; + *callback = Some(new_callback); + *geometry = current; + *reserved = bytes; + *attempts += 1; + } + if let Some(callback) = callback.take() { + // The screenshot command itself requests a paint, including while hidden. + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + } + } + kept.push(entry); + } + self.pending = kept; + if !self.pending.is_empty() { + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + + /// `since` is the request's arrival time: the one admission deadline it was admitted under. + fn dispatch(&mut self, ctx: &egui::Context, host: &mut impl Host, req: ControlRequest, since: web_time::Instant) { + match self.handle(ctx, host, &req.method, &req.params, since) { + Handled::Now(r) => { + let _ = req.reply.send(r); + } + Handled::AfterFrames(frames, value) => { + let until_pass = self.passes() + u64::from(frames); + self.pending.push(Pending::Frames { until_pass, batches: Vec::new(), since, reply: req.reply, value }); + } + Handled::AfterInput { batches, frames, value } => { + let until_pass = self.passes() + u64::from(frames) + 1; + self.pending.push(Pending::Frames { until_pass, batches, since, reply: req.reply, value }); + } + Handled::Screenshot(region) => self.admit_screenshot(ctx, host, region, req.reply, since), + } + } + + /// Return the total screenshot bytes reserved by pending captures without overflowing. + fn reserved_bytes(&self) -> usize { + self.pending + .iter() + .filter_map(|p| if let Pending::Screenshot { reserved, .. } = p { Some(*reserved) } else { None }) + .fold(0usize, usize::saturating_add) + } + + /// Count the screenshot requests still waiting for a capture. + fn pending_screenshots(&self) -> usize { + self.pending.iter().filter(|p| matches!(p, Pending::Screenshot { .. })).count() + } + + /// Admit one screenshot request within the count, per-image and aggregate limits. + fn admit_screenshot( + &mut self, + ctx: &egui::Context, + host: &mut impl Host, + region: Option, + reply: Sender, + since: web_time::Instant, + ) { + if self.pending_screenshots() >= MAX_PENDING_SCREENSHOTS { + let _ = reply.send(Err(format!("too many screenshots are already pending (limit {MAX_PENDING_SCREENSHOTS})"))); + return; + } + let geometry = Geometry::current(ctx, host.capture_epoch()); + let Some(bytes) = geometry.image_bytes() else { + let _ = reply.send(Err("the window has no capturable size".into())); + return; + }; + if let Some(region) = region + && !geometry.contains_region(region) + { + let _ = reply.send(Err("the region must lie inside the window".into())); + return; + } + if bytes > MAX_IMAGE_BYTES { + let _ = reply.send(Err(format!("the screenshot would use {bytes} bytes, over the {MAX_IMAGE_BYTES}-byte image limit"))); + return; + } + if self.reserved_bytes().saturating_add(bytes) > MAX_TOTAL_BYTES { + let _ = reply.send(Err(format!("the screenshot would exceed the {MAX_TOTAL_BYTES}-byte aggregate screenshot budget"))); + return; + } + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + self.pending.push(Pending::Screenshot { slot, callback: Some(callback), region, reply, since, geometry, reserved: bytes, attempts: 0 }); + } + + /// Resolve completed frame and screenshot requests while retaining those that still need a pass or a + /// retry. + fn finish_pending(&mut self, ctx: &egui::Context, host: &mut impl Host) { + let now = web_time::Instant::now(); + let (dropped, expired, in_pass) = self.take_finished_shared(ctx, host, now); + let mut finisher = PendingFinisher { + now, + passes: self.passes(), + current: Geometry::current(ctx, host.capture_epoch()), + in_pass, + dropped, + expired, + reserved_total: self.reserved_bytes(), + }; let mut keep = Vec::new(); - for p in self.pending.drain(..) { - match p { - Pending::Frames { until_pass, reply, value, .. } if passes >= until_pass => { - let _ = reply.send(Ok(value)); + for pending in std::mem::take(&mut self.pending) { + let kept = match pending { + Pending::Frames { until_pass, batches, since, reply, value } => finisher.resolve_frames(until_pass, batches, since, reply, value), + Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts } => { + finisher.finish_capture(ctx, PendingCapture { slot, callback, region, reply, since, geometry, reserved, attempts }) } - Pending::Frames { since, reply, .. } if now - since > NOT_DRAWN_TIMEOUT => { - // The agent is told it failed, so it must not happen later either. - if let Ok(mut sh) = self.shared.lock() { - sh.inject.clear(); - } - let _ = reply.send(Err(not_drawn("handling the input"))); - } - p @ Pending::Frames { .. } => keep.push(p), - Pending::Screenshot { tag, region, reply, since } => match shots.iter().find(|(t, _)| *t == tag) { - Some((_, image)) => { - let _ = reply.send(screenshot_png(image, region, ppp)); - } - // The platform skips painting windows that are hidden, minimized or fully - // covered, so no screenshot ever arrives: say so instead of hanging. - None if now - since > NOT_DRAWN_TIMEOUT => { - let _ = reply.send(Err(not_drawn("the screenshot"))); - } - None => keep.push(Pending::Screenshot { tag, region, reply, since }), - }, + }; + if let Some(pending) = kept { + keep.push(pending); } } self.pending = keep; } + /// Expire queued input and collect the batches a pending reply must react to. + fn take_finished_shared(&self, ctx: &egui::Context, host: &mut impl Host, now: web_time::Instant) -> (Vec, Vec, bool) { + let (dropped, expired, in_pass) = self + .shared + .lock() + .map(|mut s| { + if !s.in_pass { + expire_input(&mut s, now); + } + if std::mem::take(&mut s.cancel_gestures) { + host.cancel_pointer_gestures(); + } + (std::mem::take(&mut s.dropped), std::mem::take(&mut s.expired), s.in_pass) + }) + .unwrap_or_default(); + if !expired.is_empty() { + ctx.request_repaint(); // Run the queued key/modifier cleanup and pointer cancellation. + } + (dropped, expired, in_pass) + } + fn passes(&self) -> u64 { self.shared.lock().map(|s| s.passes).unwrap_or(0) } - fn inject(&self, batches: Vec>) -> u32 { - let n = batches.len() as u32; - if let Ok(mut s) = self.shared.lock() { - s.inject.extend(batches); + /// Queue one request's event batches (one per UI pass) and return its request id. + /// + /// The request is stamped once, at admission: all of its batches share the input-target + /// generation and the caller's single arrival deadline (`since`), so a deferred startup wait + /// is not restarted here. A poisoned mutex or a full queue is an error, never a silently empty + /// queue that would let the caller answer `Ok` for input that never happened. + fn inject(&self, batches: Vec>, since: web_time::Instant) -> Result, String> { + let mut s = self.shared.lock().map_err(|_| "control state poisoned".to_string())?; + let queued: usize = s.inject.iter().map(|request| request.batches.len()).sum(); + if queued.saturating_add(batches.len()) > MAX_QUEUED_BATCHES { + return Err(format!("too many synthetic input batches are already queued (limit {MAX_QUEUED_BATCHES})")); } - // The last batch is handled during the frame it is injected into; answer one frame later. - n + 1 + s.next_batch += 1; + let id = s.next_batch; + let epoch = s.epoch; + s.inject.push_back(RequestBatches { request: id, epoch, started: false, since, release: Vec::new(), batches: batches.into() }); + Ok(vec![id]) } - fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled { + /// Queue synthetic input batches and reply only after the requested number of frames has been presented. + fn after_input(&self, batches: Vec>, value: Value, since: web_time::Instant) -> Result { + let frames = batches.len() as u32; + let batches = self.inject(batches, since)?; + Ok(Handled::AfterInput { batches, frames, value }) + } + + /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with + /// actionable errors. + fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value, since: web_time::Instant) -> Handled { let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}")); let r: Result = (|| match method { "ui.state" => { let mut state = host.state(); - let size = ctx.input(|i| i.viewport_rect().size()); - state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": ctx.pixels_per_point() }); + // Viewport info is fresh in `logic`; the last UI pass's rectangle can be stale. + let geometry = Geometry::current(ctx, host.capture_epoch()); + let size = geometry.size; + state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": geometry.pixels_per_point }); Ok(Handled::Now(Ok(state))) } "ui.commands" => Ok(Handled::Now(Ok(host.commands()))), @@ -299,8 +933,8 @@ impl Control { } "ui.open" => Ok(Handled::Now(host.open(str_param("path")?))), "ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))), - "ui.click" => self.click(p), - "ui.focus" => self.focus(p), + "ui.click" => self.click(p, since), + "ui.focus" => self.focus(p, since), "ui.resize" => { only_params(p, method, &["width", "height"])?; let dimension = |key: &str| -> Result { @@ -326,7 +960,7 @@ impl Control { let quitting = host.quit(ctx); Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting })))) } - "ui.drag" => self.drag(p), + "ui.drag" => self.drag(p, since), "ui.move" => { let point = |key: &str| -> Result { p.get(key) @@ -336,19 +970,18 @@ impl Control { .ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points")) }; let pos = egui::pos2(point("x")?, point("y")?); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::PointerMoved(pos)]]), json!({ "moved": [pos.x, pos.y] }))) + self.after_input(vec![vec![egui::Event::PointerMoved(pos)]], json!({ "moved": [pos.x, pos.y] }), since) } "ui.type" => { let text = str_param("text")?.to_string(); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true }))) + self.after_input(vec![vec![egui::Event::Text(text)]], json!({ "typed": true }), since) } "ui.key" => { let name = str_param("key")?; let key = egui::Key::from_name(name) .ok_or_else(|| format!("ui.key: unknown key {name:?} (egui key names: A, Enter, Escape, ArrowDown, F5, …)"))?; let modifiers = modifiers(p.get("modifiers"))?; - let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - Ok(Handled::AfterFrames(self.inject(vec![vec![ev(true)], vec![ev(false)]]), json!({ "key": name }))) + self.after_input(key_batches(key, modifiers), json!({ "key": name }), since) } "ui.screenshot" => { let region = match p.get("region") { @@ -397,7 +1030,7 @@ impl Control { } /// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving). - fn drag(&mut self, p: &Value) -> Result { + fn drag(&mut self, p: &Value, since: web_time::Instant) -> Result { let point = |k: &str| -> Result { match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::>()).as_deref() { Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32)) @@ -430,11 +1063,11 @@ impl Control { if modifiers != egui::Modifiers::NONE { frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); } - let n = self.inject(frames); - Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }))) + self.after_input(frames, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }), since) } - fn click(&mut self, p: &Value) -> Result { + /// Click at a point, or on an accesskit widget by id or unique enabled label. + fn click(&mut self, p: &Value, since: web_time::Instant) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { let point = |key: &str, n: f64| { Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points")) @@ -443,8 +1076,8 @@ impl Control { let which = pointer_button(p, "ui.click")?; let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE }; // Keep hover/layout warmup, then make an instantaneous click even on slow frames. - let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]); - return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] }))); + let frames = vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]; + return self.after_input(frames, json!({ "clicked": [x, y] }), since); } let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label").and_then(Value::as_str)) { @@ -480,11 +1113,12 @@ impl Control { let label = node.label().map(str::to_owned); let action = accesskit::ActionRequest { action: Action::Click, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "clicked": id.0.to_string(), "label": label }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "clicked": id.0.to_string(), "label": label }), since) } - fn focus(&mut self, p: &Value) -> Result { + /// Focus an accesskit widget by id or unique enabled label. + fn focus(&mut self, p: &Value, since: web_time::Instant) -> Result { only_params(p, "ui.focus", &["id", "label"])?; let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label")) { @@ -512,14 +1146,20 @@ impl Control { } let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "focus_requested": id.0.to_string() }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "focus_requested": id.0.to_string() }), since) } } enum Handled { Now(Reply), + /// Answer after this many accepted UI passes, without injecting input. AfterFrames(u32, Value), + AfterInput { + batches: Vec, + frames: u32, + value: Value, + }, Screenshot(Option), } @@ -584,6 +1224,23 @@ fn pointer_button(p: &Value, method: &str) -> Result Vec> { + let key_event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let mut press = vec![key_event(true)]; + if modifiers != egui::Modifiers::NONE { + press.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + let mut release = vec![key_event(false)]; + if modifiers != egui::Modifiers::NONE { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + vec![press, release] +} + fn modifiers(v: Option<&Value>) -> Result { let mut m = egui::Modifiers::NONE; for name in v.and_then(Value::as_array).into_iter().flatten() { @@ -616,19 +1273,47 @@ fn rect_param(v: &Value) -> Result { } /// Encode (a region of) a screenshot as PNG; the reply carries base64 data and the size. -fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32) -> Reply { +/// +/// The image scale is derived from the returned pixels and the logical viewport the capture was +/// admitted for, never from the current `Context`: egui-wgpu's surface fit may have shrunk the +/// surface (and a resize/DPI change has already been rejected before this call). +fn screenshot_png(image: &egui::ColorImage, region: Option, geometry: Geometry) -> Reply { let [w, h] = image.size; + let view = geometry.size; + if w == 0 || h == 0 { + return Err("the screenshot has no pixels".into()); + } + if !(view.x.is_finite() && view.y.is_finite()) || view.x <= 0.0 || view.y <= 0.0 { + return Err("the window has no capturable size".into()); + } + if region.is_some_and(|region| !geometry.contains_region(region)) { + return Err("the region must lie inside the window".into()); + } + let scale_x = w as f32 / view.x; + let scale_y = h as f32 / view.y; + // A fitted surface shrinks both sides by one factor; a different x/y scale means the window + // changed shape and this image cannot be cropped honestly. + if (scale_x - scale_y).abs() > 0.01 * scale_x.max(1.0) { + return Err("the screenshot's aspect ratio does not match the window".into()); + } let (x0, y0, x1, y1) = match region { Some(r) => { - let px = |v: f32, max: usize| ((v * ppp).round().max(0.0) as usize).min(max); - (px(r.min.x, w), px(r.min.y, h), px(r.max.x, w), px(r.max.y, h)) + let px = |v: f32, scale: f32, max: usize| ((v * scale).round().max(0.0) as usize).min(max); + (px(r.min.x, scale_x, w), px(r.min.y, scale_y, h), px(r.max.x, scale_x, w), px(r.max.y, scale_y, h)) } None => (0, 0, w, h), }; if x1 <= x0 || y1 <= y0 { return Err("the region is outside the window".into()); } - let mut rgba = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + let crop_bytes = (x1 - x0) + .checked_mul(y1 - y0) + .and_then(|pixels| pixels.checked_mul(core::mem::size_of::())) + .ok_or_else(|| "the screenshot region is too large".to_string())?; + if crop_bytes > MAX_ENCODED_BYTES { + return Err(format!("the screenshot region would need {crop_bytes} bytes to encode, over the {MAX_ENCODED_BYTES}-byte encode limit")); + } + let mut rgba = Vec::with_capacity(crop_bytes); for y in y0..y1 { for px in &image.pixels[y * w + x0..y * w + x1] { rgba.extend_from_slice(&px.to_srgba_unmultiplied()); @@ -642,11 +1327,69 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32 let mut writer = enc.write_header().map_err(|e| e.to_string())?; writer.write_image_data(&rgba).map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; + if out.len() > MAX_ENCODED_BYTES { + return Err(format!("the encoded screenshot is {} bytes, over the {MAX_ENCODED_BYTES}-byte encode limit", out.len())); + } + let encoded = out.len().div_ceil(3).checked_mul(4).ok_or_else(|| "the encoded screenshot is too large".to_string())?; + if encoded > MAX_BASE64_BYTES { + return Err(format!("the screenshot's base64 reply would be {encoded} bytes, over the {MAX_BASE64_BYTES}-byte limit")); + } use base64::Engine as _; - Ok(json!({ "png_base64": base64::engine::general_purpose::STANDARD.encode(out), "width": cw, "height": ch, "pixels_per_point": ppp })) + Ok(json!({ + "png_base64": base64::engine::general_purpose::STANDARD.encode(out), + "width": cw, + "height": ch, + "pixels_per_point": scale_x, + })) } impl Host for crate::PdfCraftApp { + /// Cancel any in-progress pointer gesture in every open view. + fn cancel_pointer_gestures(&mut self) { + for view in &mut self.views { + view.cancel_pointer_gestures(); + } + } + + /// The input target: active document, open dialog/prompt, palette and find state. + /// + /// Synthetic input admitted for one generation is never replayed into another document or + /// dialog. Only these identities are hashed, never text or document content. + fn input_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.capture_epoch().hash(&mut hasher); + // The focused widget is part of the input target: keys typed for one field must not land + // in another after focus moves. Only the id value is hashed, never text. + let focused = self.ctx.as_ref().and_then(|ctx| ctx.memory(|m| m.focused())).map(|id| id.value()); + focused.hash(&mut hasher); + hasher.finish() + } + + /// The captured content: active document and open dialog/prompt, never keyboard focus. + fn capture_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let doc = self.active.and_then(|i| self.views.get(i)).map(|v| v.id.0); + doc.hash(&mut hasher); + format!( + "{:?}/{:?}/{:?}/{:?}/{:?}", + self.dialog, + self.password_prompt.is_some(), + self.palette_open, + self.close_request.is_some(), + self.active.and_then(|i| self.views.get(i)).and_then(|v| v.find.as_ref().map(|_| true)), + ) + .hash(&mut hasher); + hasher.finish() + } + + /// Control requests wait for the first UI pass that installs fonts; a font-gated early + /// return cannot consume injected input or issue a screenshot. + fn accepts_input(&self) -> bool { + self.fonts_ready + } + fn panel(&mut self, reset: bool, params: &Value) -> Reply { crate::side_panels::command(self, reset, params) } @@ -1401,6 +2144,1009 @@ mod transport_tests { } } +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use egui::Plugin as _; + use egui_kittest::Harness; + + /// A request is answered after accepted frames only: a discarded layout pass is not a frame + /// the agent can see, so it must not advance the count. + #[test] + fn discarded_passes_are_not_counted_as_accepted_frames() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + let ctx = egui::Context::default(); + let mut accepted = egui::FullOutput::default(); + plugin.output_hook(&ctx, &mut accepted); + let mut discarded = egui::FullOutput::default(); + discarded.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("control test discard")); + plugin.output_hook(&ctx, &mut discarded); + assert_eq!(shared.lock().unwrap().passes, 1, "only the presented pass counts"); + } + + /// A frame that requests discard on every pass and hits `max_passes` still presents its + /// last pass: that presented pass must count, or `Pending::Frames` waits for a frame that + /// never arrives. This runs the real multi-pass loop rather than a hand-built FullOutput. + #[test] + fn a_final_capped_pass_is_counted_as_presented() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 2.try_into().expect("two is non-zero")); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| ui.ctx().request_discard("control test must discard")); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 2, "the cap was reached"); + assert!(!output.platform_output.request_discard_reasons.is_empty(), "the last pass still requested a discard"); + assert_eq!(shared.lock().unwrap().passes, 1, "the presented final pass counts once"); + } + + /// `max_passes` above two still counts a frame that settles after earlier discarded passes: + /// egui clears the previous pass's discard reasons at the start of each pass, so the merged + /// output of a presented final pass is not mistaken for a discarded one. + #[test] + fn a_frame_that_settles_before_a_higher_pass_cap_is_counted_once() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 3.try_into().expect("three is non-zero")); + let mut pass = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + pass += 1; + if pass < 3 { + ui.ctx().request_discard("control test must discard the first two passes"); + } + }); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 3, "the third pass settled at the higher cap"); + assert!(output.platform_output.request_discard_reasons.is_empty(), "earlier discard reasons do not survive the settling pass"); + assert_eq!(shared.lock().unwrap().passes, 1, "the one presented frame counts once"); + } + + /// `ui.key` delivers held modifiers through `ModifiersChanged`, because egui does not derive + /// `InputState.modifiers` from a key event's own field. Shift+Tab and ⌘Enter handlers read it. + #[test] + fn key_commands_deliver_modifier_state() { + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let batches = key_batches(egui::Key::Enter, held); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + assert_eq!( + batches, + vec![vec![egui::Event::ModifiersChanged(held), key(true)], vec![key(false), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]] + ); + let ctx = egui::Context::default(); + let mut press_seen = None; + ctx.run_ui(egui::RawInput { events: batches[0].clone(), ..Default::default() }, |ui| press_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + let mut release_seen = None; + ctx.run_ui(egui::RawInput { events: batches[1].clone(), ..Default::default() }, |ui| release_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + assert_eq!(press_seen, Some(held), "the press sees the modifiers it was sent with"); + assert_eq!(release_seen, Some(egui::Modifiers::NONE), "the modifier release follows the key-up"); + } + + /// A batch admitted for one document/focus generation must not apply after that generation + /// changes; the request is reported as stale instead of replaying the input. + #[test] + fn a_stale_synthetic_batch_is_dropped_and_reported() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + } + let mut raw = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut raw); + assert!(raw.events.is_empty(), "stale input must not reach the new document"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// A stale request is dropped alone. A request admitted later for the current generation + /// behind it still runs: one agent's outdated input must not fail another's. + #[test] + fn a_stale_request_does_not_drop_a_later_current_one() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 42, + epoch: 2, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 2.0))]]), + }); + } + let ctx = egui::Context::default(); + let mut first = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut first); + assert!(first.events.is_empty(), "the stale request is not replayed"); + let mut second = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut second); + assert_eq!(second.events, [egui::Event::PointerMoved(egui::pos2(2.0, 2.0))], "the current request still applies"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// Once a request has started, the focus/dialog change caused by its own first batch must not + /// drop its release: a key press without its release leaves `keys_down` stuck. + #[test] + fn a_started_request_finishes_its_batches_after_focus_moves() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 7; + s.inject.push_back(RequestBatches { + request: 9, + epoch: 7, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([ + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ]), + }); + } + let ctx = egui::Context::default(); + let mut press = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut press); + assert!(matches!(press.events.as_slice(), [egui::Event::Key { pressed: true, .. }])); + // The press moved focus: the next pass sees another input-target generation. + shared.lock().unwrap().epoch = 8; + let mut release = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut release); + assert!(matches!(release.events.as_slice(), [egui::Event::Key { pressed: false, .. }]), "the release still arrives"); + let s = shared.lock().unwrap(); + assert!(s.dropped.is_empty(), "a started request is not reported as stale"); + assert!(s.inject.is_empty()); + } + + /// A host for timeout tests: the control layer must not need a real app. + struct NoopHost; + + impl Host for NoopHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + + /// A host that starts unready (fonts not installed) and counts dispatched state reads. + /// `state` takes `&self`, so the count sits in a `Cell`. + struct ReadinessHost { + ready: bool, + states: std::cell::Cell, + /// Methods the host was asked to run, in dispatch order. + calls: std::cell::RefCell>, + } + + impl ReadinessHost { + fn new(ready: bool) -> Self { + Self { ready, states: std::cell::Cell::new(0), calls: std::cell::RefCell::new(Vec::new()) } + } + } + + impl Host for ReadinessHost { + fn state(&self) -> Value { + self.states.set(self.states.get() + 1); + self.calls.borrow_mut().push("ui.state"); + json!({ "ready": self.ready }) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + self.calls.borrow_mut().push("ui.commands"); + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + fn accepts_input(&self) -> bool { + self.ready + } + } + + /// The admission timestamp of the one pending request, whichever kind it is. + fn pending_since(control: &Control) -> Option { + let first = control.pending.first()?; + match first { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => Some(*since), + } + } + + #[test] + fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() { + for ready in [false, true] { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(ready); + let (reply, waiting) = channel(); + let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap(); + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch"); + assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}"); + assert_eq!(host.states.get(), 0); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + } + + /// A request held during startup keeps its arrival time. Once the app accepts input, one + /// older than the control deadline fails instead of being dispatched into a later UI. + #[test] + fn a_deferred_request_expires_instead_of_dispatching_after_the_deadline() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "unready host defers the request"); + assert!(waiting.try_recv().is_err(), "it is not answered while starting"); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + // It expires even while the app is still not ready, so the client is not left waiting. + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired deferred request receives a reply").expect_err("it must not dispatch"); + assert!(error.contains("still starting"), "{error}"); + assert!(control.deferred.is_empty()); + // And readiness arriving later cannot revive it. + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + } + + /// A request deferred only briefly still runs once fonts are ready. + #[test] + fn a_fresh_deferred_request_still_dispatches_when_the_app_becomes_ready() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert!(waiting.try_recv().is_err()); + host.ready = true; + control.tick(&ctx, &mut host); + let reply = waiting.try_recv().expect("fresh deferred request replies").expect("it dispatches"); + assert_eq!(reply["ready"], true); + assert_eq!(host.states.get(), 1, "the fresh deferred request is dispatched once"); + } + + /// A request deferred during startup keeps its one arrival timestamp. Dispatching it into + /// synthetic input, a deferred frame reply or a screenshot reservation must not start a fresh + /// 5 s in that phase: every later phase measures the same deadline from arrival. + #[test] + fn a_startup_wait_counts_against_one_admission_deadline_in_every_phase() { + let params = [("ui.type", json!({ "text": "x" })), ("ui.resize", json!({ "width": 800, "height": 600 })), ("ui.screenshot", json!({}))]; + for (method, params) in params { + // `ui.resize` needs a native window; the other phases are checked on every target. + if method == "ui.resize" && cfg!(target_arch = "wasm32") { + continue; + } + let ctx = egui::Context::default(); + // A real window size, so an admitted screenshot has capturable geometry. + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send(method, params); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "{method}: deferred while starting"); + // Three of the five seconds were spent starting: the request is still inside its + // deadline, but only just. + let arrived = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(3)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = arrived; + } + host.ready = true; + control.tick(&ctx, &mut host); + assert!(control.deferred.is_empty(), "{method}: dispatched once the app accepts input"); + assert!(waiting.try_recv().is_err(), "{method}: a preserved deadline does not answer early"); + assert_eq!(pending_since(&control), Some(arrived), "{method}: the pending reply keeps the arrival deadline"); + if method == "ui.type" { + let shared = control.shared.lock().unwrap(); + assert_eq!(shared.inject.front().map(|request| request.since), Some(arrived), "{method}: queued input keeps the arrival deadline"); + } + // Move that same shared timestamp past its own deadline (five seconds after arrival). + // Expiry measures the one admission instant, so nothing waits for a fresh five. + let past = arrived.checked_sub(timeout()).expect("a representable instant"); + for pending in &mut control.pending { + match pending { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => *since = past, + } + } + if let Ok(mut shared) = control.shared.lock() { + for request in &mut shared.inject { + request.since = past; + } + } + control.finish_pending(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it must not succeed"); + assert!(error.contains("not being drawn"), "{method}: {error}"); + assert!(control.pending.is_empty(), "{method}: the expired request is not retried"); + assert_eq!(control.reserved_bytes(), 0, "{method}: any reservation is released"); + } + } + + /// One client's requests keep their arrival order: a request deferred during startup runs + /// before a newer request that was still waiting in the channel when the app became ready. + #[test] + fn a_deferred_request_runs_before_a_newer_queued_one() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let older = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "the older request is deferred while starting"); + let newer = client.send("ui.commands", json!({})); + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(*host.calls.borrow(), ["ui.state", "ui.commands"], "the older deferred request is dispatched first"); + assert_eq!(older.try_recv().expect("the older request replies").expect("it dispatches")["ready"], true); + assert_eq!(newer.try_recv().expect("the newer request replies").expect("it dispatches"), json!([])); + } + + /// A request whose startup wait used up the deadline is refused once the app is ready, and the + /// message says the app finished starting instead of claiming it is still starting. + #[test] + fn a_request_that_outlasted_startup_reports_that_the_app_finished_starting() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + host.ready = true; + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it is never dispatched"); + assert!(error.contains("finished starting"), "{error}"); + assert!(!error.contains("was still starting"), "the app is ready now: {error}"); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + + /// Keep key/modifier cleanup, but a canceled pointer must never produce a release event. + #[test] + fn a_timed_out_started_request_cancels_pointer_and_releases_keys() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let pos = egui::pos2(24.0, 32.0); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + let ids = control + .inject( + vec![ + vec![ + egui::Event::ModifiersChanged(held), + key(true), + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: held }, + ], + vec![egui::Event::PointerMoved(egui::pos2(40.0, 48.0))], + vec![key(false), egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: held }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Enter) && i.pointer.primary_down())); + assert_eq!(ctx.input(|i| i.modifiers), held); + age_input(&mut control); + let next = egui::pos2(80.0, 90.0); + control.inject(vec![vec![egui::Event::PointerMoved(next)]], web_time::Instant::now()).unwrap(); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!ui.input(|i| i.keys_down.contains(&egui::Key::Enter))); + assert_eq!(ui.input(|i| i.modifiers), egui::Modifiers::NONE); + assert_eq!(ui.input(|i| i.pointer.hover_pos()), Some(next), "another live request survives"); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().dragged_id().is_none() && ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(control.shared.lock().unwrap().inject.is_empty()); + assert!(rx.try_recv().is_err(), "only one timeout reply"); + } + + #[test] + fn a_deadline_crossing_inside_an_admitted_pass_cancels_before_the_next_interaction() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let pos = egui::pos2(24.0, 32.0); + let ids = control + .inject( + vec![ + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(ui.input(|i| i.pointer.primary_down()), "the press was admitted before expiry"); + age_input(&mut control); + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(rx.try_recv().is_err(), "do not cancel an already-calculated interaction mid-pass"); + }) + .drop_without_applying_deltas(); + ctx.run_ui(egui::RawInput::default(), |ui| { + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.shared.lock().unwrap().inject.is_empty()); + } + + /// Deterministic elapsed-time input: age the same admission in both halves, without sleeping + /// or fabricating any egui pointer/widget state. + fn age_input(control: &mut Control) { + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for request in &mut control.shared.lock().unwrap().inject { + request.since = past; + } + for pending in &mut control.pending { + if let Pending::Frames { since, .. } = pending { + *since = past; + } + } + } + + /// The public gestures this test drives through real UI passes. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Gesture { + AtomicClick, + HeldClick, + Marquee, + } + + fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) { + match gesture { + Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})), + Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})), + Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})), + } + } + + fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) { + let client_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = client_slot.clone(); + let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap(); + app.set_option("quick", "marquee-zoom").unwrap(); + *slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + let client = client_slot.lock().unwrap().take().unwrap(); + (h, client) + } + + fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + } + + /// The actual laid-out page rect and a short point gesture inside it. + fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) { + let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect()); + assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}"); + let from = page.center(); + let to = from + egui::vec2(40.0, 50.0); + assert!(page.contains(to)); + (from, to) + } + + /// Step until the public request has really reached egui and can still be interrupted. + fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool { + let mut admitted = false; + for _ in 0..8 { + marquee_step(h); + admitted = if gesture == Gesture::AtomicClick { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1) + } else { + h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some()) + }; + if admitted { + break; + } + } + admitted + } + + /// The request is queued exactly once, with the expected terminal batch not yet applied. + fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) { + { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + assert_eq!(shared.inject.len(), 1, "only the actual public request is queued"); + let pending = shared.inject.front().unwrap(); + assert!(pending.started); + let terminal = pending.batches.back().expect("the terminal batch is still pending"); + if gesture == Gesture::AtomicClick { + assert_eq!(pending.batches.len(), 1); + assert_eq!(terminal.len(), 2); + assert!( + matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!( + matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far"); + } else { + assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press"); + assert_eq!(terminal.len(), 1); + assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. })); + } + } + assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run"); + assert!(h.ctx.drag_stopped_id().is_none()); + if gesture == Gesture::Marquee { + assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview"); + } else { + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + } + } + + /// Expire the admission both through hidden logic and through the next input hook, then + /// assert that cancellation left no gesture, zoom or edit behind. + fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver, hidden_logic: bool, zoom: f32, bytes: &[u8]) { + age_input(h.state_mut().control.as_mut().unwrap()); + if hidden_logic { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let mut control = app.control.take().unwrap(); + control.tick(&ctx, app); + app.control = Some(control); + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + marquee_step(h); + if !hidden_logic { + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + for _ in 0..2 { + marquee_step(h); + } + assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none()); + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit"); + assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty()); + assert!(h.state().control.as_ref().unwrap().pending.is_empty()); + assert!(reply.try_recv().is_err(), "the cancellation replies once"); + } + + /// Complete one public gesture and check its real event trace and one-step zoom. + fn complete_positive_gesture( + h: &mut Harness<'static, crate::PdfCraftApp>, + client: &ControlClient, + gesture: Gesture, + positive: Gesture, + bytes: &[u8], + ) { + let (from, to) = gesture_target(h); + let zoom = h.state().views[0].zoom; + let (method, params) = gesture_request(positive, from, to); + let reply = client.send(method, params); + let mut value = None; + let mut buttons = Vec::new(); + let mut positions = Vec::new(); + for _ in 0..20 { + marquee_step(h); + h.ctx.input(|i| { + for event in &i.events { + match event { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => { + buttons.push((*pressed, i.time)); + positions.push(*pos); + } + egui::Event::PointerMoved(pos) => positions.push(*pos), + _ => {} + } + } + }); + if let Ok(result) = reply.try_recv() { + value = Some(result.unwrap()); + break; + } + } + assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}"); + assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::>(), [true, false]); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + assert!(h.ctx.dragged_id().is_none()); + if positive == Gesture::Marquee { + assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms"); + } else { + let options = h.ctx.options(|o| o.input_options); + assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click"); + assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary"); + assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step"); + } + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document"); + assert!(reply.try_recv().is_err(), "successful request replies once"); + } + + /// Real page hit-testing, real control batches and the production marquee completion path. + /// Exercise both hidden-logic expiry and expiry first observed by the next input hook. + #[test] + fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() { + for hidden_logic in [false, true] { + for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] { + let (mut h, client) = marquee_harness(); + for _ in 0..5 { + marquee_step(&mut h); + } + let (from, to) = gesture_target(&h); + let zoom = h.state().views[0].zoom; + let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone(); + let (method, params) = gesture_request(gesture, from, to); + let reply = client.send(method, params); + assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}"); + assert_interruptible_admission(&h, gesture, from); + assert!(reply.try_recv().is_err(), "the terminal batch has not run"); + assert_eq!(h.state().views[0].zoom, zoom); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes); + assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes); + + // Complete the same public gesture, then an atomic click after held input. + // Real event traces prove the stationary drag is a valid short point click. + let positives = [gesture, Gesture::AtomicClick]; + let count = if gesture == Gesture::AtomicClick { 1 } else { 2 }; + for positive in positives.into_iter().take(count) { + complete_positive_gesture(&mut h, &client, gesture, positive, &bytes); + } + } + } + } + + /// A screenshot that times out cancels its slot and gives its reservation back, with a + /// defined error instead of a silent success. + #[test] + fn a_timed_out_screenshot_releases_its_slot_and_budget() { + struct FakeHost; + impl Host for FakeHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap_or_else(web_time::Instant::now), + geometry: Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, epoch: 0 }, + reserved: 800 * 600 * 4, + attempts: 0, + }); + control.finish_pending(&ctx, &mut FakeHost); + let error = rx.try_recv().expect("the request is answered").expect_err("a timeout is an error"); + assert!(error.contains("not being drawn"), "{error}"); + assert_eq!(control.reserved_bytes(), 0, "the reservation is released"); + assert!(control.pending.is_empty(), "the cancelled capture is not retried"); + } + + /// A real successful callback must not bypass expiry, including when resize would otherwise + /// retry it. A non-expired completion still produces a real encoded PNG reply. + #[test] + fn completed_screenshots_obey_the_admission_deadline_before_success_or_retry() { + for (expired, resized) in [(false, false), (true, false), (true, true)] { + let ctx = egui::Context::default(); + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 32.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, _client) = attach(&ctx); + let geometry = Geometry::current(&ctx, 0); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + callback.complete(image); + assert!(weak_image.upgrade().is_some(), "the completed slot really retains pixels"); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: None, + region: None, + reply, + since: if expired { + web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap() + } else { + web_time::Instant::now() + }, + geometry, + reserved: geometry.image_bytes().unwrap(), + attempts: 0, + }); + if resized { + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(80.0, 40.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + assert_ne!(Geometry::current(&ctx, 0).size, geometry.size); + } + control.finish_pending(&ctx, &mut NoopHost); + let result = rx.try_recv().expect("one settled reply"); + if expired { + assert!(result.unwrap_err().contains("not being drawn"), "expiry takes priority over success or resize"); + } else { + let image = result.expect("the live completed capture still succeeds"); + assert_eq!(image["width"], 64); + assert_eq!(image["height"], 32); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(image["png_base64"].as_str().expect("actual PNG reply")).unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } + assert!(control.pending.is_empty(), "an expired capture is never retried"); + assert_eq!(control.reserved_bytes(), 0); + assert!(weak_image.upgrade().is_none(), "the slot releases its pixels"); + assert!(rx.try_recv().is_err(), "exactly one reply"); + } + } + + #[test] + fn expired_unissued_screenshots_are_not_issued_and_late_callbacks_are_harmless() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let late_callback = callback.clone(); + let geometry = Geometry { size: egui::vec2(64.0, 32.0), pixels_per_point: 1.0, epoch: 0 }; + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(), + geometry, + reserved: 64 * 32 * 4, + attempts: 0, + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| control.finish_frame(ui.ctx(), &mut NoopHost)); + assert!( + output + .viewport_output + .values() + .all(|viewport| !viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::Screenshot(_)))) + ); + output.drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + assert_eq!(control.reserved_bytes(), 0); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + late_callback.complete(image); + assert!(weak_image.upgrade().is_none(), "a late completion cannot resurrect a canceled request"); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().is_err(), "no second reply"); + } + + /// One queued batch is consumed by exactly one UI pass. + #[test] + fn one_batch_is_consumed_by_one_pass() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 3; + s.inject.push_back(RequestBatches { + request: 1, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 3.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 2, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(4.0, 5.0))]]), + }); + } + let mut first = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut first); + assert_eq!(first.events.len(), 1); + let mut second = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut second); + assert_eq!(second.events.len(), 1); + assert_eq!(second.events[0], egui::Event::PointerMoved(egui::pos2(4.0, 5.0))); + let mut third = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut third); + assert!(third.events.is_empty(), "a consumed batch is never repeated"); + } +} + +#[cfg(test)] +mod screenshot_geometry_tests { + use super::*; + + #[test] + fn image_bytes_are_checked_against_overflow_and_limits() { + assert_eq!(checked_image_bytes(2, 3), Some(24)); + assert_eq!(checked_image_bytes(usize::MAX, 2), None); + let big = Geometry { size: egui::vec2(10_000.0, 10_000.0), pixels_per_point: 8.0, epoch: 0 }; + assert!(big.image_bytes().is_some_and(|bytes| bytes > MAX_IMAGE_BYTES)); + } + + /// The crop is derived from the returned pixels and the admitted logical rect, not from a + /// current `pixels_per_point`: a fitted surface may return a smaller image. + #[test] + fn region_crop_uses_the_capture_geometry() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 50.0), pixels_per_point: 2.0, epoch: 7 }; + let reply = + screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(10.0, 5.0), egui::pos2(60.0, 30.0))), geometry).expect("a valid crop"); + assert_eq!(reply["width"], 100); + assert_eq!(reply["height"], 50); + assert_eq!(reply["pixels_per_point"], 2.0); + } + + /// A retry replaces one reservation in a running aggregate. Three 50 MiB captures that each + /// retry to 100 MiB must not all be admitted against a stale 150 MiB snapshot. + #[test] + fn retries_keep_one_running_aggregate_budget() { + const MIB: usize = 1024 * 1024; + let first = retried_reservation(3 * 50 * MIB, 50 * MIB, 100 * MIB).expect("the first retry fits"); + let second = retried_reservation(first, 50 * MIB, 100 * MIB).expect("the second retry fits"); + assert_eq!(second, 250 * MIB); + assert_eq!(retried_reservation(second, 50 * MIB, 100 * MIB), None, "the third would pass 256 MiB"); + assert_eq!(retried_reservation(0, 0, MAX_IMAGE_BYTES + 1), None, "a single image over the per-image limit never fits"); + } + + #[test] + fn an_image_with_another_aspect_ratio_is_rejected() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 60.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, None, geometry).is_err()); + } + + #[test] + fn an_outside_region_is_rejected_not_panicking() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(11.0, 6.0), egui::pos2(15.0, 9.0))), geometry).is_err()); + } + + /// A region that a shrink now cuts off is refused, not silently clamped to a smaller crop. + #[test] + fn a_region_cut_by_a_shrink_is_rejected_instead_of_cropped() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let shrunk = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + let region = egui::Rect::from_min_max(egui::pos2(8.0, 4.0), egui::pos2(15.0, 9.0)); + assert!(!shrunk.contains_region(region)); + let error = screenshot_png(&image, Some(region), shrunk).expect_err("a cut region must not become a smaller image"); + assert!(error.contains("inside the window"), "{error}"); + } + + /// Screenshot identity is the captured content, not the window's monitor-space origin or the + /// focused widget: only the capture epoch and the viewport-local size/scale matter. + #[test] + fn capture_geometry_compares_size_scale_and_capture_epoch_only() { + let admitted = Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }; + assert_eq!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(801.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.5, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 6 }); + assert!(admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(800.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(801.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(-1.0, 0.0), egui::pos2(10.0, 10.0)))); + } +} + #[cfg(test)] mod pointer_tests { use super::*; @@ -1436,6 +3182,8 @@ mod pointer_tests { let slow_dt = (max_duration + 0.25) as f32; assert!(f64::from(slow_dt) > max_duration); for discard in [false, true] { + // This probe draws its own UI with the harness's fonts, not PdfCraftApp::ui. + // Exercise the readiness gate explicitly before allowing this host's input. let state = Probe { control: None, client: None, @@ -1484,6 +3232,26 @@ mod pointer_tests { ); h.run_steps(3); let c = h.state().client.as_ref().unwrap().clone(); + let probe_pos = h.get_by_label("Click probe").rect().center(); + let waiting = c.send("ui.click", json!({"x":probe_pos.x,"y":probe_pos.y})); + h.run_steps(3); + assert!(waiting.try_recv().is_err(), "unready host must defer control input"); + assert_eq!(h.state().primary, 0); + assert!(h.state().events.is_empty()); + h.state_mut().host.fonts_ready = true; + let mut ready_reply = None; + for _ in 0..8 { + h.step(); + if let Ok(reply) = waiting.try_recv() { + ready_reply = Some(reply); + break; + } + } + assert!(ready_reply.expect("ready host completes deferred click").is_ok()); + assert_eq!(h.state().primary, 1); + h.state_mut().primary = 0; + h.state_mut().events.clear(); + h.state_mut().discarded = false; assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration); let pos = h.get_by_label("Click probe").rect().center(); assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (0, 0, 0)); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 59535f35e..4fe58a671 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -232,7 +232,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Show")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6f247757..b3437246a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { unlock(app, ctx); } +/// Draw the active modal dialog and apply the action the user chose. fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); @@ -154,6 +155,22 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { if tab == PropsTab::SpaceAudit { ui.add_space(4.0); @@ -162,7 +179,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { PropsTab::Description => { - row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); + path_row(ui, "File", doc.name.clone()); match app.props_draft.as_mut() { Some((_, draft)) if editable_info => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { @@ -192,7 +209,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((_, v)) = app.view_draft.as_mut() else { return }; ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); ui.end_row(); - ui.label(tl!("Navigation tab")); + let label = ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { egui::ComboBox::from_id_salt("iv-nav") .selected_text(tl!(format!("{:?}", v.navigation) @@ -209,10 +226,12 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ] { ui.selectable_value(&mut v.navigation, n, l); } - }); + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Page layout")); + let label = ui.label(tl!("Page layout")); ui.add_enabled_ui(editable, |ui| { let names = [ (L::Default, tl!("Default")), @@ -224,14 +243,18 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }); + egui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Magnification")); + let label = ui.label(tl!("Magnification")); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { let names = [ @@ -246,21 +269,25 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { M::Percent(p) => format!("{p:.0}%"), m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; - egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }); + egui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }) + .response + .labelled_by(label.id); }); }); ui.end_row(); - ui.label(tl!("Open to page")); + let label = ui.label(tl!("Open to page")); ui.add_enabled_ui(editable, |ui| { let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() { + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { v.page = p - 1; } ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); @@ -455,7 +482,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } PropsTab::Advanced => { row(ui, "PDF version", i.pdf_version.clone()); - row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); let p = &i.pages[0]; row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); @@ -606,10 +633,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { d.to = d.to.clamp(1, count); d.from = d.from.clamp(1, d.to); ui.horizontal(|ui| { - ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); - ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); if from.changed() || to.changed() { d.range_edited = true; } @@ -619,8 +646,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let max_start = d.src_pages.saturating_sub(n) + 1; d.src_from = d.src_from.clamp(1, max_start.max(1)); ui.horizontal(|ui| { - ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))); + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); }); @@ -1286,7 +1313,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::About => { // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal_wrapped(|ui| { for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { @@ -1882,7 +1909,16 @@ fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.label(tl!("This document is trying to open a local file:")); egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { - ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true)); + // A local path is file/path chrome: left to right, so its final component stays last. + let mut job = egui::text::LayoutJob::simple( + pending.path.to_string_lossy().into_owned(), + egui::FontId::monospace(13.0), + crate::theme::Tokens::get(ui.ctx()).text, + ui.available_width().max(0.0), + ); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(true)); }); ui.add_space(8.0); ui.label(tl!("Only allow this if you trust the source document.")); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index f67398d84..4f5630c0b 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -471,6 +471,19 @@ pub struct ImageSelection { settled: Option<(Rect, Rect)>, } +impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.drag = None; + self.turning = false; + if let Some(crop) = &mut self.crop + && let Some((_, _, before)) = crop.drag.take() + { + crop.keep = before; + } + } +} + /// Cropping a page image: the handles trim instead of resizing, the part that will go is /// dimmed, Enter or Done applies and Esc or Cancel leaves it. #[derive(Clone, Debug, PartialEq)] @@ -1615,7 +1628,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new("edit-text-line")) + egui::Area::new(egui::Id::unique("edit-text-line")) .order(egui::Order::Foreground) .fade_in(false) .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) @@ -1677,7 +1690,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; let output = egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) + .id(egui::Id::unique("edit-text-line-input")) .font(font.clone()) .margin(egui::Margin::ZERO) .layouter(&mut layouter) @@ -1686,6 +1699,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .desired_width(width) .desired_rows(rows) .show(ui); + // The editor paints over the page with no visible label; name it for assistive tech. + output.response.response.clone().accessible_name(tl!("Text editing line")); if ed.has_changes() { if background_ready { reveal = true; diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs index d9b5255bf..6dacf67a6 100644 --- a/crates/ui-egui/src/field_table_ui.rs +++ b/crates/ui-egui/src/field_table_ui.rs @@ -159,7 +159,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let mut choice = None; let mut laid = None; egui::Window::new(tl!("Text Field Table")) - .id(egui::Id::new("field_table_window")) + .id(egui::Id::unique("field_table_window")) .open(&mut open) .collapsible(false) .resizable(false) @@ -255,15 +255,20 @@ fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[For for (c, column) in d.table.columns.iter_mut().enumerate() { let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]); let share = shares.get(c).copied().unwrap_or_default(); + let mut column_label_response = None; ui.horizontal(|ui| { - ui.label(RichText::new(column_label(c)).strong()); + let label = ui.label(RichText::new(column_label(c)).strong()); + column_label_response = Some(label.id); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0)); ui.label(RichText::new(size).color(t.text_muted).size(11.0)); }); }); let full = ui.available_width(); - ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + let pattern = ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + if let Some(label) = column_label_response { + pattern.labelled_by(label); + } ui.add_space(4.0); } }; @@ -375,7 +380,7 @@ fn column_dividers(ui: &mut egui::Ui, d: &mut FieldTableDraft, bar: Rect, ends: let mut active = None; for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) { let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom())); - let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag()); + let resp = ui.interact(hit, ui.scope_id().with(("field table divider", i)), Sense::click_and_drag()); let share = d.shares().get(i).copied().unwrap_or_default(); resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i))); let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width()); @@ -469,7 +474,7 @@ mod tests { |ui| { let other = ui.button("Other control"); if let Some(i) = focus { - ui.memory_mut(|memory| memory.request_focus(ui.id().with(("field table divider", i)))); + ui.memory_mut(|memory| memory.request_focus(ui.scope_id().with(("field table divider", i)))); } else { other.request_focus(); } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 862b63e72..aab071b50 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -555,7 +555,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( @@ -718,7 +718,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // Over the page, so only where the document shows: a box scrolled under the mode bar or a // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("fill-text", view.id.0))) + egui::Area::new(egui::Id::unique(("fill-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(pos) @@ -732,15 +732,17 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); - let r = ui.add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) - .desired_width(width) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) - .text_color(Color32::BLACK) - .hint_text(tl!("Type text")) - .id_salt("fill-text-edit"), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text(tl!("Type text")) + .id_salt("fill-text-edit"), + ) + .accessible_name(tl!("Text to place")); if t.focus { r.request_focus(); t.focus = false; @@ -911,10 +913,10 @@ fn scan_tab( } let Some(scan) = d.scan.as_mut() else { painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); return browse; }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); // The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points. let [pw, ph] = scan.photo.size(); let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32); diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs index cce004ce5..b5eafe69d 100644 --- a/crates/ui-egui/src/fling.rs +++ b/crates/ui-egui/src/fling.rs @@ -64,7 +64,7 @@ impl Fling { /// (animations off, single-page paging, a zoom, a dialog). pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) { for event in events { - let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + let Event::MouseWheel { unit, delta, phase, modifiers, .. } = event else { continue }; self.stop(); // Zooming, a mouse wheel and sideways Shift scrolling never glide. if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift { @@ -160,7 +160,13 @@ mod tests { use egui::{Modifiers, vec2}; fn wheel(dy: f32, phase: TouchPhase) -> Event { - Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } /// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last. @@ -213,6 +219,7 @@ mod tests { assert!(!fling.gliding(), "the finger rested before it lifted"); } + /// Check that new finger or wheel input stops an in-progress glide. #[test] fn new_input_stops_a_glide() { let mut fling = Fling::new(true); @@ -222,11 +229,18 @@ mod tests { let mut fling = Fling::new(true); let now = swipe(&mut fling, -25.0, 6); - let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + let notch = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; fling.read(&[notch], now + 0.1, true); assert!(!fling.gliding(), "a wheel notch"); } + /// Check that disabled flinging, a false glide flag, and a command-modified zoom gesture prevent glide. #[test] fn no_glide_where_it_is_off_or_not_wanted() { let mut off = Fling::new(false); @@ -242,7 +256,13 @@ mod tests { assert!(!fling.gliding(), "animations off, paging or a dialog"); let mut zoom = Fling::new(true); - let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND }; + let ctrl = |phase| Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, -25.0), + phase, + modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true); zoom.read(&[ctrl(TouchPhase::Move)], dt, true); zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true); diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs index 727417e2f..fe9b2c370 100644 --- a/crates/ui-egui/src/folders_ui.rs +++ b/crates/ui-egui/src/folders_ui.rs @@ -314,20 +314,21 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); - ui.label(egui::RichText::new(crate::bidi::visual(&name)).font(theme::semibold(14.0))).on_hover_text(folder); + // Folder names and paths are file/path chrome: explicitly left to right, so an + // Arabic location keeps its `.pdf`/extension at the visual end. + widgets::label_ltr(ui, name.clone(), theme::semibold(14.0), t.text).on_hover_text(folder); ui.add_space(4.0); // Leave room for Unpin: a long location is cut short, never the button. let room = (ui.available_width() - 110.0).max(0.0); ui.scope(|ui| { ui.set_max_width(room); - let place = egui::RichText::new(crate::bidi::visual(&location(folder))).font(theme::regular(12.0)).color(t.text_faint); - ui.add(egui::Label::new(place).truncate()).on_hover_text(folder); + widgets::label_ltr(ui, location(folder), theme::regular(12.0), t.text_faint).on_hover_text(folder); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } @@ -353,7 +354,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let shown = if expanded { listing.files.len() } else { listing.files.len().min(SHOWN) }; for f in listing.files.iter().take(shown) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &f.name)); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); } @@ -369,7 +370,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let detail = if when.is_empty() { size } else { format!("{when} · {size}") }; let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); let width = detail.left() - 16.0 - (rect.left() + 62.0); - widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width); + widgets::row_text(ui, rect.min + vec2(62.0, 19.0), f.name.as_str(), theme::medium(13.0), t.text, width); if resp.on_hover_text(&f.path).clicked() { open = Some(f.path.clone()); } diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 9849920ac..69a49704a 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -280,7 +280,7 @@ fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { /// labels. fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), label)); let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); resp.on_hover_text(label) @@ -317,7 +317,7 @@ fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: /// The text colour as a square swatch that opens the palette. fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); let fill = rect.shrink(4.0); ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8caa44ced..28494160c 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -322,7 +322,7 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { #[allow(clippy::too_many_arguments)] fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) { let mut enter = false; - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -340,8 +340,9 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, widget))); - let r = ui.add_sized(rect.size(), te); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + // The form field's own logical name; not a visual/preordered label. + let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -381,7 +382,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st fx.calendar_text = Some(fx.text.clone()); } let mut picked = None; - let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0))) + let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(field.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -672,7 +673,7 @@ pub(crate) fn overlay( // Over the page, so only where the document shows: a field scrolled under the mode bar // or a side panel must leave them their pointer (#2755). It stays on its field, cut off // as the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -697,7 +698,7 @@ pub(crate) fn overlay( .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } @@ -705,11 +706,14 @@ pub(crate) fn overlay( te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); } let mut laid_out = None; - let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }); + let r = ui + .add_sized(rect.size(), |ui: &mut egui::Ui| { + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }) + // The form field's own logical name; not a visual/preordered label. + .accessible_name(f.name.clone()); if let (Some(tb), Some(before)) = (text_box, before) && fx.text != before { @@ -784,7 +788,7 @@ pub(crate) fn overlay( if editable_combo(f) { combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget); } - let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) + let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -843,10 +847,10 @@ pub(crate) fn overlay( FormFieldKind::CheckBox | FormFieldKind::Radio => { // Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so // Space, Tab and the arrows come here instead of turning or scrolling the page. - let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)); + let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)); let mut has_focus = false; // Not interactable, so clicks still reach the page under it. - let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); + let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); area.fixed_pos(rect.min).show(ctx, |ui| { let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); if let Some(fx) = view.forms.focus.as_mut() diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 59b0a52bb..bfabf701e 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -18,6 +18,7 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre } } +/// Draw the Home page with the welcome text, community card and recent documents. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -72,7 +73,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { for id in RECOMMENDED { let Some(g) = catalog::group(id) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); let fill = press.fill(t.card, t.hover, t.pressed); @@ -110,7 +111,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file"))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); ui.painter().rect( @@ -142,7 +143,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if !app.recent.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); }); } @@ -155,7 +156,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut remove = None; for r in &app.recent { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); let press = theme::Press::track(ui, &resp); // The row, not its response: the pointer stays "in" the row over its remove button. let hovered = ui.rect_contains_pointer(rect); @@ -168,9 +169,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if hovered { // Remove just this file from the list (#430); the file itself is untouched. let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0)); - let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click()); + let x = ui.interact(x_rect, ui.scope_id().with(("remove-recent", &r.path)), Sense::click()); let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]); - x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if x.hovered() { ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed); } @@ -197,8 +198,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); // The name and path stop short of the page count; hovering shows the whole path. let width = detail.left() - 16.0 - (body.left() + 46.0); - widgets::row_text(ui, body.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width); - widgets::row_text(ui, body.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width); + widgets::row_text(ui, body.min + vec2(46.0, 15.0), r.name.as_str(), theme::medium(13.5), t.text, width); + widgets::row_text(ui, body.min + vec2(46.0, 32.0), r.path.as_str(), theme::regular(11.0), t.text_faint, width); if theme::hand(resp).on_hover_text(&r.path).clicked() { open = Some(r.path.clone()); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8b951d6f6..3e6d73552 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu القائمة File ملف @@ -184,6 +184,15 @@ Available متاح Ready جاهز Search tools and commands… البحث في الأدوات والأوامر… + +# Accessible names for inputs that have no visible label. + Added text النص المضاف + Link URL عنوان URL للرابط + Search tools and commands البحث في الأدوات والأوامر + Text editing line سطر تحرير النص + Text to place النص المراد وضعه + Tooltip تلميح + Words or phrases to redact الكلمات أو العبارات المراد حجبها No matching tools لا توجد أدوات مطابقة Tool أداة Command أمر diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index c3a692c49..ec0339f18 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1832,6 +1832,15 @@ Tool Инструмент Command Команда Search tools and commands… Търсене на инструменти и команди… + +# Accessible names for inputs that have no visible label. + Added text Добавен текст + Link URL URL на връзката + Search tools and commands Търсене на инструменти и команди + Text editing line Ред за редактиране на текст + Text to place Текст за поставяне + Tooltip Подсказка + Words or phrases to redact Думи или фрази за заличаване No matching tools Няма съответстващи инструменти All tools Всички инструменти Read Четене diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cc888dc97..0cacae3c2 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -178,6 +178,15 @@ Available Disponible Ready Llest Search tools and commands… Cerca eines i ordres… + +# Accessible names for inputs that have no visible label. + Added text Text afegit + Link URL URL de l'enllaç + Search tools and commands Cerca eines i ordres + Text editing line Línia d'edició de text + Text to place Text que cal col·locar + Tooltip Suggeriment + Words or phrases to redact Paraules o frases per censurar No matching tools Cap eina coincident Tool Eina Command Ordre diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 864cdb434..7bf429666 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -155,15 +155,6 @@ impl Catalog { (c, errors) } - /// Rewrite every translation (each plural form on its own), e.g. into display order for a - /// right-to-left language. Keys are untouched. - pub fn map_translations(&mut self, f: impl Fn(&str) -> String) { - let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.ids)); - for translation in tables.flat_map(HashMap::values_mut).chain(self.plurals.values_mut().flatten()) { - *translation = f(translation); - } - } - pub fn plain(&self, s: &str) -> Option<&str> { self.plain.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index db8fc1dab..71c0c6286 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu پێڕست File فایل @@ -184,6 +184,15 @@ Available بەردەست Ready ئامادە Search tools and commands… گەڕان لە ئامرازەکان و فەرمانەکان… + +# Accessible names for inputs that have no visible label. + Added text دەقی زیادکراو + Link URL ناونیشانی URLی بەستەر + Search tools and commands گەڕان لە ئامرازەکان و فەرمانەکان + Text editing line هێڵی دەستکاریکردنی دەق + Text to place دەقی دانان + Tooltip ڕێنمایی + Words or phrases to redact وشە یان دەستەواژەکان بۆ شاردنەوە No matching tools هیچ ئامرازێکی گونجاو نییە Tool ئامراز Command فەرمان diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 28fb4e1d6..ed6d051b5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -305,6 +305,15 @@ key Space Mezerník Available Dostupné Ready Připraveno Search tools and commands… Hledat nástroje a příkazy… + +# Accessible names for inputs that have no visible label. + Added text Přidaný text + Link URL Adresa URL odkazu + Search tools and commands Hledat nástroje a příkazy + Text editing line Řádek pro úpravu textu + Text to place Text k umístění + Tooltip Popisek + Words or phrases to redact Slova nebo fráze k začernění No matching tools Žádné odpovídající nástroje Tool Nástroj Command Příkaz diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bc202ef1a..8586a21d4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -157,6 +157,15 @@ Available Verfügbar Ready Bereit Search tools and commands… Tools und Befehle suchen… + +# Accessible names for inputs that have no visible label. + Added text Hinzugefügter Text + Link URL URL des Links + Search tools and commands Tools und Befehle suchen + Text editing line Textbearbeitungszeile + Text to place Einzufügender Text + Tooltip Tooltip + Words or phrases to redact Zu schwärzende Wörter oder Ausdrücke No matching tools Keine passenden Werkzeuge Tool Werkzeug Command Befehl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index ebe796965..e3eac77ca 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2167,6 +2167,15 @@ View more Περισσότερα Available Διαθέσιμο Search tools and commands… Αναζήτηση εργαλείων και εντολών… + +# Accessible names for inputs that have no visible label. + Added text Προστιθέμενο κείμενο + Link URL Διεύθυνση URL συνδέσμου + Search tools and commands Αναζήτηση εργαλείων και εντολών + Text editing line Γραμμή επεξεργασίας κειμένου + Text to place Κείμενο προς τοποθέτηση + Tooltip Συμβουλή εργαλείου + Words or phrases to redact Λέξεις ή φράσεις για απόκρυψη No matching tools Δεν βρέθηκαν εργαλεία Tool Εργαλείο Command Εντολή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c839eb330..0d4e505a7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -180,6 +180,15 @@ Available Disponible Ready Listo Search tools and commands… Buscar herramientas y comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto añadido + Link URL URL del enlace + Search tools and commands Buscar herramientas y comandos + Text editing line Línea de edición de texto + Text to place Texto que colocar + Tooltip Información sobre herramientas + Words or phrases to redact Palabras o frases que censurar No matching tools Ninguna herramienta coincide Tool Herramienta Command Comando diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 297d3084b..5cfc21753 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -14,9 +14,9 @@ # (Acrobat's included) was consulted or copied; add entries the same way. # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters. # -# Right to left: like Arabic and Kurdish, `rtl: true` in i18n/mod.rs puts every entry into display -# order when the catalog loads ([`crate::bidi::display_rtl`]); entries are written here in logical -# (typing) order. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins +# Right to left: like Arabic and Kurdish, entries stay in logical (typing) order. +# `rtl: true` in i18n/mod.rs selects explicit right-to-left text direction; epaint reorders +# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins # compound words. Menu منو diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a876b8aab..a542ee9b9 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -178,6 +178,15 @@ Available Käytettävissä Ready Valmis Search tools and commands… Hae työkaluja ja komentoja… + +# Accessible names for inputs that have no visible label. + Added text Lisätty teksti + Link URL Linkin URL-osoite + Search tools and commands Hae työkaluja ja komentoja + Text editing line Tekstin muokkausrivi + Text to place Sijoitettava teksti + Tooltip Työkaluvihje + Words or phrases to redact Peitettävät sanat tai lauseet No matching tools Ei vastaavia työkaluja Tool Työkalu Command Komento diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bb935754b..61c8b5171 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -157,6 +157,15 @@ Available Disponible Ready Prêt Search tools and commands… Rechercher des outils et des commandes… + +# Accessible names for inputs that have no visible label. + Added text Texte ajouté + Link URL URL du lien + Search tools and commands Rechercher des outils et des commandes + Text editing line Ligne de modification du texte + Text to place Texte à placer + Tooltip Info-bulle + Words or phrases to redact Mots ou expressions à biffer No matching tools Aucun outil correspondant Tool Outil Command Commande diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8b1de38dc..e9d627aca 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -162,6 +162,15 @@ Available उपलब्ध Ready तैयार Search tools and commands… टूल और कमांड खोजें… + +# Accessible names for inputs that have no visible label. + Added text जोड़ा गया टेक्स्ट + Link URL लिंक का URL + Search tools and commands टूल और कमांड खोजें + Text editing line टेक्स्ट संपादन पंक्ति + Text to place रखने के लिए टेक्स्ट + Tooltip टूलटिप + Words or phrases to redact रेडैक्ट करने के लिए शब्द या वाक्यांश No matching tools कोई मेल खाता टूल नहीं Tool टूल Command कमांड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 71bf7d36c..6b37b9169 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -155,6 +155,15 @@ Available Elérhető Ready Kész Search tools and commands… Eszközök és parancsok keresése… + +# Accessible names for inputs that have no visible label. + Added text Hozzáadott szöveg + Link URL Hivatkozás URL-je + Search tools and commands Eszközök és parancsok keresése + Text editing line Szövegszerkesztő sor + Text to place Elhelyezendő szöveg + Tooltip Elemleírás + Words or phrases to redact Kitakarni kívánt szavak vagy kifejezések No matching tools Nincs illeszkedő eszköz Tool Eszköz Command Parancs diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 409a04cf4..292b336e1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1011,6 +1011,15 @@ Scroll tabs right Gulir tab ke kanan Search comments Cari komentar Search tools and commands… Cari alat dan perintah… + +# Accessible names for inputs that have no visible label. + Added text Teks yang ditambahkan + Link URL URL tautan + Search tools and commands Cari alat dan perintah + Text editing line Baris penyuntingan teks + Text to place Teks yang akan ditempatkan + Tooltip Tooltip + Words or phrases to redact Kata atau frasa untuk disensor Searchable Image (Exact) Gambar yang Dapat Dicari (Tepat) Searching… {s} of {p} pages Mencari… {s} dari {p} halaman Searching… {s}/{p} Mencari… {s}/{p} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 11a9d4869..20729bae6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -181,6 +181,15 @@ Available Disponibile Ready Pronto Search tools and commands… Cerca strumenti e comandi… + +# Accessible names for inputs that have no visible label. + Added text Testo aggiunto + Link URL URL del collegamento + Search tools and commands Cerca strumenti e comandi + Text editing line Riga di modifica del testo + Text to place Testo da posizionare + Tooltip Descrizione comando + Words or phrases to redact Parole o frasi da oscurare No matching tools Nessuno strumento corrispondente Tool Strumento Command Comando diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a8c287bc..1ed4d43b6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -184,6 +184,15 @@ Available 使用可能 Ready 使用可能 Search tools and commands… ツールとコマンドを検索… + +# Accessible names for inputs that have no visible label. + Added text 追加したテキスト + Link URL リンクの URL + Search tools and commands ツールとコマンドを検索 + Text editing line テキスト編集行 + Text to place 配置するテキスト + Tooltip ツールチップ + Words or phrases to redact 墨消しする単語または語句 No matching tools 該当するツールがありません Tool ツール Command コマンド diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index a9eada974..9022c3591 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -145,6 +145,15 @@ Available Қолжетімді Ready Дайын Search tools and commands… Құралдар мен командаларды іздеу… + +# Accessible names for inputs that have no visible label. + Added text Қосылған мәтін + Link URL Сілтеменің URL мекенжайы + Search tools and commands Құралдар мен командаларды іздеу + Text editing line Мәтінді өңдеу жолы + Text to place Орналастырылатын мәтін + Tooltip Қалтқы + Words or phrases to redact Жасырылатын сөздер немесе сөз тіркестері No matching tools Сәйкес құралдар жоқ Tool Құрал Command Команда diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 98b68e85c..09b9cad9f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -162,6 +162,15 @@ Available 사용 가능 Ready 준비됨 Search tools and commands… 도구 및 명령 검색… + +# Accessible names for inputs that have no visible label. + Added text 추가한 텍스트 + Link URL 링크 URL + Search tools and commands 도구 및 명령 검색 + Text editing line 텍스트 편집 줄 + Text to place 배치할 텍스트 + Tooltip 도구 설명 + Words or phrases to redact 민감 정보를 제거할 단어 또는 구문 No matching tools 일치하는 도구가 없습니다 Tool 도구 Command 명령 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 331b24501..964467d82 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -182,6 +182,15 @@ Available ພ້ອມໃຊ້ງານ Ready ພ້ອມແລ້ວ Search tools and commands… ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ… + +# Accessible names for inputs that have no visible label. + Added text ຂໍ້ຄວາມທີ່ເພີ່ມ + Link URL URL ຂອງລິ້ງຄ໌ + Search tools and commands ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ + Text editing line ແຖວແກ້ໄຂຂໍ້ຄວາມ + Text to place ຂໍ້ຄວາມທີ່ຈະວາງ + Tooltip ຄຳແນະນຳເຄື່ອງມື + Words or phrases to redact ຄຳ ຫຼື ວະລີທີ່ຈະປິດບັງ No matching tools ບໍ່ພົບເຄື່ອງມືທີ່ກົງກັນ Tool ເຄື່ອງມື Command ຄຳສັ່ງ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 2c151b255..43749f4ec 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1067,6 +1067,15 @@ Scroll tabs right Slinkti korteles dešinėn Search comments Ieškoti komentaruose Search tools and commands… Ieškoti įrankių ir komandų… + +# Accessible names for inputs that have no visible label. + Added text Pridėtas tekstas + Link URL Saito URL + Search tools and commands Ieškoti įrankių ir komandų + Text editing line Teksto redagavimo eilutė + Text to place Tekstas, kurį reikia įdėti + Tooltip Patarimas + Words or phrases to redact Šalinami žodžiai ar frazės Searchable Image (Exact) Paveikslėlis su tiksliu atpažinimu Searching… {s} of {p} pages Ieškoma… {s} iš {p} puslapių Searching… {s}/{p} Ieškoma… {s}/{p} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 15a27becb..f57894f6c 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -44,8 +44,8 @@ pub struct LangInfo { /// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's /// `@plural` entries list one form per index. pub plural: fn(u64) -> usize, - /// The language is written right to left. egui lays text out left to right, so the catalog's - /// translations are put into display order when it loads ([`crate::bidi::display_rtl`]). + /// The language is written right to left. Catalogs stay in logical order: the egui 0.37 fork + /// owns bidi shaping/reordering (E5/E12), so preordering here would reverse Arabic twice. pub rtl: bool, catalog: OnceLock, } @@ -265,15 +265,16 @@ impl LangInfo { (0..=1000).map(self.plural).max().unwrap_or(0).saturating_add(1) } + /// Return the parsed catalogue, building it on first use and warning about entries that fall back to + /// English. fn catalog(&self) -> &Catalog { self.catalog.get_or_init(|| { - let (mut catalog, errors) = Catalog::parse(self.source, self.plural_forms()); + let (catalog, errors) = Catalog::parse(self.source, self.plural_forms()); for error in errors { log::warn!("{} interface catalog: {error}; that entry shows in English", self.code); } - if self.rtl { - catalog.map_translations(crate::bidi::display_rtl); - } + // Catalogs keep logical text: the fork shapes and reorders each run once. Accessibility + // names, copied text and search therefore see the same logical string as the source. catalog }) } @@ -325,8 +326,17 @@ impl Lang { /// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in /// code point order: Greek, Cyrillic, Arabic, then the CJK languages. pub fn for_picker() -> Vec { + Self::for_picker_by(Lang::name) + } + + /// [`for_picker`](Self::for_picker), ordered by the names the picker actually shows. + /// + /// `shown` must return each language's displayed name. Pass [`Lang::picker_name`] to keep a + /// Latin-script fallback with the other Latin names when a script's face is missing, rather + /// than sorting it into a block of unrenderable labels. + pub fn for_picker_by(mut shown: impl FnMut(Lang) -> &'static str) -> Vec { let mut rest: Vec = Lang::all().filter(|l| l.code() != Lang::EN.code()).collect(); - rest.sort_by_cached_key(|l| name_sort_key(l.name())); + rest.sort_by_cached_key(|l| name_sort_key(shown(*l))); std::iter::once(Lang::EN).chain(rest).collect() } @@ -334,8 +344,61 @@ impl Lang { self.0.name } - /// Whether the language is written right to left. Its translations come back from the - /// lookups in display order (see [`LangInfo::rtl`]). + /// The language's name in English, used by [`picker_name`](Self::picker_name) when this + /// build's fonts cannot draw [`name`](Self::name). + pub fn english_name(self) -> &'static str { + match self.code() { + "en" => "English", + "ja" => "Japanese", + "zh-hans" => "Simplified Chinese", + "zh-hant" => "Traditional Chinese", + "cs" => "Czech", + "pt-br" => "Portuguese (Brazil)", + "de" => "German", + "es" => "Spanish", + "fr" => "French", + "ru" => "Russian", + "kk" => "Kazakh", + "bg" => "Bulgarian", + "te" => "Telugu", + "hu" => "Hungarian", + "uk" => "Ukrainian", + "it" => "Italian", + "ar" => "Arabic", + "ckb" => "Central Kurdish", + "fa" => "Persian", + "pl" => "Polish", + "sl" => "Slovenian", + "el" => "Greek", + "tr" => "Turkish", + "lt" => "Lithuanian", + "id" => "Indonesian", + "vi" => "Vietnamese", + "fi" => "Finnish", + "nb" => "Norwegian Bokmål", + "sq" => "Albanian", + "th" => "Thai", + "ca" => "Catalan", + "ro" => "Romanian", + "lo" => "Lao", + "hi" => "Hindi", + "ko" => "Korean", + // Only reached if a language is added to `LANGUAGES` without an English name; the + // code still tells the reader which entry it is. + _ => self.0.code, + } + } + + /// The name to show in the language picker: the native name when the current fonts can draw + /// it, otherwise the English name. With egui 0.37's default `MissingGlyphPolicy::Panic`, a + /// native name whose script has no face (for example Hindi in a build without a Devanagari + /// face and with installed fonts turned off) would otherwise abort while the list opens. + pub fn picker_name(self, drawable: impl FnOnce(&str) -> bool) -> &'static str { + if drawable(self.name()) { self.name() } else { self.english_name() } + } + + /// Whether the language is written right to left. Lookups stay logical; the egui fork + /// shapes and reorders each run for display (see [`LangInfo::rtl`]). pub fn rtl(self) -> bool { self.0.rtl } @@ -711,6 +774,20 @@ mod tests { set_current(previous); } + /// Regression test: a language picker uses its native name only when a face can draw it, otherwise its + /// English name. + #[test] + fn picker_names_fall_back_to_english_when_their_script_has_no_face() { + let hi = Lang::from_code("hi").expect("hi registered"); + assert_eq!(hi.picker_name(|_| true), "हिन्दी", "the native name is kept when it can be drawn"); + assert_eq!(hi.picker_name(|_| false), "Hindi", "no Devanagari face: an English name, not an unrenderable label"); + assert_eq!(Lang::EN.picker_name(|_| false), "English"); + for language in Lang::all() { + assert!(!language.english_name().is_empty(), "{}", language.code()); + assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code()); + } + } + #[test] fn tags_map_to_languages() { assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); @@ -1694,25 +1771,24 @@ mod tests { assert_eq!(restored.language, "ar"); } - /// Lookups return Arabic in display order: egui draws the words left to right as given, and - /// joins the letters of each word itself. + /// Catalog lookups stay in logical order; the egui 0.37 fork owns bidi shaping and display + /// reordering (E5/E12), so preordering here would reverse Arabic twice. Accessibility names, + /// copied labels and search therefore see the source string. #[test] - fn arabic_lookups_are_in_display_order() { + fn arabic_catalog_stays_logical() { let ar = arabic(); - // "حفظ باسم…" as typed; the ellipsis ends the phrase on the left. - assert_eq!(tr(ar, "Save as…"), "…باسم حفظ"); - // Latin names keep their own direction inside the phrase. - assert_eq!(tr(ar, "About PdfCraft"), "PdfCraft حول"); - // A caption's colon stays beside the widget on its right. + assert_eq!(tr(ar, "Save as…"), "حفظ باسم…"); + assert_eq!(tr(ar, "About PdfCraft"), "حول PdfCraft"); assert_eq!(tr(ar, "Name:"), "الاسم:"); - assert_eq!(tr(ar, "Issued by: "), "عن صادرة: "); - // Placeholders survive and are filled in place. - assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "{name}: {e} فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "a {e}.pdf: x فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "12 من 3 الصفحة"); + assert_eq!(tr(ar, "Issued by: "), "صادرة عن: "); + // Placeholders survive in their logical positions. + assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "تعذّر فتح {name}: {e}"); + assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "تعذّر فتح a {e}.pdf: x"); + assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "الصفحة 3 من 12"); set_current(ar); - assert_eq!(command_label("Undo Insert pages from a.pdf"), "a.pdf من صفحات إدراج عن التراجع"); - assert_eq!(menu_label("file.saveAs", "Save as…"), "…باسم حفظ"); + let command = command_label("Undo Insert pages from a.pdf"); + assert!(command.contains("a.pdf") && command.contains("إدراج صفحات"), "logical command label: {command:?}"); + assert_eq!(menu_label("file.saveAs", "Save as…"), "حفظ باسم…"); set_current(Lang::EN); } @@ -1744,7 +1820,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); } assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); - // Lookups return Persian (single words in display order). + // Lookups return Persian in logical order; epaint handles visual ordering. assert_eq!(tr(fa, "Save"), "ذخیره"); assert_eq!(tr(fa, "Cancel"), "لغو"); assert_eq!(tr(fa, "File"), "فایل"); diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 75c926a0e..6f1739974 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1020,6 +1020,15 @@ Scroll tabs right Rull faner til høyre Search comments Søk i kommentarer Search tools and commands… Søk etter verktøy og kommandoer… + +# Accessible names for inputs that have no visible label. + Added text Lagt til tekst + Link URL URL for lenken + Search tools and commands Søk etter verktøy og kommandoer + Text editing line Tekstredigeringslinje + Text to place Tekst som skal plasseres + Tooltip Verktøytips + Words or phrases to redact Ord eller fraser som skal sladdes Searchable Image (Exact) Søkbart bilde (eksakt) Searching… {s} of {p} pages Søker… {s} av {p} sider Searching… {s}/{p} Søker… {s}/{p} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7d7673949..963d71758 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -180,6 +180,15 @@ Available Dostępne Ready Gotowe Search tools and commands… Szukaj narzędzi i poleceń… + +# Accessible names for inputs that have no visible label. + Added text Dodany tekst + Link URL Adres URL łącza + Search tools and commands Szukaj narzędzi i poleceń + Text editing line Wiersz edycji tekstu + Text to place Tekst do umieszczenia + Tooltip Podpowiedź + Words or phrases to redact Słowa lub frazy do utajenia No matching tools Brak pasujących narzędzi Tool Narzędzie Command Polecenie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 371c8cc14..c34ae83bc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1705,6 +1705,15 @@ Tool Ferramenta Command Comando Search tools and commands… Pesquisar ferramentas e comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto adicionado + Link URL URL do link + Search tools and commands Pesquisar ferramentas e comandos + Text editing line Linha de edição de texto + Text to place Texto a colocar + Tooltip Dica de ferramenta + Words or phrases to redact Palavras ou frases para ocultar No matching tools Nenhuma ferramenta correspondente All tools Todas as ferramentas Read Ler diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 09b313e73..78d2298b9 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1020,6 +1020,15 @@ Scroll tabs right Derulare file la dreapta Search comments Căutare în comentarii Search tools and commands… Căutare instrumente și comenzi… + +# Accessible names for inputs that have no visible label. + Added text Text adăugat + Link URL URL-ul linkului + Search tools and commands Căutare instrumente și comenzi + Text editing line Linie de editare a textului + Text to place Text de plasat + Tooltip Sfat ecran + Words or phrases to redact Cuvinte sau expresii de anonimizat Searchable Image (Exact) Imagine în care se poate căuta (exactă) Searching… {s} of {p} pages Se caută… {s} din {p} pagini Searching… {s}/{p} Se caută… {s}/{p} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 0523fe70c..274232d58 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -177,6 +177,15 @@ Available Доступно Ready Готово Search tools and commands… Поиск инструментов и команд… + +# Accessible names for inputs that have no visible label. + Added text Добавленный текст + Link URL URL-адрес ссылки + Search tools and commands Поиск инструментов и команд + Text editing line Строка редактирования текста + Text to place Текст для размещения + Tooltip Подсказка + Words or phrases to redact Слова или фразы для скрытия No matching tools Нет подходящих инструментов Tool Инструмент Command Команда diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3a7ea48c2..420e6a599 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1636,6 +1636,15 @@ Search Iskanje Search comments Išči po komentarjih Search tools and commands… Poišči orodja in ukaze… + +# Accessible names for inputs that have no visible label. + Added text Dodano besedilo + Link URL URL povezave + Search tools and commands Poišči orodja in ukaze + Text editing line Vrstica za urejanje besedila + Text to place Besedilo za postavitev + Tooltip Namig + Words or phrases to redact Besede ali fraze za počrnitev Searchable Image (Exact) Iskalna slika (natančna) Searching… {s} of {p} pages Iskanje… {s} od {p} strani Searching… {s}/{p} Iskanje… {s}/{p} diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 70a6fbad7..ba7c9dc5b 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -1585,6 +1585,15 @@ Search Kërko Search comments Kërko te komentet Search tools and commands… Kërko mjete dhe komanda… + +# Accessible names for inputs that have no visible label. + Added text Tekst i shtuar + Link URL URL e lidhjes + Search tools and commands Kërko mjete dhe komanda + Text editing line Rreshti i redaktimit të tekstit + Text to place Teksti për t'u vendosur + Tooltip Këshilla e mjetit + Words or phrases to redact Fjalë ose fraza për redaktim Searchable Image (Exact) Imazh i kërkueshëm (i saktë) Searching… {s} of {p} pages Po kërkohet… {s} nga {p} faqe Searching… {s}/{p} Po kërkohet… {s}/{p} diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 2c8808aa4..7f339989e 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -161,6 +161,15 @@ comment menu Edit సవరించు Available అందుబాటులో ఉంది Ready సిద్ధం Search tools and commands… సాధనాలు, కమాండ్‌లలో వెతుకు… + +# Accessible names for inputs that have no visible label. + Added text జోడించిన టెక్స్ట్ + Link URL లింక్ URL + Search tools and commands సాధనాలు, కమాండ్‌లలో వెతుకు + Text editing line టెక్స్ట్ సవరణ పంక్తి + Text to place ఉంచాల్సిన టెక్స్ట్ + Tooltip టూల్‌టిప్ + Words or phrases to redact రెడాక్ట్ చేయాల్సిన పదాలు లేదా పదబంధాలు No matching tools సరిపోలే సాధనాలు లేవు Tool సాధనం Command కమాండ్ diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 597a0dbb9..2c9b892ed 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -172,6 +172,15 @@ Available พร้อมใช้งาน Ready พร้อมใช้ Search tools and commands… ค้นหาเครื่องมือและคำสั่ง… + +# Accessible names for inputs that have no visible label. + Added text ข้อความที่เพิ่ม + Link URL URL ของลิงก์ + Search tools and commands ค้นหาเครื่องมือและคำสั่ง + Text editing line บรรทัดแก้ไขข้อความ + Text to place ข้อความที่จะวาง + Tooltip คำแนะนำเครื่องมือ + Words or phrases to redact คำหรือวลีที่จะปิดทับ No matching tools ไม่มีเครื่องมือที่ตรงกัน Tool เครื่องมือ Command คำสั่ง diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0d07fcc76..a2ecaea17 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -142,6 +142,15 @@ Available Kullanılabilir Ready Hazır Search tools and commands… Araçları ve komutları ara… + +# Accessible names for inputs that have no visible label. + Added text Eklenen metin + Link URL Bağlantı URL'si + Search tools and commands Araçları ve komutları ara + Text editing line Metin düzenleme satırı + Text to place Yerleştirilecek metin + Tooltip İpucu + Words or phrases to redact Karartılacak sözcükler veya ifadeler No matching tools Eşleşen araç yok Tool Araç Command Komut diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7531c9a75..9c7686883 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -182,6 +182,15 @@ Available Доступно Ready Готово Search tools and commands… Пошук інструментів і команд… + +# Accessible names for inputs that have no visible label. + Added text Доданий текст + Link URL URL-адреса посилання + Search tools and commands Пошук інструментів і команд + Text editing line Рядок редагування тексту + Text to place Текст для розміщення + Tooltip Підказка + Words or phrases to redact Слова або фрази для вилучення No matching tools Відповідних інструментів немає Tool Інструмент Command Команда diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d9c375a6e..973ba870e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1057,6 +1057,15 @@ theme Appearance Giao diện Scroll tabs right Cuộn các thẻ sang phải Search comments Tìm bình luận Search tools and commands… Tìm công cụ và lệnh… + +# Accessible names for inputs that have no visible label. + Added text Văn bản đã thêm + Link URL URL của liên kết + Search tools and commands Tìm công cụ và lệnh + Text editing line Dòng chỉnh sửa văn bản + Text to place Văn bản cần đặt + Tooltip Chú giải công cụ + Words or phrases to redact Từ hoặc cụm từ cần xóa Searchable Image (Exact) Ảnh có thể tìm kiếm (Chính xác) Searching… {s} of {p} pages Đang tìm… {s} trên {p} trang Searching… {s}/{p} Đang tìm… {s}/{p} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2b1f7897..d77c8f642 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1839,6 +1839,15 @@ Tool 工具 Command 命令 Search tools and commands… 搜索工具和命令… + +# Accessible names for inputs that have no visible label. + Added text 已添加的文本 + Link URL 链接 URL + Search tools and commands 搜索工具和命令 + Text editing line 文本编辑行 + Text to place 要放置的文本 + Tooltip 提示 + Words or phrases to redact 要密文的单词或短语 No matching tools 没有匹配的工具 All tools 所有工具 Read 阅读 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ecb5ff623..ce3b939f1 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1783,6 +1783,15 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… + +# Accessible names for inputs that have no visible label. + Added text 已新增的文字 + Link URL 連結 URL + Search tools and commands 搜尋工具與指令 + Text editing line 文字編輯行 + Text to place 要放置的文字 + Tooltip 提示 + Words or phrases to redact 要遮蔽的字詞或片語 No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index bbc2712d8..84aa70657 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -286,24 +286,66 @@ pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; /// leaves 173 pt): a taller floor would push OK off the screen again. pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; +/// Whether the interface can draw a language picker's native name. On the web the browser's glyph +/// rasterizer draws scripts no embedded face has, so the native names stay; on native, ask the +/// fonts this build installed (including an installed fallback face, unless system fonts are off). +fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = (ui, text); + true + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) + } +} + +/// The date picker's displayed name. Its languages are the interface languages, so it uses the +/// same drawable-name fallback: a native name whose script this build has no face for would +/// otherwise abort under egui 0.37's `MissingGlyphPolicy::Panic` (for example Hindi without a +/// Devanagari face and installed system fonts turned off). +fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { + match crate::i18n::Lang::from_code(l.code) { + Some(language) => language.picker_name(drawable), + None => l.name, + } +} + /// Preferences: interface language and size, appearance, identity and JavaScript. Drawn in a scroll /// area; [`preferences_buttons`] goes below it, outside the scroll, so OK is always in reach. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); ui.horizontal(|ui| { - ui.label(tl!("Interface language")); - let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); + let label = ui.label(tl!("Interface language")); + // A native name is only drawn when these fonts can draw it; otherwise the English name + // keeps the list readable instead of aborting on a missing glyph (for example Hindi + // without a Devanagari face, and installed system fonts turned off). + let (selected, picker) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let selected = crate::i18n::Lang::from_code(&app.language) + .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); + let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) + .into_iter() + .map(|language| (language, language.picker_name(drawable))) + .collect::>(); + (selected, picker) + }; let before = app.language.clone(); - egui::ComboBox::from_id_salt("interface-language").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { - ui.close(); - } - for language in crate::i18n::Lang::for_picker() { - if ui.selectable_value(&mut app.language, language.code().to_string(), language.name()).clicked() { + egui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { ui.close(); } - } - }); + for (language, name) in &picker { + if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); // Relabel the rest of this dialog in the new language right away, not next frame. if app.language != before { crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); @@ -311,24 +353,28 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok }); ui.horizontal(|ui| { use crate::ui_scale; - ui.label(tl!("Interface size")); + let label = ui.label(tl!("Interface size")); // Auto shows the size it draws at: the display's scale times the system's text scale. let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - egui::ComboBox::from_id_salt("interface-size").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { - ui.close(); - } - for p in ui_scale::PRESETS { - let factor = f32::from(p) / 100.0; - if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { - app.ui_scale = Some(factor); + egui::ComboBox::from_id_salt("interface-size") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { ui.close(); } - } - }); + for p in ui_scale::PRESETS { + let factor = f32::from(p) / 100.0; + if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { + app.ui_scale = Some(factor); + ui.close(); + } + } + }) + .response + .labelled_by(label.id); }); ui.label( egui::RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")) @@ -437,38 +483,42 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // is app-wide: every open document follows it, and documents open with it. ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Replace document colours")); + let label = ui.label(tl!("Replace document colours")); let mode = app.reading_colors.mode_name(); - egui::ComboBox::from_id_salt("reading-colors").selected_text(tl!(reading_mode_label(mode))).show_ui(ui, |ui| { - for (m, label) in [ - ("off", "Off"), - ("black-on-white", "Black on white"), - ("white-on-black", "White on black"), - ("yellow-on-black", "Yellow on black"), - ("green-on-black", "Green on black"), - ("custom", "Custom"), - ] { - if ui.selectable_label(mode == m, tl!(label)).clicked() { - match m { - "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), - "custom" => { - // Custom starts from the colours already chosen, or black on white. - let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { - pdfcraft_render::ReadingColors { params: Some(params) } - }); - app.set_reading_colors(colors); - } - // `parse` knows these names; a preset comes with its own flags. - preset => { - if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + egui::ComboBox::from_id_salt("reading-colors") + .selected_text(tl!(reading_mode_label(mode))) + .show_ui(ui, |ui| { + for (m, label) in [ + ("off", "Off"), + ("black-on-white", "Black on white"), + ("white-on-black", "White on black"), + ("yellow-on-black", "Yellow on black"), + ("green-on-black", "Green on black"), + ("custom", "Custom"), + ] { + if ui.selectable_label(mode == m, tl!(label)).clicked() { + match m { + "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), + "custom" => { + // Custom starts from the colours already chosen, or black on white. + let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { + pdfcraft_render::ReadingColors { params: Some(params) } + }); app.set_reading_colors(colors); } + // `parse` knows these names; a preset comes with its own flags. + preset => { + if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + app.set_reading_colors(colors); + } + } } + ui.close(); } - ui.close(); } - } - }); + }) + .response + .labelled_by(label.id); }); if app.reading_colors.params.is_some() { let mut changed = false; @@ -588,10 +638,20 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { } ui.horizontal(|ui| { let label = ui.label(tl!("Language")); - let interface = crate::i18n::current().name(); - let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); - let current = app.session.date_language().map(str::to_string); - let selected = DATE_LANGUAGES.iter().find(|l| current.as_deref() == Some(l.code)).map_or(follow.clone(), |l| l.name.to_string()); + // Build every label before the combo box mutably borrows `ui`, with the same drawable + // fallback the interface language picker uses. + let (follow, selected, current, names) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let interface = crate::i18n::current().picker_name(drawable); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES + .iter() + .find(|l| current.as_deref() == Some(l.code)) + .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); + let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); + (follow, selected, current, names) + }; egui::ComboBox::from_id_salt("date-language") .selected_text(selected) .width(240.0) @@ -599,9 +659,8 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { ui.close(); } - for l in &DATE_LANGUAGES { - if ui.selectable_label(current.as_deref() == Some(l.code), l.name).clicked() - && app.session.set_date_language(Some(l.code)).is_ok() + for (l, name) in &names { + if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() { ui.close(); } @@ -619,3 +678,21 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .color(t.text_muted), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_language_names_fall_back_when_the_native_name_cannot_be_drawn() { + let date_language = |code: &str| pdfcraft_engine::dates::DATE_LANGUAGES.iter().find(|l| l.code == code).expect("date language"); + let hindi = date_language("hi"); + assert_eq!(date_language_picker_name(hindi, |_| true), hindi.name, "the native name is kept when it can be drawn"); + assert_eq!(date_language_picker_name(hindi, |_| false), "Hindi", "no Devanagari face: show the English name"); + let chinese = date_language("zh-hans"); + assert_eq!(date_language_picker_name(chinese, |_| false), "Simplified Chinese"); + for language in &pdfcraft_engine::dates::DATE_LANGUAGES { + assert!(!date_language_picker_name(language, |_| false).is_empty(), "{}", language.code); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6aed5dd85..d4ee683d4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,8 @@ // The settings object in `persist` is one `json!` literal past the default limit of 128. #![recursion_limit = "256"] +use crate::control::Host as _; + /// Whether `a` and `b` name the same file: equal, or equal once made absolute against the /// current directory (links are not resolved, as in `last_session`). pub(crate) fn same_file(a: &str, b: &str) -> bool { @@ -281,8 +283,8 @@ struct GridDrop { /// Dropped on the Pages panel (its gap decides where they go), not the page grid. panel: bool, files: Vec, - /// When to stop waiting for the pointer (egui time, seconds). - deadline: f64, + /// When to stop waiting for the pointer (monotonic: `logic` may run without a UI pass). + deadline: web_time::Instant, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -921,7 +923,7 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, - last_autosave: f64, + last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). @@ -1092,6 +1094,7 @@ pub enum OpenOutcome { } impl PdfCraftApp { + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { session: Session::new(), @@ -1257,7 +1260,7 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), - last_autosave: 0.0, + last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), dialog_seen: None, @@ -1723,7 +1726,7 @@ impl PdfCraftApp { // Where there is no telling where the pointer is during the drag, its place arrives // with the first move after the drop. ctx.request_repaint(); - self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: ctx.input(|i| i.time) + 1.0 }); + self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: web_time::Instant::now() + std::time::Duration::from_secs(1) }); } Vec::new() } @@ -1747,7 +1750,7 @@ impl PdfCraftApp { let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; let gap = match if panel { view.panel_gap } else { view.grid_gap } { Some(gap) => gap, - None if ctx.input(|i| i.time) >= deadline => usize::MAX, + None if web_time::Instant::now() >= deadline => usize::MAX, None => return ctx.request_repaint(), }; view.insert_at = Some(gap); @@ -3447,6 +3450,60 @@ impl PdfCraftApp { } } +/// Root-level panel salts whose 0.36 persisted state key changed in 0.37. +/// +/// The shared rail and side panes now own their state. The legacy `rail` and +/// `read_right_panel` entries are inert; the former `rail-nav` footer is no longer migrated. +const PANEL_SALTS: [&str; 4] = ["tab_strip", "mode_bar", "rail", "read_right_panel"]; + +/// Copy persisted 0.36 panel sizes to 0.37's parent-scope key, once, before the first panel. +/// +/// 0.36 stored [`egui::PanelState`] under the panel's own `Id::unique(salt)`; 0.37 derives it +/// from the parent `Ui`'s scope plus an `IdSalt`. A missing copy would reset a width the user +/// chose. The old entry is left untouched, so rolling back to 0.36 still finds it. +fn migrate_panel_state(ui: &egui::Ui) { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + return; + } + for salt in PANEL_SALTS { + migrate_panel_state_from(ui, salt); + } +} + +/// Copy one 0.36 panel's state to the key derived from this exact parent `Ui`. +/// +/// The parent must be the `Ui` the panel is shown in (for a nested panel, the panel's content +/// `Ui`), because 0.37's key includes that `Ui`'s scope. +pub(crate) fn migrate_panel_state_from(ui: &egui::Ui, salt: &str) { + let old = egui::Id::unique(salt); + let new = ui.scope_id().with_salt(egui::IdSalt::new(salt)); + if old == new || egui::PanelState::load(ui.ctx(), new).is_some() { + return; + } + if let Some(state) = egui::PanelState::load(ui.ctx(), old) { + ui.ctx().data_mut(|data| data.insert_persisted(new, state)); + log::debug!("migrated panel {salt} state {:?}", state.size()); + } +} + +impl PdfCraftApp { + /// The parts of [`eframe::App::raw_input_hook`] that must run before egui's input hooks: + /// publish this pass's input-target generation to the control plugin, and hand the wheel + /// input to the reduced-motion reader. + /// + /// A test host that drives the app without eframe's raw hook can call this first, so its + /// frame ordering matches production instead of hiding focus-change bugs. + #[doc(hidden)] + pub fn prepare_raw_input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // The control plugin's input hook runs next: give it this pass's input generation. + if let Some(control) = &self.control { + control.set_epoch(self.input_epoch()); + } + // Before egui reads this frame's wheel input. + self.scroll_speed.apply(ctx, raw); + } +} + impl eframe::App for PdfCraftApp { /// While files are dragged over the window the system sends no pointer moves, so egui /// would keep the place the pointer entered at: tell it where the pointer really is, so the @@ -3454,8 +3511,7 @@ impl eframe::App for PdfCraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // The button's release a window move or resize kept from egui. window_controls::release_after_desktop_grab(ctx, raw); - // Before egui reads this frame's wheel input. - self.scroll_speed.apply(ctx, raw); + self.prepare_raw_input(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { return; } @@ -3515,8 +3571,8 @@ impl eframe::App for PdfCraftApp { self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some(); // Also done in `raw_input_hook`, before input; here for hosts that don't call the hook. self.scroll_speed.set_line_speed(ctx); - // Before anything reads this frame's wheel input or draws, and with the line speed just set. - self.motion.begin_pass(ctx, self.animation_effects); + // Motion reads this frame's wheel input, so it begins in `ui`, in a known UI phase: + // `logic` also runs alone while hidden, where the input events and time are stale. // Before taking this frame's drop: the grid must be drawn once with the pointer where // the files were let go before the gap is read. self.finish_grid_drop(ctx); @@ -3600,9 +3656,9 @@ impl eframe::App for PdfCraftApp { } self.guard_quit(ctx); // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). + #[cfg(target_os = "macos")] craft_quit::on_close_request(ctx); - let now = ctx.input(|i| i.time); - self.autosave_tick(ctx, now); + self.autosave_tick(ctx, web_time::Instant::now()); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); @@ -3651,11 +3707,17 @@ impl eframe::App for PdfCraftApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.language)); + // A language change switches stock chrome between LTR and RTL before anything is drawn. + theme::sync_direction(&ctx); // Fonts registered via set_fonts only take effect next frame; named families would panic now. if !self.fonts_ready { ctx.request_repaint(); return; } + // Before any widget reads this frame's wheel input or draws. + self.motion.begin_pass(&ctx, self.animation_effects); + // One-time 0.36 -> 0.37 panel persistence migration, before any panel stores or reads state. + migrate_panel_state(ui); for view in &mut self.views { view.begin_render_frame(); } @@ -3688,6 +3750,10 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } return; } self.sync_window_frame(&ctx); @@ -3722,6 +3788,11 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + // Screenshots are issued only after a settled pass that really drew the window. + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } } } @@ -3751,3 +3822,75 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod panel_migration_tests { + use super::*; + + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(width, 900.0)) } + } + + /// 0.36 stored a panel's persisted size under `Id::unique(salt)`. 0.37 derives the key from + /// the root `Ui`'s scope; the upgrade must carry the chosen width over, not reset it. + #[test] + fn upgrade_from_036_panel_memory_keeps_width() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + ctx.data_mut(|d| d.insert_persisted(old, state(287.0))); + let mut migrated = None; + let mut new_id = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate_panel_state(ui); + new_id = Some(ui.scope_id().with_salt(egui::IdSalt::new("rail"))); + migrated = egui::PanelState::load(ui.ctx(), new_id.expect("an id")); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the 0.36 panel width migrates to the 0.37 key"); + assert!((migrated.size().x - 287.0).abs() <= 1.0, "width survives: {:?}", migrated.size()); + assert_eq!(new_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail"))); + assert!(egui::PanelState::load(&ctx, old).is_some(), "the old key stays for rollback"); + } + + /// A synthetic nested panel is keyed from its own parent `Ui`, not the root. + /// Migrating it at the root would silently miss its 0.36 state; the fixture uses + /// legacy rail names but does not model the current shared rail implementation. + #[test] + fn nested_panel_state_migrates_from_its_actual_parent_ui() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail-nav"); + ctx.data_mut(|d| d.insert_persisted(old, state(140.0))); + let mut nested_id = None; + let mut migrated = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Panel::right("rail").resizable(false).exact_size(48.0).show(ui, |ui| { + migrate_panel_state_from(ui, "rail-nav"); + // The key egui will use for the nested panel shown in this same Ui. + let key = ui.scope_id().with_salt(egui::IdSalt::new("rail-nav")); + nested_id = Some(key); + migrated = egui::PanelState::load(ui.ctx(), key); + egui::Panel::bottom("rail-nav").resizable(false).show(ui, |_ui| {}); + }); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the nested 0.36 state migrates to its parent-scoped key"); + assert_eq!(migrated.size().x, 140.0); + assert_ne!(nested_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail-nav")), "the nested key is not the root key"); + } + + /// The one-time copy must never overwrite state a 0.37 run has already stored. + #[test] + fn migration_does_not_replace_a_037_panel_state() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + let new = ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail")); + ctx.data_mut(|d| { + d.insert_persisted(old, state(287.0)); + d.insert_persisted(new, state(412.0)); + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| migrate_panel_state(ui)); + output.drop_without_applying_deltas(); + let kept = egui::PanelState::load(&ctx, new).expect("the 0.37 state"); + assert_eq!(kept.size().x, 412.0); + } +} diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 446de39fc..f7768912d 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -291,7 +291,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.add_enabled_ui(d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")); + ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")).accessible_name(tl!("Link URL")); }); }); widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view")); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 00dad4bd5..79670ad30 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -353,6 +353,7 @@ fn finish(state: &mut MeasureView, pending: &mut Option, doc: &Document, p pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { egui::ScrollArea::vertical().id_salt("measure-panel").show(ui, |ui| panel_body(app, ui, t)); } +/// Draw the measurement panel: tool selection, scale calibration, snapping and the current reading. fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { crate::widgets::begin_dialog(ui); let Some((index, id)) = app.active_ids() else { @@ -406,8 +407,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.label(tl!("Click the first point to begin.")); } ui.horizontal(|ui| { - ui.label(tl!("Label")); - ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)); + let label = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)).labelled_by(label.id); }); ui.separator(); ui.add(widgets::checkbox(&mut state.snap_enabled, tl!("Snap to drawing"))); @@ -429,7 +430,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.horizontal(|ui| { ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); - typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Scale"))); state.ratio_dirty |= typed.changed(); if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { match parse_ratio(&state.ratio_text) { @@ -459,8 +460,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::DragValue::new(&mut state.real_distance).range(0.000001..=1e12)); }); ui.horizontal(|ui| { - ui.label(tl!("Unit")); - ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); + let unit = ui.label(tl!("Unit")); + ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)).labelled_by(unit.id); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. @@ -480,8 +481,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } ui.horizontal(|ui| { - ui.label(tl!("Viewport")); - ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)); + let viewport = ui.label(tl!("Viewport")); + ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)).labelled_by(viewport.id); }); let apply = ui.add_enabled(doc.allows_annotation(), egui::Button::new(tl!("Apply scale"))).clicked(); if let Some(error) = &state.error { diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index 934fc0618..d95d205b0 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -88,11 +88,19 @@ pub struct Motion { /// egui's own animation time and scroll animation, kept while they are changed here. egui_times: Option<(f32, ScrollAnimation)>, wheel: WheelReader, + /// This frame's wheel motion, read on its first pass and reused on later passes. + /// + /// egui empties `RawInput` between the passes of a multi-pass frame, so reading the wheel + /// again in a later pass sees nothing. Only the first pass is handed the motion (a scroll + /// area keeps the offset it moved to, a view keeps the zoom it set); later passes are handed + /// no scroll and a unit zoom, explicitly. + frame: Option<(u64, WheelMotion)>, } impl Default for Motion { + /// Start motion handling with animations enabled and no desktop preference sampled yet. fn default() -> Self { - Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default() } + Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default(), frame: None } } } @@ -114,13 +122,36 @@ impl Motion { if animate { // A gesture begun while easing is egui's to finish. self.wheel = WheelReader::default(); + self.frame = None; return; } let options = ctx.options(|o| o.input_options); - let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); - ctx.input_mut(|i| i.smooth_scroll_delta = motion.scroll); + // One frame may run several passes (`request_discard`). Only the first pass has this + // frame's events, and only it may hand the wheel on: a scroll area applies the delta to + // its persisted offset, and a view multiplies the zoom into its persisted zoom, so + // replaying either would apply it twice. egui also rebuilds `smooth_scroll_delta` and + // its zoom factor from its retained wheel state every pass, so later passes must be + // given zero scroll and a unit zoom explicitly. A missing zoom entry would not do: it + // would fall back to egui's rebuilt factor. + let frame = ctx.cumulative_frame_nr(); + let first_pass = match self.frame { + Some((read_in, _)) => read_in != frame, + None => true, + }; + let motion = match self.frame { + Some((read_in, motion)) if read_in == frame => motion, + _ => { + let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); + self.frame = Some((frame, motion)); + motion + } + }; + let (scroll, zoom) = if first_pass { (motion.scroll, motion.zoom) } else { (Vec2::ZERO, 1.0) }; + ctx.input_mut(|i| i.smooth_scroll_delta = scroll); let pass = ctx.cumulative_pass_nr(); - ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, motion.zoom))); + ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, zoom))); + // Existing kinetic scrolling remains owned by egui; gating wheel input does not + // suppress a scroll area's per-pass advancement of retained velocity. } /// egui's animations, and its scrolling to a place, take no time unless the window is to @@ -148,8 +179,9 @@ pub(crate) fn animated(ctx: &egui::Context) -> bool { ctx.global_style().animation_time > 0.0 } +/// Return the stable egui id holding the last wheel-zoom input state. fn zoom_id() -> egui::Id { - egui::Id::new("pdfcraft-motion-wheel-zoom") + egui::Id::unique("pdfcraft-motion-wheel-zoom") } /// This pass's zoom factor from a pinch or ⌘/Ctrl with the wheel (1.0 for none): egui's @@ -194,7 +226,7 @@ impl WheelReader { let mut modifiers = self.touch.unwrap_or_default(); for event in events { match event { - Event::MouseWheel { unit, delta: step, phase, modifiers: latest } => match phase { + Event::MouseWheel { unit, delta: step, phase, modifiers: latest, .. } => match phase { TouchPhase::Start => { self.touch = Some(*latest); modifiers = *latest; @@ -256,7 +288,7 @@ mod tests { use std::rc::Rc; fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { - Event::MouseWheel { unit, delta, phase, modifiers } + Event::MouseWheel { unit, delta, phase, modifiers, source: egui::MouseWheelSource::Unknown } } fn notch(dy: f32, modifiers: Modifiers) -> Event { @@ -507,6 +539,104 @@ mod tests { }); } + /// With animation effects off, a real scroll area moves by one wheel step whether the frame + /// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already + /// applied and stored the step in the first pass, so a later pass must not apply it again, + /// and must not replay the frame's raw wheel events either. + #[test] + fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() { + fn run(two_passes: bool) -> (Vec, Vec) { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut offsets = Vec::new(); + let mut wheel_events = Vec::new(); + let raw = egui::RawInput { + time: Some(0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)], + ..Default::default() + }; + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count())); + // Taller than the 300-point viewport, so one step stays far from the scroll + // limit and a doubled step could not be hidden by clamping. + let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.set_min_size(egui::vec2(100.0, 3000.0)); + }); + offsets.push(out.state.unclamped_offset()); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass scroll regression"); + } + }); + output.drop_without_applying_deltas(); + (offsets, wheel_events) + } + + let (single, single_events) = run(false); + let (two, two_events) = run(true); + + assert_eq!(single.len(), 1, "one frame, one pass"); + assert_eq!(two.len(), 2, "the frame really ran two passes"); + assert_eq!(single_events, vec![1], "one wheel event reaches the first pass"); + assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass"); + + let step = single[0].y; + assert!(step > 0.0, "the wheel step scrolled: {single:?}"); + assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}"); + assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}"); + } + + /// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom + /// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass): + /// multiplying an accumulator by each pass's factor gives the single step either way. A + /// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it. + #[test] + fn two_pass_zoom_applies_one_step_like_a_single_pass() { + /// One frame's `zoom_delta` readings, one per pass. + fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec, two_passes: bool) -> Vec { + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events, + ..Default::default() + }; + let mut zooms = Vec::new(); + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + zooms.push(zoom_delta(ui.ctx())); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass zoom regression"); + } + }); + output.drop_without_applying_deltas(); + zooms + } + + let line = egui::InputOptions::default().line_scroll_speed; + let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] { + assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}"); + let single = { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + frame(&ctx, &mut motion, 0.1, events.clone(), false) + }; + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let two = frame(&ctx, &mut motion, 0.1, events, true); + assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}"); + assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}"); + let product = |zooms: &[f32]| zooms.iter().product::(); + assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}"); + assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}"); + assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}"); + // The next frame's wheel reading starts over; the two-pass frame's factor is gone. + let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false); + assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame"); + } + } + #[test] fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 8d7d64e47..d495116c6 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -36,6 +36,12 @@ struct Gesture { } impl ObjectSelection { + /// Clear any object-selection gesture and the page it was blocking. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.gesture = None; + self.blocked_page = None; + } + pub fn count(&self) -> usize { self.selected.len() } diff --git a/crates/ui-egui/src/organize_panel.rs b/crates/ui-egui/src/organize_panel.rs index b02d11801..338f64bc1 100644 --- a/crates/ui-egui/src/organize_panel.rs +++ b/crates/ui-egui/src/organize_panel.rs @@ -195,7 +195,7 @@ enum Availability { fn row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability, menu: bool) -> egui::Response { let ready = availability == Availability::Ready && ui.is_enabled(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), if ready { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ready, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ready, label)); let press = theme::Press::track(ui, &resp); if ready { press.wash(ui, rect, 6, false); diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index ebb81ab5f..a6a318c1f 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -144,7 +144,7 @@ mod tests { let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); seen = PagePointer::read(ui, &resp, modal); if area { - egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { ui.allocate_exact_size(OVER.size(), Sense::hover()); }); } diff --git a/crates/ui-egui/src/page_selection_ui.rs b/crates/ui-egui/src/page_selection_ui.rs index 0907232f7..eb97b16b0 100644 --- a/crates/ui-egui/src/page_selection_ui.rs +++ b/crates/ui-egui/src/page_selection_ui.rs @@ -96,7 +96,7 @@ impl PageSelectionInput { .char_limit(16_384) .hint_text(current.saturating_add(1).to_string()), ); - field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label)); + field.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), label)); commit = ui.is_enabled() && !cancel && field.lost_focus(); let pick = crate::icons::button(ui, "chevron-down", 26.0, false, tl!("Select pages")); chosen = egui::Popup::menu(&pick).show(menu).and_then(|r| r.inner); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 4f4197927..99174a4eb 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -19,6 +19,11 @@ struct Hit { /// Where `needle` (folded with [`pdfcraft_render::text::fold_case`]) matches `hay`, for ranking. fn score(hay: &str, needle: &str) -> Option { + score_for_direction(hay, needle, crate::i18n::current().rtl()) +} + +/// Rank logical text, accepting reordered query words for right-to-left locales. +fn score_for_direction(hay: &str, needle: &str, rtl: bool) -> Option { if needle.is_empty() { return Some(0); } @@ -26,10 +31,9 @@ fn score(hay: &str, needle: &str) -> Option { if let Some(p) = h.find(needle) { return Some(p); } - // A right-to-left language's labels are in display order (their words run the other way) - // while the query is in typing order, so several words match one by one. - if crate::i18n::current().rtl() && needle.contains(char::is_whitespace) { - let first = needle.split_whitespace().map(|word| h.find(word)).try_fold(usize::MAX, |first, p| p.map(|p| first.min(p))); + // Match the all-tools search: RTL queries may name the same words in another order. + if rtl && needle.contains(char::is_whitespace) { + let first = needle.split_whitespace().try_fold(usize::MAX, |first, word| h.find(word).map(|at| first.min(at))); if let Some(first) = first { return Some(first); } @@ -39,8 +43,9 @@ fn score(hay: &str, needle: &str) -> Option { needle.chars().all(|c| it.any(|x| x == c)).then_some(100) } +/// Draw the command palette and dispatch the chosen command or tool group. pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { - let palette_rect_id = egui::Id::new("palette_rect"); + let palette_rect_id = egui::Id::unique("palette_rect"); if !app.palette_open { return; } @@ -111,8 +116,8 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { hits.truncate(12); let (down, up) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp))); let selected = ctx.data_mut(|data| { - let id = egui::Id::new("palette_selected"); - let query_id = egui::Id::new("palette_selected_query"); + let id = egui::Id::unique("palette_selected"); + let query_id = egui::Id::unique("palette_selected_query"); let previous_query = data.get_temp::(query_id); let selected = if previous_query.as_deref() == Some(&q) { data.get_temp::(id).unwrap_or(0) } else { 0 }.min(hits.len().saturating_sub(1)); @@ -143,7 +148,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } let mut chosen: Option<(Option<&'static str>, Option<&'static str>)> = None; - let area_response = egui::Area::new(egui::Id::new("palette")) + let area_response = egui::Area::new(egui::Id::unique("palette")) .order(egui::Order::Foreground) .pivot(Align2::CENTER_TOP) .fixed_pos(egui::pos2(screen.center().x, screen.top() + 96.0)) @@ -152,13 +157,15 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(560.0); ui.horizontal(|ui| { ui.add(icons::image("search", 18.0, t.text_muted)); - let r = ui.add( - egui::TextEdit::singleline(&mut app.palette_query) - .hint_text(tl!("Search tools and commands…")) - .frame(egui::Frame::NONE) - .font(theme::regular(15.0)) - .desired_width(f32::INFINITY), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut app.palette_query) + .hint_text(tl!("Search tools and commands…")) + .frame(egui::Frame::NONE) + .font(theme::regular(15.0)) + .desired_width(f32::INFINITY), + ) + .accessible_name(tl!("Search tools and commands")); // Enter runs the selected hit while the field is focused or relinquishing focus. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -181,7 +188,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // Display is translated; matching above already considered both languages. let shown = crate::i18n::command_label(&h.label); let shown_detail = tl!(&h.detail).to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &shown)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, h.ready, &shown)); let fg = if h.ready { t.text } else { t.text_faint }; let detail_font = theme::regular(12.0); let detail_max = (rect.width() * 0.42).clamp(0.0, 220.0); @@ -226,9 +233,20 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { #[cfg(test)] mod search { - use super::score; + use super::{score, score_for_direction}; use pdfcraft_render::text::fold_case; + /// Word-order tolerance is RTL-only and still requires every requested word. + #[test] + fn rtl_palette_queries_accept_reordered_words_without_reversing_text() { + let label = "تنظيم الصفحات"; + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("تنظيم الصفحات"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("الصفحات مفقود"), true), None); + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), false), None); + assert_eq!(score_for_direction("Organize Pages", &fold_case("Pages Organize"), false), None); + } + #[test] fn commands_match_in_any_case_with_turkish_i() { assert_eq!(score("İçe aktar", &fold_case("içe")), Some(0)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 535ba2e08..bcc4123f2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -108,9 +108,10 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, elided } +/// Draw one tool-group row with its icon, label, badge and availability hint. fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -155,6 +156,7 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { theme::hand(resp).on_hover_text(tip) } +/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels. fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { if g.id == "edit" { edit_panel(app, ui, t, g); @@ -215,7 +217,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static continue; } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(item.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(item.label))); let ready = item.availability == Availability::Ready; let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); @@ -363,7 +365,7 @@ fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static widgets::section_title(ui, tl!("Add content")); let content: Vec<_> = g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); - let more_id = egui::Id::new("edit_panel_more"); + let more_id = egui::Id::unique("edit_panel_more"); let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; for i in content.iter().take(shown) { @@ -436,7 +438,7 @@ fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: & /// item is muted and says when it ships when hovered. fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -467,7 +469,7 @@ fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { for kind in StampKind::ALL.into_iter().filter(|k| k.group() == group) { let active = app.quick_tool == crate::QuickTool::Stamp(kind); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, kind.label())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, kind.label())); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, active); let body = rect.translate(press.offset()); @@ -654,6 +656,7 @@ pub(crate) enum Nav { Flash(usize, [f32; 4]), } +/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -787,7 +790,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); - let search_id = egui::Id::new(("bookmark_search", id.0)); + let search_id = egui::Id::unique(("bookmark_search", id.0)); let mut bookmark_query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); if panel == RightPanel::Bookmarks && !info.outline.is_empty() { ui.horizontal(|ui| { @@ -863,7 +866,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } for (li, l) in info.layers.iter().enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, l.visible, &l.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.visible, &l.name)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -1196,13 +1199,14 @@ fn title_format(item: &OutlineItem, depth: usize, t: &Tokens) -> egui::TextForma egui::TextFormat { font_id, color, italics: item.italic, ..Default::default() } } +/// Draw one bookmark row with expansion, inline rename and the editable context menu. fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { if cx.searching && !cx.matches.contains(path) { return; } let depth = path.len() - 1; let indent = depth as f32 * 16.0; - let id = ui.id().with(("outline", path)); + let id = ui.scope_id().with(("outline", path)); if let Some(levels) = cx.expand { ui.data_mut(|d| d.insert_temp(id, depth < levels)); } @@ -1258,7 +1262,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte let galley = ui.fonts_mut(|f| f.layout_job(job)); let h = (galley.size().y + 12.0).max(28.0); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &item.title)); theme::Press::track(ui, &resp).wash(ui, rect, 6, false); let x0 = rect.left() + indent; if !item.children.is_empty() { @@ -1478,7 +1482,7 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie } let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); let picked = view.selected.contains(&i); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, picked, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, picked, info.clone())); // The current page keeps its heavier border; picked pages share its fill. let selected = i == view.current; let press = theme::Press::track(ui, &resp); @@ -1722,7 +1726,7 @@ fn fields( FieldKind::Unknown => "square", }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &f.name)); // A row scrolled out of view still takes clicks and keyboard focus, but isn't drawn: // laying out every row of a large form each frame took a tenth of a second a frame. let visible = ui.is_rect_visible(rect); @@ -1749,8 +1753,8 @@ fn fields( for (r, icon, earlier, tip) in [(up, "chevron-up", true, tl!("Earlier in tab order")), (down, "chevron-down", false, tl!("Later in tab order"))] { - let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click()); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name))); + let b = ui.interact(r, ui.scope_id().with((&f.name, earlier)), Sense::click()); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 9f58f5ea5..263569367 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -151,6 +151,13 @@ pub struct PrepareView { } impl PrepareView { + /// Cancel a preparation marquee without injecting the terminal pointer release that Escape does. + pub(crate) fn cancel_pointer_gesture(&mut self) { + cancel_marquee(self); + // Unlike Escape, control cancellation never injects a terminal pointer release. + self.ignore_release = false; + } + /// Unique field names: selecting several widgets of one radio group still edits one field. pub fn names(&self) -> Vec { let mut names = Vec::new(); @@ -1371,6 +1378,7 @@ const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1' const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; +/// Draw the field-format editor for the selected category and its options. fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { let cat = category(&d.format); let mut picked = cat; @@ -1483,8 +1491,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { ui.selectable_value(p, pr.to_string(), pr); } }); - ui.label(tl!("Custom:")); - ui.add(crate::widgets::line(p).desired_width(120.0)); + let custom = ui.label(tl!("Custom:")); + ui.add(crate::widgets::line(p).desired_width(120.0)).labelled_by(custom.id); }); } Format::Special(n) => { @@ -1508,8 +1516,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } }); ui.horizontal(|ui| { - ui.label(tl!("Mask:")); - ui.add(crate::widgets::line(m).desired_width(160.0)); + let mask = ui.label(tl!("Mask:")); + ui.add(crate::widgets::line(m).desired_width(160.0)).labelled_by(mask.id); }); ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); if idx < 4 { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index fcc8d89d9..5bf8737fc 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -300,7 +300,7 @@ pub(crate) struct PrintTasks { fn listed_printers() -> Vec { #[cfg(target_arch = "wasm32")] { - vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None }] + vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None, description: String::new() }] } #[cfg(not(target_arch = "wasm32"))] { @@ -894,15 +894,20 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { } egui::Grid::new(("driver-group", n)).num_columns(2).spacing([10.0, 4.0]).show(ui, |ui| { for o in chunk { - ui.label(&o.label); let current = choices.get(&o.key).cloned().unwrap_or_else(|| o.default.clone()); let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); let mut pick = current.clone(); - egui::ComboBox::from_id_salt(("driver-option", &o.key)).selected_text(label_of(¤t)).width(200.0).show_ui(ui, |ui| { - for (k, l) in &o.choices { - ui.selectable_value(&mut pick, k.clone(), l.as_str()); - } - }); + let option_label = ui.label(&o.label); + egui::ComboBox::from_id_salt(("driver-option", &o.key)) + .selected_text(label_of(¤t)) + .width(200.0) + .show_ui(ui, |ui| { + for (k, l) in &o.choices { + ui.selectable_value(&mut pick, k.clone(), l.as_str()); + } + }) + .response + .labelled_by(option_label.id); if pick != current { if pick == o.default { choices.remove(&o.key); @@ -918,13 +923,19 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { }); } -fn combo(ui: &mut egui::Ui, id: &str, value: &mut T, choices: &[(T, &str)], width: f32) { +/// Draw a combo box whose accessible name does not depend on the selected value. +fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { - for (v, label) in choices { - ui.selectable_value(value, *v, *label); - } - }); + egui::ComboBox::from_id_salt(id) + .selected_text(shown) + .width(width) + .show_ui(ui, |ui| { + for (v, label) in choices { + ui.selectable_value(value, *v, *label); + } + }) + .response + .accessible_name(name); } /// Draw the dialog. `thumb` gives a page's thumbnail texture when there is one. Returns @@ -948,7 +959,7 @@ pub(crate) fn body( ui.vertical(|ui| { ui.set_width(470.0); egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Printer:")); + let printer_caption = ui.label(tl!("Printer:")); let before = d.printer.clone(); // The browser's print dialog sets copies, two-sided printing and colour itself. let browser = d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER); @@ -976,7 +987,9 @@ pub(crate) fn body( if ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")).clicked() { d.printer_picked = true; } - }); + }) + .response + .labelled_by(printer_caption.id); let tip = if spool::HAS_PRINTER_PREFERENCES { tl!("Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply.") } else { @@ -1017,10 +1030,10 @@ pub(crate) fn body( ); ui.end_row(); } - ui.label(tl!("Copies:")); + let copies_label = ui.label(tl!("Copies:")); ui.add_enabled_ui(!browser, |ui| { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); + ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)).labelled_by(copies_label.id); ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); }); @@ -1034,6 +1047,7 @@ pub(crate) fn body( combo( ui, "duplex", + tl!("Two-sided:"), &mut d.duplex, &[ (None, tl!("Printer default")), @@ -1046,14 +1060,16 @@ pub(crate) fn body( }); ui.end_row(); } - ui.label(tl!("Paper:")); + let paper_label = ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, tl!(name)); } - }); + }) + .response + .labelled_by(paper_label.id); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -1104,11 +1120,11 @@ pub(crate) fn body( ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.which, Which::All, tl!("All")); widgets::radio_value(ui, &mut d.which, Which::Current, tl!("Current page")); - widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); + let pages = widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); let r = ui.add_enabled( d.which == Which::Range, crate::widgets::line(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), - ); + ).labelled_by(pages.id); if r.gained_focus() { d.which = Which::Range; } @@ -1123,6 +1139,7 @@ pub(crate) fn body( combo( ui, "subset", + tl!("More options:"), &mut d.subset, &[(Subset::All, tl!("All pages in range")), (Subset::Odd, tl!("Odd pages only")), (Subset::Even, tl!("Even pages only"))], 150.0, @@ -1157,7 +1174,8 @@ pub(crate) fn body( if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } - ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); + ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")) + .accessible_name(tl!("Custom scale")); }); } Handling::Label => { @@ -1170,21 +1188,22 @@ pub(crate) fn body( } Handling::Poster => { ui.horizontal(|ui| { - ui.label(tl!("Tile scale:")); - ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); - ui.label(tl!("Overlap:")); - ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); + let scale_label = ui.label(tl!("Tile scale:")); + ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")).labelled_by(scale_label.id); + let overlap_label = ui.label(tl!("Overlap:")); + ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")).labelled_by(overlap_label.id); ui.add(widgets::checkbox(&mut d.cut_marks, tl!("Cut marks"))); }); } Handling::Multiple => { ui.horizontal(|ui| { ui.label(tl!("Pages per sheet:")); - combo(ui, "per-sheet", &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); + combo(ui, "per-sheet", tl!("Pages per sheet:"), &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); ui.label(tl!("Page order:")); combo( ui, "order", + tl!("Page order:"), &mut d.order, &[ (PageOrder::Horizontal, tl!("Horizontal")), @@ -1212,6 +1231,7 @@ pub(crate) fn body( combo( ui, "booklet", + tl!("Booklet subset:"), &mut d.booklet_subset, &[ (BookletSubset::BothSides, tl!("Both sides")), @@ -1221,7 +1241,7 @@ pub(crate) fn body( 130.0, ); ui.label(tl!("Binding:")); - combo(ui, "binding", &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); + combo(ui, "binding", tl!("Binding:"), &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); }); } } @@ -1237,6 +1257,7 @@ pub(crate) fn body( combo( ui, "content", + tl!("Comments & Forms"), &mut d.content, &[ (Content::Document, tl!("Document")), @@ -1257,7 +1278,7 @@ pub(crate) fn body( ui.add_enabled_ui(d.as_image, |ui| { ui.label(tl!("Resolution:")); let mut dpi = d.dpi.round() as u32; - combo(ui, "print-dpi", &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); + combo(ui, "print-dpi", tl!("Resolution:"), &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); d.dpi = f64::from(dpi); }); }); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index fb48c369d..959877de6 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -86,7 +86,7 @@ fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response } ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(label), theme::regular(14.0), t.text); let info = tl!(label).to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, info.clone())); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 128e57604..71759c459 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -311,22 +311,32 @@ impl PdfCraftApp { } } - /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { + /// Autosave when the interval has passed (called by `logic`, which also runs while hidden). + /// + /// The deadline is monotonic (`web_time::Instant`): egui's input clock does not advance + /// during a hidden `run_logic`, so using it would stall autosave or restart its interval. + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: web_time::Instant) { if self.recovery.is_none() { return; } - if now - self.last_autosave >= AUTOSAVE_SECS { - self.last_autosave = now; + // The first tick starts the interval, as the old zero clock did: it does not snapshot + // immediately just because no deadline had been recorded yet. + let Some(last) = self.last_autosave else { + self.last_autosave = Some(now); + if self.session.has_pending_autosave() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECS)); + } + return; + }; + if now.duration_since(last).as_secs_f64() >= AUTOSAVE_SECS { + self.last_autosave = Some(now); self.autosave_now(); } // An idle native window has no input-driven frames. Wake it at the existing // autosave deadline, without keeping clean or already-snapshotted documents awake. if self.session.has_pending_autosave() { - let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); - if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { - ctx.request_repaint_after(delay); - } + let next = self.last_autosave.unwrap_or(now) + std::time::Duration::from_secs_f64(AUTOSAVE_SECS); + ctx.request_repaint_after(next.saturating_duration_since(now)); } } @@ -469,8 +479,8 @@ mod idle_tests { assert_eq!(distinct.len(), 200); } - fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { - let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: web_time::Instant) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { app.autosave_tick(ui.ctx(), now); }); // Headless timer test: acknowledge texture uploads without a GPU backend. @@ -478,6 +488,8 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops + /// waking the window. #[test] fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { let dir = TestDir::new(); @@ -487,22 +499,26 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); assert!(store.list().is_empty()); - frame(&mut app, &ctx, 60.0); + frame(&mut app, &ctx, base + Duration::from_secs(60)); assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); } + /// Check that an edit after autosave schedules the next deadline, keeps the old snapshot until then, and + /// replaces it on time. #[test] fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { let dir = TestDir::new(); @@ -512,7 +528,9 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); - frame(&mut app, &ctx, 60.0); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); + frame(&mut app, &ctx, base + Duration::from_secs(60)); let entries = store.list(); assert_eq!(entries.len(), 1); let key = entries[0].key.clone(); @@ -520,20 +538,20 @@ mod idle_tests { assert!(!app.session.has_pending_autosave()); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX); app.session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second edit".into() }).unwrap(); assert!(app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 75.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(75)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "second edit wakes by 120 seconds: {delay:?}"); assert_eq!(store.read(&key).unwrap(), first, "editing does not overwrite the snapshot before its deadline"); - frame(&mut app, &ctx, 119.0); + frame(&mut app, &ctx, base + Duration::from_secs(119)); assert_eq!(store.read(&key).unwrap(), first); - frame(&mut app, &ctx, 120.0); + frame(&mut app, &ctx, base + Duration::from_secs(120)); let entries = store.list(); assert_eq!(entries.len(), 1, "the same document replaces its existing snapshot"); assert_eq!(entries[0].key, key); @@ -545,11 +563,12 @@ mod idle_tests { assert!(app.session.get(id).unwrap().dirty); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 121.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(121)); } assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); } + /// Check that a clean document and a document with recovery disabled never schedule an autosave. #[test] fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { for recovery_enabled in [false, true] { @@ -565,9 +584,10 @@ mod idle_tests { app.session.open_new("Unsaved.pdf", bytes).unwrap(); } let ctx = egui::Context::default(); + let base = web_time::Instant::now(); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert_eq!(delay, Duration::MAX); } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 8a48b3f69..6a5bee7ea 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -388,7 +388,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> widgets::radio_value(ui, &mut d.mode, SearchMode::Phrase, tl!("Single word or phrase")); let mut enter = false; ui.add_enabled_ui(d.mode == SearchMode::Phrase, |ui| { - let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)); + let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)).accessible_name(tl!("Text to find")); enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); ui.add_space(6.0); @@ -396,7 +396,8 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> let mut import = false; ui.add_enabled_ui(d.mode == SearchMode::Words, |ui| { egui::ScrollArea::vertical().id_salt("redact-words").max_height(120.0).show(ui, |ui| { - ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)); + ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)) + .accessible_name(tl!("Words or phrases to redact")); }); ui.horizontal(|ui| { import = ui.button(tl!("Import list…")).clicked(); diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index e5a2b453f..ebb6a34c4 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -90,7 +90,13 @@ mod tests { fn wheel(unit: egui::MouseWheelUnit, dy: f32, command: bool) -> egui::Event { let modifiers = egui::Modifiers { command, ..egui::Modifiers::NONE }; - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + } } #[test] diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index 630ca898d..85a9b4605 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -11,6 +11,7 @@ use crate::widgets; /// Glyphs of context shown on each side of a match. const CONTEXT: usize = 36; +/// Draw the Search panel with its query, options, progress and clickable result excerpts. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: usize) { crate::widgets::begin_dialog(ui); let searched = view.texts.len() + view.text_failed.len(); @@ -18,7 +19,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us find.in_panel = true; let l = ui.label(egui::RichText::new(tl!("What word or phrase would you like to search for?")).color(t.text_muted)); let r = ui - .add(crate::widgets::line(&mut find.query).id(egui::Id::new("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .add(crate::widgets::line(&mut find.query).id(egui::Id::unique("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .accessible_name(tl!("Search")) .labelled_by(l.id); if find.focus { r.request_focus(); diff --git a/crates/ui-egui/src/side_panels.rs b/crates/ui-egui/src/side_panels.rs index 743882e61..71c3baa64 100644 --- a/crates/ui-egui/src/side_panels.rs +++ b/crates/ui-egui/src/side_panels.rs @@ -322,7 +322,7 @@ impl PaneContent for Content<'_> { match pane { Pane::Tools => crate::panels::left_panel(self.app, ui), Pane::Side => { - ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect())); + ui.data_mut(|data| data.insert_temp(egui::Id::unique("pdfcraft-inspector-body"), ui.max_rect())); crate::panels::right_panel(self.app, ui); } } @@ -389,7 +389,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; let panels = app.side_panels; // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. - let output = PaneHost::new("pdfcraft-side-panels") + let output = PaneHost::new(egui::Id::unique("pdfcraft-side-panels")) .policy(SidePolicy::Columns) .f6(false) .labels(labels) diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 6d09115b7..8f6b2c4a0 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -678,6 +678,7 @@ fn error(ui: &mut egui::Ui, err: &Option) { } } +/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { title(ui, "Sign with a Digital ID"); ui.label(tl!("Choose the digital ID that you want to use for signing:")); @@ -690,7 +691,7 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let selected = d.selected == Some(i); let usable = e.unusable.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, usable, selected, &e.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name)); let fill = if selected { t.accent_soft } else if resp.hovered() && usable { diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 383dc1e7a..971683887 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -503,6 +503,8 @@ mod tests { std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") } + /// Check that the first readable fallback candidate wins, so reversing the preference list reverses the + /// chosen face. #[test] fn the_first_readable_candidate_wins_not_the_last() { // List order *is* the preference, so a loader that scanned back-to-front would pick a @@ -517,9 +519,9 @@ mod tests { let size = |p: &Path| std::fs::metadata(p).expect("bundled face").len(); assert_ne!(size(&inter), size(&mono), "the two fixtures must be tellable apart"); let first = script_fallback(&[inter.clone(), mono.clone()], 'A').expect("Inter covers 'A'"); - assert_eq!(first.font.len() as u64, size(&inter), "the first readable candidate must win"); + assert_eq!(first.bytes().len() as u64, size(&inter), "the first readable candidate must win"); let reversed = script_fallback(&[mono.clone(), inter], 'A').expect("JetBrains Mono covers 'A'"); - assert_eq!(reversed.font.len() as u64, size(&mono), "reversing the list must reverse the pick"); + assert_eq!(reversed.bytes().len() as u64, size(&mono), "reversing the list must reverse the pick"); } #[test] @@ -663,7 +665,7 @@ mod tests { // them, as this list produces. let direct = script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).expect("the list above resolves"); let cached = devanagari_fallback().expect("devanagari_fallback() resolves where these faces are installed"); - assert_eq!(cached.font.len(), direct.font.len(), "devanagari_fallback() must load the face this list points at, not another script's"); + assert_eq!(cached.bytes().len(), direct.bytes().len(), "devanagari_fallback() must load the face this list points at, not another script's"); assert_eq!(cached.index, direct.index, "and the same face within that file"); // The advertised last resort is the biggest file on the list and reaches its target through // a compatibility symlink in `/Library/Fonts`, a folder installers and users may modify — so diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 442cd6d8e..d9ba466b4 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting}; +use egui::epaint::text::{FallbackAlign, FontTweak, HintingTarget, SmoothHinting}; use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -122,8 +122,9 @@ impl Tokens { } } + /// Return the cached theme tokens, falling back to the light palette before the first apply. pub fn get(ctx: &egui::Context) -> Self { - ctx.data(|d| d.get_temp::(egui::Id::new("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) + ctx.data(|d| d.get_temp::(egui::Id::unique("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) } pub fn dark(&self) -> bool { @@ -230,14 +231,34 @@ pub const SYSTEM_HEBREW_FALLBACK: &str = "system-hebrew-fallback"; /// The name of the installed Hangul (Korean) face [`installed_font_definitions`] may add. pub const SYSTEM_HANGUL_FALLBACK: &str = "system-hangul-fallback"; +/// Which installed face an interface language needs, given whether the build embeds a `Hans` +/// face. +/// +/// A bundled `Hans` face serves Simplified Chinese, so the installed face stays last there. It +/// cannot serve Traditional Chinese: a Traditional interface keeps an installed `Hant` face +/// before the Japanese faces even when a Simplified-only face is embedded, or Traditional-only +/// characters would fall through to a Japanese face first. +#[cfg(not(target_arch = "wasm32"))] +fn installed_fallback_need(cjk: CjkPreference, has_embedded_hans: bool) -> crate::system_fonts::Need { + use crate::system_fonts::Need; + match cjk { + CjkPreference::SimplifiedChinese if has_embedded_hans => Need::Other, + CjkPreference::SimplifiedChinese => Need::Hans, + CjkPreference::TraditionalChinese => Need::Hant, + CjkPreference::Default => Need::Other, + } +} + /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. /// -/// - In a Chinese interface built without a `Hans` craft-fonts face (every release so far, -/// #826), it is a Chinese system face (Microsoft YaHei, PingFang, …), placed before the -/// Japanese craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), -/// and one line drawn from two faces with different vertical metrics would not sit on one -/// baseline. +/// - In a Simplified Chinese interface built without an embedded `Hans` face (#826), it is a +/// Simplified Chinese system face (Microsoft YaHei, PingFang SC, …), placed before the Japanese +/// craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), and one +/// line drawn from two faces with different vertical metrics would not sit on one baseline. +/// - A Traditional Chinese interface always uses an installed `Hant` face before the Japanese +/// faces, even when a Simplified-only `Hans` face is embedded: that face cannot supply +/// Traditional-only characters (說, 體, …). /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// @@ -268,12 +289,7 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { stack.push(name.to_owned()); } } - let need = match cjk { - _ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other, - CjkPreference::SimplifiedChinese => Need::Hans, - CjkPreference::TraditionalChinese => Need::Hant, - CjkPreference::Default => Need::Other, - }; + let need = installed_fallback_need(cjk, !pdfcraft_fonts::ui_chinese_fonts().is_empty()); if let Some(data) = fallback(need) { fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data); let japanese: Vec = pdfcraft_fonts::ui_japanese_fonts().iter().map(|f| f.name()).collect(); @@ -323,37 +339,14 @@ fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak { tweak } -/// A face's vertical metrics in em, as egui reads them: skrifa's ascent, descent (negative) and -/// line gap at the default location. `None` when the face doesn't parse. -fn vertical_metrics(bytes: &[u8], index: u32) -> Option<[f32; 3]> { - use skrifa::MetadataProvider as _; - let font = skrifa::FontRef::from_index(bytes, index).ok()?; - let m = font.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let em = f32::from(m.units_per_em); - let metrics = [m.ascent / em, m.descent / em, m.leading / em]; - (em > 0.0 && metrics.iter().all(|v| v.is_finite())).then_some(metrics) -} - -/// How far below the primary face's baseline egui draws a fallback face's, in em of the font size -/// (negative: above). egui centres the fallback's line box (ascent − descent + gap) on the -/// primary's rather than lining up their baselines, so the baseline moves by half the difference -/// of ascent + descent, less half the difference of the line gaps (epaint `text_layout`). -fn baseline_drop(primary: [f32; 3], fallback: [f32; 3]) -> f32 { - let [pa, pd, pg] = primary; - let [a, d, g] = fallback; - ((a + d) - (pa + pd) + pg - g) / 2.0 -} - -/// `data` with its glyphs moved onto Inter's baseline (#1676). Without this, Chinese next to Latin -/// in one label sits higher or lower than the Latin by a few percent of the font size, depending on -/// the installed Chinese face (PingFang, Microsoft JhengHei, SimSun…). Inter is the first face of -/// every proportional family; the monospace family's JetBrains Mono differs from it by 0.004 em, -/// well under a pixel. A face that doesn't parse is left as it is (egui will skip it anyway). +/// `data` with its glyphs placed on the row's baseline (#1676). epaint 0.37 otherwise centres a +/// fallback face's line box on the primary's, so Chinese next to Latin in one label sits higher or +/// lower than the Latin by a few percent of the font size, depending on the installed or embedded +/// face (PingFang, Microsoft JhengHei, LXGW WenKai Lite…). `FallbackAlign::Baseline` makes epaint +/// position every face's glyphs from the primary face's ascent, which is exactly the existing +/// baseline-alignment intent without metric-derived offsets that go stale across egui versions. pub(crate) fn on_interface_baseline(mut data: FontData) -> FontData { - let primary = vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0); - if let (Some(primary), Some(fallback)) = (primary, vertical_metrics(&data.font, data.index)) { - data.tweak.y_offset_factor = -baseline_drop(primary, fallback); - } + data.tweak.fallback_align = FallbackAlign::Baseline; data } @@ -429,13 +422,15 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +/// Install the palette, widget styling, text sizes and right-to-left flag for the chosen theme. pub fn apply(ctx: &egui::Context, kind: ThemeKind) { // Keep the native window inherited. A concrete egui theme pins macOS's // NSWindow appearance and prevents winit from delivering ThemeChanged. // Our saved choice owns the palette; both egui branches receive it below. ctx.set_theme(egui::ThemePreference::System); + let rtl = crate::i18n::current().rtl(); let t = Tokens::for_kind(kind); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("pdfcraft-theme"), t)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("pdfcraft-theme"), t)); let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.card; @@ -477,6 +472,9 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { } ctx.set_visuals(v); ctx.global_style_mut(|s| { + // RTL languages mirror stock chrome (menus, combo boxes, scroll bars, headers). + // Labels still get their paragraph direction explicitly from `bidi::ui_direction`. + s.right_to_left = rtl; s.spacing.item_spacing = egui::vec2(8.0, 6.0); s.spacing.button_padding = egui::vec2(10.0, 5.0); s.spacing.menu_margin = egui::Margin::same(6); @@ -493,6 +491,30 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { let style = ctx.global_style(); ctx.set_style_of(egui::Theme::Light, style.clone()); ctx.set_style_of(egui::Theme::Dark, style); + ctx.data_mut(|d| d.insert_temp(direction_key(), rtl)); +} + +/// Return the stable egui id holding the current right-to-left style flag. +fn direction_key() -> egui::Id { + egui::Id::unique("pdfcraft-style-right-to-left") +} + +/// Keep stock egui chrome's direction in step with the drawing language, after a language +/// change as well as at startup. Setting a flag on both concrete themes is cheap; this avoids +/// rebuilding the whole visuals and font state every frame. +pub fn sync_direction(ctx: &egui::Context) { + let rtl = crate::i18n::current().rtl(); + let changed = ctx.data_mut(|d| { + if d.get_temp::(direction_key()) == Some(rtl) { + false + } else { + d.insert_temp(direction_key(), rtl); + true + } + }); + if changed { + ctx.all_styles_mut(|s| s.right_to_left = rtl); + } } /// WCAG 2 contrast ratio between two opaque colours. @@ -520,6 +542,21 @@ pub fn readable(color: Color32, background: Color32, text: Color32) -> Color32 { mod tests { use super::*; + /// Arabic and Kurdish switch stock chrome to mirror from the right; English switches it + /// back. This is the `Style::right_to_left` half of the explicit RTL policy. + #[test] + fn arabic_chrome_is_right_to_left_after_the_language_changes() { + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + crate::i18n::set_current(crate::i18n::Lang::from_code("ar").expect("registered language")); + apply(&ctx, ThemeKind::Light); + assert!(ctx.global_style().right_to_left); + crate::i18n::set_current(crate::i18n::Lang::EN); + sync_direction(&ctx); + assert!(!ctx.global_style().right_to_left); + crate::i18n::set_current(previous); + } + /// #3193: a bookmark's own colour is kept where it reads on the panel, and moved towards the /// theme's text colour where it doesn't (dark blue on the dark panel), never past it. #[test] @@ -641,18 +678,16 @@ mod tests { ) } - /// The baseline of each glyph of `text` laid out by egui at `size` points, in points from the - /// top of the row: its position plus its face's `y_offset_factor`, as epaint draws it. + /// The y position of each glyph of `text` laid out by egui at `size` points, in points from + /// the top of the row. fn baselines(fallback: FontData, size: f32, text: &str) -> Vec { - let factor = fallback.tweak.y_offset_factor; let mut defs = FontDefinitions::empty(); defs.font_data.insert("Inter".into(), Arc::new(FontData::from_static(pdfcraft_fonts::INTER_REGULAR))); defs.font_data.insert("fallback".into(), Arc::new(fallback)); defs.families.insert(FontFamily::Proportional, vec!["Inter".into(), "fallback".into()]); let mut fonts = Fonts::new(TextOptions::default(), defs); let g = galley(&mut fonts, FontFamily::Proportional, size, 1.0, text); - let inter_has = |c: char| c.is_ascii(); - g.rows[0].glyphs.iter().map(|gl| gl.pos.y + if inter_has(gl.chr) { 0.0 } else { size * factor }).collect() + g.rows[0].glyphs.iter().map(|gl| gl.pos.y).collect() } #[test] @@ -664,14 +699,23 @@ mod tests { // As egui lays it out by itself, DŽ sits ~4 pt off H's baseline: the bug (#1676). let raw = baselines(FontData::from_static(dancing), size, "HDŽ"); assert!((raw[1] - raw[0]).abs() > 3.0, "the test fonts must differ: {raw:?}"); - let drop = baseline_drop(vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0).unwrap(), vertical_metrics(dancing, 0).unwrap()); - assert!((raw[1] - raw[0] - size * drop).abs() <= 1.0, "baseline_drop predicts egui: {raw:?} vs {drop}"); - // On the interface baseline, to the pixel. + // `FallbackAlign::Baseline` puts every face on the same baseline, to the pixel. let aligned = baselines(on_interface_baseline(FontData::from_static(dancing)), size, "HDŽ"); assert!((aligned[1] - aligned[0]).abs() <= 1.0, "{aligned:?}"); - // A face that doesn't parse is left alone rather than guessed at. - assert_eq!(on_interface_baseline(FontData::from_static(b"not a font")).tweak, FontTweak::default()); - assert_eq!(vertical_metrics(b"", 0), None); + assert_eq!(on_interface_baseline(FontData::from_static(dancing)).tweak.fallback_align, FallbackAlign::Baseline); + } + + /// The policy only exists on native platforms (`installed_font_definitions` has no installed + /// faces on wasm), so the test must not use it in a wasm test build. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn traditional_chinese_keeps_an_installed_face_when_hans_is_embedded() { + use crate::system_fonts::Need; + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, true), Need::Other); + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, false), Need::Hans); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, true), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, false), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::Default, true), Need::Other); } #[test] diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs index b9d474565..10ccbbbaa 100644 --- a/crates/ui-egui/src/wheel_boost.rs +++ b/crates/ui-egui/src/wheel_boost.rs @@ -313,11 +313,23 @@ mod tests { } fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::SHIFT, + source: egui::MouseWheelSource::Unknown, + } } #[test] @@ -379,6 +391,7 @@ mod tests { assert_eq!(input.page, egui::vec2(-1.0, 0.0)); } + /// Check that NaN, zero, negative or infinite scales and non-wheel events add nothing. #[test] fn invalid_scales_and_events_contribute_nothing() { use egui::MouseWheelUnit::Line; @@ -386,8 +399,13 @@ mod tests { let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false); assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}"); } - let zoom = - egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let zoom = egui::Event::MouseWheel { + unit: Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false); assert_eq!(input, FrameInput::default()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2d1f63996..01cab0331 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -27,12 +27,12 @@ fn dialog_frame(ctx: &egui::Context) -> egui::Frame { /// A modal with the card fill, a 10-point corner and room around its contents. pub fn modal(ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> egui::Modal { - egui::Modal::new(egui::Id::new(id)).frame(dialog_frame(ctx)) + egui::Modal::new(egui::Id::unique(id)).frame(dialog_frame(ctx)) } /// Show [`modal`], outline its controls, and remember the card rect for layout checks. pub fn show_modal(ctx: &egui::Context, id: &str, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { - let stored = egui::Id::new(id); + let stored = egui::Id::unique(id); modal(ctx, id).show(ctx, |ui| { begin_dialog(ui); let inner = content(ui); @@ -111,7 +111,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) }; let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); let well = rect.shrink2(vec2(2.0, 9.0)); if active { @@ -140,21 +140,26 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let text_color = if primary { Color32::WHITE } else { t.text }; + // Dialog buttons are translated UI text: an explicit RTL base keeps an Arabic label right + // to left even when its first strong character is Latin or a placeholder. + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); - let (fill, stroke, text) = if primary { - (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) + let (fill, stroke) = if primary { + (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE) } else { - (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text) + (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border)) }; ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside); if marked { resp.request_focus(); ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); } - ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text); + let body = rect.translate(press.offset()); + ui.painter().galley(body.center() - label_galley.size() / 2.0, label_galley, text_color); theme::hand(resp) } @@ -167,14 +172,15 @@ pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response { pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f32) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(13.0); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, t.text); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); - ui.painter().text(body.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); + ui.painter().galley(body.left_center() + vec2(30.0, -label_galley.size().y / 2.0), label_galley, t.text); theme::hand(resp) } @@ -182,7 +188,7 @@ pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f3 pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), placeholder)); let press = theme::Press::track(ui, &resp); let fill = press.fill(t.field, t.hover, t.pressed); let edge = t.border.lerp_to_gamma(t.text_muted, press.hover * 0.35); @@ -197,6 +203,7 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response let text_left = (rect.left() + 34.0).min(text_right); let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom())); let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint); + job.direction = crate::bidi::ui_direction(); job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() }; let text = ui.fonts_mut(|f| f.layout_job(job)); let elided = text.elided; @@ -280,11 +287,20 @@ fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui:: } } +/// Draw a panel section heading with explicit paragraph direction and letter spacing. pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + // The paragraph direction is explicit: an Arabic title beginning with Latin or a placeholder + // must not inherit a left-to-right base from its first strong character. + let mut job = egui::text::LayoutJob::simple(crate::i18n::upper_label(text), theme::semibold(10.5), t.text_faint, ui.available_width().max(0.0)); + job.direction = crate::bidi::ui_direction(); + if let Some(section) = job.sections.first_mut() { + section.format.extra_letter_spacing = 0.6; + } + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)); ui.add_space(2.0); } @@ -292,12 +308,35 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { /// at most `max_width` wide: a long file name or path stays inside its row (#425). pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into, font: egui::FontId, color: Color32, max_width: f32) { let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + // A file name or path: an Arabic name keeps its extension at the visual end, as in the tab. + job.direction = egui::epaint::text::TextDirection::LeftToRight; job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0)); let galley = ui.painter().layout_job(job); let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size()); ui.painter().galley(rect.min, galley, color); } +/// A wrapping label for a file name or folder path, laid out explicitly left to right so an +/// Arabic name keeps `.pdf` at the end. The accessibility name is the logical text. +pub fn label_ltr(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let mut job = egui::text::LayoutJob::simple(text.into(), font, color, ui.available_width().max(0.0)); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + +/// [`label_ltr`] for one table row: the name is cut with an ellipsis instead of wrapping onto a +/// second row. The logical text remains the accessibility name. +pub fn label_ltr_truncated(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let text = text.into(); + let width = ui.available_width().max(0.0); + let mut job = egui::text::LayoutJob::simple_singleline(text, font, color); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + job.wrap = egui::text::TextWrapping::truncate_at_width(width); + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + /// Text wrapped to the available width, for text people write (comments, replies). A paragraph /// in a right-to-left script (Persian, Arabic, Hebrew) reads right to left and sits against the /// right edge ([`crate::bidi::wrap`]); text without one is an ordinary wrapping label. @@ -312,11 +351,11 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co } let width = ui.available_width().max(1.0); let lines = wrapped_lines(ui, text, &font, width, truncate); - let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, ui.painter().layout_no_wrap(line.text.clone(), font.clone(), color))).collect(); + let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, wrapped_line_galley(ui, line, &font, color))).collect(); let height = galleys.iter().map(|(_, g)| g.size().y).sum(); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); // Painted text only: the accessibility name keeps the logical order. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); let mut y = rect.top(); for (rtl, galley) in galleys { let x = if rtl { rect.right() - galley.size().x } else { rect.left() }; @@ -327,6 +366,15 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co resp } +/// One wrapped line laid out with its own paragraph direction. A line beginning with a Latin +/// word inside a right-to-left comment is still part of that paragraph, so its base direction +/// must stay right to left; `Auto` would follow the Latin word instead. +fn wrapped_line_galley(ui: &egui::Ui, line: &crate::bidi::Line, font: &egui::FontId, color: Color32) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(line.text.clone(), font.clone(), color); + job.direction = if line.rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }; + ui.painter().layout_job(job) +} + const ELLIPSIS: &str = "\u{2026}"; /// Wrapped right-to-left text kept between frames. Wrapping measures many candidate lines, so @@ -351,7 +399,7 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru let mut hasher = std::collections::hash_map::DefaultHasher::new(); (text, font, width.to_bits(), ui.ctx().pixels_per_point().to_bits(), truncate).hash(&mut hasher); let key = hasher.finish(); - let cache_id = egui::Id::new("pdfcraft-rtl-wrap-cache"); + let cache_id = egui::Id::unique("pdfcraft-rtl-wrap-cache"); if let Some(hit) = ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(cache_id).lines.get(&key).cloned()) { return hit; } @@ -368,12 +416,9 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru if truncate && (lines.len() > 1 || cut) { lines.truncate(1); if let Some(line) = lines.first_mut() { - // The ellipsis goes where the line's reading ends: the left of a right-to-left line. - if line.rtl { - line.text.insert_str(0, ELLIPSIS); - } else { - line.text.push_str(ELLIPSIS); - } + // The ellipsis goes where the line's reading ends, in logical text: epaint places it + // on the left for a right-to-left line and on the right for a left-to-right one. + line.text.push_str(ELLIPSIS); } } let lines: std::sync::Arc<[crate::bidi::Line]> = lines.into(); @@ -400,9 +445,9 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Above the progress notice while a job runs. - let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::new(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; + let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::unique(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 }; - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, lift)) @@ -455,10 +500,10 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { }; let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; let animated = crate::motion::animated(ctx); - let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); + let shown = ctx.animate_value_with_time(egui::Id::unique("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); let now = ctx.input(|i| i.time); let mut cancel = false; - let area = egui::Area::new(egui::Id::new("progress-notice")) + let area = egui::Area::new(egui::Id::unique("progress-notice")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) @@ -476,7 +521,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover()); // The label above names the job; the bar carries the percentage. resp.widget_info(|| { - let mut info = egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator); + let mut info = egui::WidgetInfo::new(egui::Role::ProgressIndicator); info.value = Some(f64::from(fraction) * 100.0); info }); @@ -506,7 +551,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { } }); }); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(PROGRESS_HEIGHT), area.response.rect.height())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(PROGRESS_HEIGHT), area.response.rect.height())); ctx.request_repaint_after(std::time::Duration::from_millis(33)); cancel } @@ -555,10 +600,10 @@ pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Re let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover()); let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0); - let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click()); - let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click()); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more)); + let main = ui.interact(main_rect, ui.scope_id().with(("split-pill", label)), Sense::click()); + let arrow = ui.interact(more_rect, ui.scope_id().with(("split-pill-more", label)), Sense::click()); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), more)); let radius = CornerRadius::same(15); ui.painter().rect(rect, radius, t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 }; @@ -586,7 +631,7 @@ pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> R let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); let (fill, stroke, text) = if primary { (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) @@ -710,7 +755,7 @@ mod tests { ); output.drop_without_applying_deltas(); } - ctx.memory(|memory| memory.area_rect(egui::Id::new("toast")).unwrap()) + ctx.memory(|memory| memory.area_rect(egui::Id::unique("toast")).unwrap()) } #[test] @@ -761,7 +806,7 @@ mod tests { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let cache_len = - |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("pdfcraft-rtl-wrap-cache")).lines.len()); + |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("pdfcraft-rtl-wrap-cache")).lines.len()); let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; let long = persian.repeat(2_000); let mut first = None; @@ -775,7 +820,7 @@ mod tests { first = Some(lines); let collapsed = wrapped_lines(ui, &long, &theme::regular(13.0), 120.0, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].text.starts_with(ELLIPSIS), "{:?}", collapsed[0].text); + assert!(collapsed[0].text.ends_with(ELLIPSIS), "{:?}", collapsed[0].text); }) .drop_without_applying_deltas(); } @@ -789,6 +834,61 @@ mod tests { assert!(cache_len(&ctx) <= WRAP_CACHE_MAX, "bounded: {}", cache_len(&ctx)); } + /// Comment lines reach epaint in logical order. A mixed Persian/Latin comment preordered by + /// `bidi::wrap` would be reordered a second time by egui 0.37; the old display-order text is + /// distinctly different, so this fails if the preordering comes back. + #[test] + fn rtl_comment_lines_stay_logical_for_epaint() { + let mixed = "این نظر PDF درباره فایل است"; + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let lines = wrapped_lines(ui, mixed, &theme::regular(13.0), 1000.0, false); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, mixed, "epaint needs reading order, not display order"); + assert!(lines[0].rtl); + // Truncation appends the ellipsis logically; the line still starts with its first word. + let cut = wrapped_lines(ui, mixed, &theme::regular(13.0), 60.0, true); + assert_eq!(cut.len(), 1); + assert!(cut[0].text.starts_with("این"), "{:?}", cut[0].text); + assert!(cut[0].text.ends_with(ELLIPSIS), "{:?}", cut[0].text); + // The galley keeps logical text, so inspect positioned glyphs to prove that the + // visible ellipsis is at the left-hand reading end after epaint's bidi layout. + let galley = ui.painter().layout_no_wrap(cut[0].text.clone(), theme::regular(13.0), Color32::WHITE); + let row = &galley.rows[0]; + let ellipsis_x = row.glyphs.iter().find(|glyph| glyph.chr == '\u{2026}').expect("ellipsis glyph").pos.x; + assert!( + row.glyphs.iter().filter(|glyph| glyph.chr != '\u{2026}' && !glyph.chr.is_whitespace()).all(|glyph| glyph.pos.x > ellipsis_x), + "RTL truncation must place the ellipsis to the left: {:?}", + row.glyphs + ); + }) + .drop_without_applying_deltas(); + } + + /// A wrapped line keeps its paragraph's direction. The second line here starts with a + /// Latin word, but belongs to a right-to-left comment: `Auto` would shape it left to right + /// and put `PDF` at the visual left. Assert the actual painted positions instead. + #[test] + fn wrapped_rtl_line_starting_with_latin_keeps_its_rtl_base() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let line = crate::bidi::Line { text: "PDF درباره فایل است".to_owned(), rtl: true }; + let galley = wrapped_line_galley(ui, &line, &theme::regular(13.0), Color32::WHITE); + assert_eq!(galley.job.direction, egui::epaint::text::TextDirection::RightToLeft); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| crate::bidi::has_rtl(&c.to_string())).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "the line stays right to left: {positions:?}"); + }) + .drop_without_applying_deltas(); + } + + /// Check right-aligned wrapping and a single collapsed line whose logical text ends with an ellipsis. #[test] fn rtl_comment_wraps_against_the_right_edge() { let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; @@ -805,7 +905,7 @@ mod tests { // Collapsed: one line, its ellipsis on the left where right-to-left reading ends. let collapsed = wrapped_shapes(persian, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].2.starts_with('\u{2026}'), "{collapsed:?}"); + assert!(collapsed[0].2.ends_with('\u{2026}'), "the galley retains logical text: {collapsed:?}"); // Text without right-to-left script is a plain label, as before. let latin = wrapped_shapes("A short note", false); assert_eq!(latin.len(), 1); diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index 70df81422..9965d46ec 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -96,7 +96,7 @@ pub fn buttons(ui: &mut egui::Ui, buttons: &[Button]) { Button::Close => tl!("Close"), }; let (rect, response) = ui.allocate_exact_size(Vec2::splat(BUTTON), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let response = response.on_hover_text(label); if ui.is_rect_visible(rect) { paint(ui.painter(), rect, button, maximized, &t, &response); @@ -203,7 +203,7 @@ pub fn resize_edges(ctx: &egui::Context) { } let screen = ctx.content_rect(); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("window_resize_edges")) + egui::Area::new(egui::Id::unique("window_resize_edges")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .pivot(Align2::LEFT_TOP) @@ -214,7 +214,7 @@ pub fn resize_edges(ctx: &egui::Context) { if rect.width() <= 0.0 || rect.height() <= 0.0 { continue; } - let response = ui.interact(rect, ui.id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); + let response = ui.interact(rect, ui.scope_id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); // Sensing only drags, a drag starts on the press itself. if response.drag_started() { desktop_grab(ui.ctx(), egui::ViewportCommand::BeginResize(direction)); @@ -231,8 +231,9 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +/// Return the stable egui id for desktop window-grab state. fn grab_id() -> egui::Id { - egui::Id::new("window_controls_desktop_grab") + egui::Id::unique("window_controls_desktop_grab") } /// Hand the pointer to the desktop to move or resize the window. On Wayland the compositor keeps diff --git a/crates/ui-egui/tests/it/actions.rs b/crates/ui-egui/tests/it/actions.rs index 8e5f37cf0..a78de3481 100644 --- a/crates/ui-egui/tests/it/actions.rs +++ b/crates/ui-egui/tests/it/actions.rs @@ -1,12 +1,30 @@ //! Action Wizard in the real shell (egui_kittest): run a built-in action on files, create an //! action, and remember it. +use egui::accesskit::Role; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::Session; use pdfcraft_engine::actions::Step; use pdfcraft_ui_egui::PdfCraftApp; +/// The Action Wizard's name, description and step-argument boxes are named by their labels. +#[test] +fn action_editor_inputs_have_accessible_names() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(2); + assert!(h.state_mut().execute("actions.wizard")); + h.run_steps(2); + h.get_by_label("New Action…").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Action name:"); + h.get_by_role_and_label(Role::TextInput, "Description:"); +} + #[test] fn run_and_create_actions() { let dir = std::env::temp_dir().join(format!("pdfcraft-actions-ui-{}", std::process::id())); diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs index 1a66723bb..c52b2892e 100644 --- a/crates/ui-egui/tests/it/attachment_row.rs +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -127,22 +127,23 @@ fn the_right_click_menu_opens_saves_and_deletes() { assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (3, 1, 3)); h.key_press(egui::Key::Escape); h.run_steps(3); - // The PDF's menu: Save writes the PDF, not the text file whose row comes last. + // Scope actions to the menu: egui nests it under its row in the accessibility tree. + // Save writes the PDF, not the text file whose row comes last. right_click(&mut h, "inner.pdf"); assert_eq!(count(&h, "Open in a new tab"), 2, "the PDF's menu offers Open"); h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); - h.get_all_by_label("Save attachment…").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Save attachment…").click(); h.run_steps(3); assert_eq!(std::fs::read(&target).unwrap(), INNER, "Save from inner.pdf's menu"); right_click(&mut h, "inner.pdf"); - h.get_all_by_label("Open in a new tab").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Open in a new tab").click(); h.run_steps(3); assert_eq!(h.state().views.len(), 2, "Open from the menu"); h.state_mut().active = Some(0); h.run_steps(3); // Delete from inner.pdf's menu leaves notes.txt, whose row's button would delete itself. right_click(&mut h, "inner.pdf"); - h.get_all_by_label("Delete attachment").last().unwrap().click(); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Delete attachment").click(); h.run_steps(3); let s = h.state(); let doc = s.session.get(s.views[0].id).unwrap(); diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151b..415067ad3 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -101,7 +101,7 @@ fn new_rename_reorder_indent_delete_and_undo() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] @@ -290,8 +290,8 @@ fn style_and_colour_from_the_bookmark_menu() { let submenu = |h: &mut Harness<'static, PdfCraftApp>, menu: &str, item: &str| { h.get_by_label("Intro").click_secondary(); h.run_steps(2); - // A submenu's button carries its arrow. - h.get_by_label(&format!("{menu} ⏵")).click(); + // The submenu arrow is painted as an atom; the accessible name is the menu title. + h.get_by_label(menu).click(); h.run_steps(2); h.get_by_label(item).click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index 4ed7b5778..c6f2e3e72 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -180,6 +180,17 @@ fn ready(t: &CombineThumb) -> bool { matches!(t, CombineThumb::Ready { .. }) } +/// Wait for a modal opened by an action to be laid out and present in the accessibility tree. +/// A newly shown modal needs a pass of its own; the action's frame only sets its state. +fn wait_for_label(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + for _ in 0..30 { + if h.query_by_label(label).is_some() { + return; + } + h.run_steps(1); + } +} + /// Press, move in steps and release, as a user drags. fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { h.hover_at(from); @@ -691,6 +702,7 @@ fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); } +/// Check that right-clicking a locked card selects that card and offers Unlock for it. #[test] fn a_locked_card_offers_unlock_on_a_right_click() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); @@ -699,6 +711,7 @@ fn a_locked_card_offers_unlock_on_a_right_click() { // The menu's item comes after the toolbar's button of the same name. h.get_all_by_label("Unlock…").last().unwrap().click(); h.run_steps(2); + wait_for_label(&mut h, "Unlock secret.pdf"); h.get_by_label("Unlock secret.pdf"); assert_eq!(h.state().combine_selection(), [1], "the card right-clicked is the one unlocked"); } @@ -716,6 +729,7 @@ fn ctrl_wheel(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, dy: f32) { delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } @@ -833,6 +847,8 @@ fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { assert_eq!(h.state().combine_zoom, now); } +/// Check that wheel zoom keeps the card under the pointer fixed and never blanks its thumbnail while a +/// sharper render arrives. #[test] fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); @@ -845,6 +861,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(6); // A card fully in view, below the toolbar, with its thumbnail drawn. @@ -866,6 +883,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); // The wheel zooms smoothly over several frames; in every one the card stays put. let mut zooms = Vec::new(); @@ -976,6 +994,7 @@ fn scrolled_grid(dy: f32) -> (Harness<'static, PdfCraftApp>, f32) { delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); (h, top) @@ -1012,6 +1031,7 @@ fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { } } +/// Check that zooming out at the bottom keeps the last card in place without leaving a gap below it. #[test] fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { let (mut h, _) = scrolled_grid(20_000.0); @@ -1024,6 +1044,7 @@ fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { delta: vec2(0.0, -20_000.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); let bottom = card(&h, "f39.pdf").bottom(); @@ -1598,13 +1619,16 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.get_by_label("Preview").click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); + wait_for_label(&mut h, "Page 3 of 3"); h.get_by_label("Page 3 of 3"); - // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from - // it); nothing is selected yet, so no "Remove b.pdf" anywhere. - h.get_by_label("Remove page"); + // The preview is modal: the card's actions are not drawn behind it. + assert!(h.query_by_label("Remove page").is_none(), "the preview covers the page card's actions"); h.key_press(Key::Escape); h.run_steps(2); + // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from + // it); nothing is selected yet, so no "Remove b.pdf" anywhere. hover_card(&mut h, &page_card(4, "b.pdf")); + h.get_by_label("Remove page"); h.get_by_label("Collapse"); assert!(h.query_by_label("Remove b.pdf").is_none()); // Space on a page card that has the keyboard: that page too. @@ -1615,6 +1639,7 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds"); + wait_for_label(&mut h, "Page 2 of 3"); h.get_by_label("Page 2 of 3"); } @@ -1651,6 +1676,7 @@ fn collapsing_brings_the_files_card_into_view() { delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 6f1028861..ff8282014 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,12 +293,13 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } +/// Check that choosing Delete pages from the Pages menu reduces the document page count. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Pages ⏵").hover(); + h.get_by_label("Pages").hover(); h.run_steps(3); h.get_by_label_contains("Delete pages").click(); h.run_steps(3); @@ -306,6 +307,8 @@ fn the_pages_menu_comes_from_the_registry() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); } +/// Check that Open Recent lists a recorded file and opens it in a new active tab without replacing the +/// original. #[test] fn the_open_recent_menu_lists_files_and_opens_one() { let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id())); @@ -326,9 +329,9 @@ fn the_open_recent_menu_lists_files_and_opens_one() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); + h.get_by_label("Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; h.get_by_label_contains("recent.pdf").click(); @@ -341,12 +344,13 @@ fn the_open_recent_menu_lists_files_and_opens_one() { assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice()); } +/// Check that Open Recent is disabled while no file has been opened. #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), @@ -358,6 +362,7 @@ fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile { pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 } } +/// Check that Clear Recent Files empties the list and disables Open Recent. #[test] fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -370,16 +375,16 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); + h.get_by_label("Open Recent").hover(); h.run_steps(3); h.get_by_label("Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("File ⏵").hover(); + h.get_by_label("File").hover(); h.run_steps(3); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -459,7 +464,7 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { h.ctx.set_os(os); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); h } @@ -530,6 +535,8 @@ fn the_about_dialog_shows_the_running_version() { h.get_by_label_contains(&format!("Version {}", env!("CARGO_PKG_VERSION"))); } +/// Check that the quit shortcut needs exact platform modifiers and that unsaved work opens the save prompt +/// instead of closing. #[test] fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { for (os, modifiers) in [ @@ -550,6 +557,9 @@ fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close)); assert_eq!(closes, should_close, "{os:?}, extra={extra:?}, dirty={dirty}"); if dirty { + // The save prompt is a modal laid out after the key pass; let that pass settle + // before looking it up. + h.run_steps(2); h.get_by_label("Cancel").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index d516330dd..3a3169576 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1,6 +1,7 @@ //! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and //! the Comments panel. +use egui::accesskit::Role; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -542,7 +543,7 @@ fn selected_text_replace_action_opens_replacement_composer() { h.run_steps(3); h.get_by_label("Replace text").click(); h.run_steps(2); - h.get_by_label("Replacement text"); + h.get_by_role_and_label(Role::MultilineTextInput, "Replacement text"); assert!(h.state().views[0].selected_text().is_none()); } @@ -1052,7 +1053,7 @@ fn the_panel_posts_comments_and_replies() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 3ad7953b3..e18c7bf47 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -88,6 +88,14 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +/// Let the next real frames run after a control setting that changes the interface fonts or +/// opens a modal. `ui.set` answers in the input pass, and a font change discards the pass that +/// applies it, so the modal's labels are not in the accessibility tree until those frames have +/// been laid out. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + h.run_steps(3); +} + #[test] fn comment_pin_is_reported_and_persistent() { let (mut h, c) = harness(); @@ -162,6 +170,9 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { assert_eq!(state["documents"][0]["dirty"], false); } +/// Kittest calls only `App::logic` and `App::ui`, not eframe's `raw_input_hook`, so this checks +/// the widget tree and key handling but not the production focus-generation ordering. The +/// `production_*` tests above drive `prepare_raw_input` first and cover that ordering. #[test] fn control_focus_traverses_real_widgets_and_keyboard_activates() { let (mut h, c) = harness(); @@ -183,6 +194,55 @@ fn control_focus_traverses_real_widgets_and_keyboard_activates() { assert_eq!(state["documents"][0]["dirty"], false); } +/// A frame driven in production order: eframe's raw input hook runs before egui's input hooks, so +/// the control plugin sees the focus/dialog generation produced by the previous UI pass. The +/// kittest harness does not call `raw_input_hook`, which otherwise hides focus-change bugs. +fn production_step(h: &mut Harness<'static, PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn call_production(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { + let rx = c.send(method, params); + for _ in 0..30 { + production_step(h); + if let Ok(r) = rx.try_recv() { + return r; + } + } + panic!("{method}: no reply after 30 production-ordered frames"); +} + +#[test] +fn production_key_release_survives_the_focus_change_its_press_causes() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); + let reply = call_production(&mut h, &c, "ui.key", json!({"key": "Tab"})); + reply.unwrap_or_else(|e| panic!("a Tab press that moves focus must not leave its release unapplied: {e}")); + let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"})); + assert!(sign["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "E-Sign" && w["focused"] == true), "focus moved on the press: {sign}"); + assert!(!h.ctx.input(|i| i.keys_down.contains(&egui::Key::Tab)), "the release reached egui: no stuck Tab"); +} + +#[test] +fn production_drag_releases_the_button_after_the_press_moves_focus() { + let (mut h, c) = harness_pages(2); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None }); + ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"})); + let field = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"})); + let rect = field["widgets"][0]["rect"].as_array().expect("the search field has a rect"); + let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap()); + let start = json!([x0 + 8.0, (y0 + y1) / 2.0]); + let end = json!([x1 - 8.0, (y0 + y1) / 2.0]); + call_production(&mut h, &c, "ui.drag", json!({"from": start, "to": end, "steps": 3})) + .unwrap_or_else(|e| panic!("the press's focus change must not cancel its moves and release: {e}")); + assert!(!h.ctx.input(|i| i.pointer.button_down(egui::PointerButton::Primary)), "the pointer button was released"); + assert_eq!(h.ctx.input(|i| i.modifiers), egui::Modifiers::NONE, "no modifier is left held"); +} + #[test] fn control_button_focus_preserves_document_undo_redo_shortcuts() { let (mut h, c) = harness_pages(2); @@ -209,7 +269,7 @@ fn control_text_focus_retains_text_undo_ownership() { ok(&mut h, &c, "ui.focus", json!({"id": widgets["widgets"][0]["id"]})); assert!(h.ctx.text_edit_focused()); ok(&mut h, &c, "ui.type", json!({"text": "target"})); - let search_id = egui::Id::new(("bookmark_search", h.state().views[0].id.0)); + let search_id = egui::Id::unique(("bookmark_search", h.state().views[0].id.0)); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("target")); ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]})); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("")); @@ -454,7 +514,7 @@ fn language_switch_preserves_document_and_command_ids() { let lang = i18n::Lang::from_code(code).unwrap(); h.get_by_label(i18n::tr(lang, "Menu")).click(); h.run_steps(2); - h.get_by_label(&format!("{} ⏵", i18n::tr(lang, "File"))).hover(); + h.get_by_label(i18n::tr(lang, "File")).hover(); h.run_steps(3); h.get_by_label_contains(i18n::tr(lang, "Open…")); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); @@ -716,6 +776,7 @@ fn autoscroll_cancels_on_click_wheel_focus_loss_and_pointer_exit() { delta: egui::vec2(0.0, -20.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(2); assert!(!h.state().views[0].auto_scrolling()); @@ -854,6 +915,8 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + // The command sets the dialog; its content reaches the accessibility tree in the next pass. + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); @@ -879,6 +942,7 @@ fn german_preferences_and_search_keep_command_ids() { ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" })); @@ -908,6 +972,7 @@ fn ukrainian_preferences_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "uk" })); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); for label in ["Мова інтерфейсу", "Українська"] { let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label })); assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}"); @@ -940,7 +1005,14 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); - ok(&mut h, &c, "ui.click", json!({ "label": "編集 ⏵" })); + h.run_steps(2); + // The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick + // the popup entry by its id, not by its visible text. + let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" })); + // The tool button reports its toggle state; the popup's menu button does not. + let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry"); + ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] })); + h.run_steps(2); let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); @@ -1015,7 +1087,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() { let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "about"})); + settle(&mut h); for label in ["关于", "贡献者", "模型"] { let found = ok(&mut h, &c, "ui.inspect", json!({"query": label})); assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label), "{found}"); @@ -1042,7 +1116,9 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "signature"})); + settle(&mut h); let typed = ok(&mut h, &c, "ui.inspect", json!({"query": "请输入您的签名。"})); assert!(typed["count"].as_u64().unwrap() > 0, "{typed}"); // The shell also has a Draw control; the modal's button is registered after the shell. @@ -1270,6 +1346,10 @@ fn screenshots_of_window_and_region() { let region = ok(&mut h, &c, "ui.screenshot", json!({ "region": [10, 20, 110, 70] })); assert_eq!((region["width"].as_f64().unwrap(), region["height"].as_f64().unwrap()), ((100.0 * ppp).round(), (50.0 * ppp).round())); assert!(call(&mut h, &c, "ui.screenshot", json!({ "region": [5, 5, 1, 1] })).is_err()); + // A region that cannot be inside the window is refused at admission, before a frame or slot + // is used, with an actionable message. + let outside = call(&mut h, &c, "ui.screenshot", json!({ "region": [1300, 0, 1500, 10] })).unwrap_err(); + assert!(outside.contains("inside the window"), "{outside}"); } #[test] @@ -1766,7 +1846,13 @@ fn navigation_wheel(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, delt vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(pos), - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, delta), modifiers, phase: egui::TouchPhase::Move }, + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, delta), + modifiers, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }, ], ); h.run_steps(20); @@ -1866,7 +1952,7 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { let (mut h, _c) = harness(); let area = h.state().views[0].viewport_rect(); let shown = |h: &Harness<'static, PdfCraftApp>| { - let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("page-size-hint")); + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("page-size-hint")); h.ctx.memory(|m| m.areas().visible_last_frame(&layer)) }; let away = area.center() + egui::vec2(300.0, -200.0); @@ -1947,6 +2033,7 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { delta: egui::vec2(0.0, -1200.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); assert!(h.state().views[0].current > 0, "the wheel really moved to a later page"); diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index d33ea7baa..47462fcf2 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -33,8 +33,9 @@ fn fixture_with_info(info: Option<&str>) -> Vec { out } +/// Return the rectangle recorded for a dialog card, failing the test when the card is missing. fn card(h: &Harness<'_, PdfCraftApp>, id: &str) -> egui::Rect { - h.ctx.data(|d| d.get_temp(egui::Id::new(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) + h.ctx.data(|d| d.get_temp(egui::Id::unique(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) } fn protected() -> Vec { @@ -202,6 +203,7 @@ fn space_audit_tab_measures_again_after_an_edit() { assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}"); } +/// Check that a dialog's Close button follows the content when switching from a taller tab to a shorter one. #[test] fn dialog_buttons_follow_the_content_after_a_taller_tab() { use pdfcraft_ui_egui::{Dialog, PropsTab}; @@ -213,6 +215,10 @@ fn dialog_buttons_follow_the_content_after_a_taller_tab() { app }); h.run_steps(4); + for name in ["Navigation tab", "Page layout", "Magnification"] { + h.get_by_role_and_label(Role::ComboBox, name); + } + h.get_by_role_and_label(Role::SpinButton, "Open to page"); h.state_mut().dialog = Some(Dialog::Properties(PropsTab::Description)); h.run_steps(4); // The card shrinks back to the shorter tab: Close sits under the last row, not halfway diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index a6523184a..9bf7f7081 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1370,7 +1370,9 @@ fn edit_menu_names_the_step_to_undo() { }); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); // submenus open on hover; their labels carry the arrow + // The popup's Edit entry and the left panel's Edit tool carry the same visible label. The + // tool reports a toggle state; the menu entry does not. + h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow h.run_steps(3); h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut h.run_steps(3); @@ -6346,6 +6348,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); let scrolled = h.state().views[0].page_screen_rect(0).unwrap(); diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs index 2f3569661..26a6f1f3d 100644 --- a/crates/ui-egui/tests/it/editor_taps.rs +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -313,7 +313,7 @@ fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); mouse_click(h, p); assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("edit-text-line-input"))), "with the keyboard"); } /// The open paragraph's line spacing, if a paragraph is open. diff --git a/crates/ui-egui/tests/it/fonts.rs b/crates/ui-egui/tests/it/fonts.rs index 1e569230d..d897f509b 100644 --- a/crates/ui-egui/tests/it/fonts.rs +++ b/crates/ui-egui/tests/it/fonts.rs @@ -15,6 +15,19 @@ fn families() -> Vec { vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)] } +/// The bundled faces, with no craft-fonts and no installed system face: 0.37 gates its emoji +/// fonts behind `monochrome_emoji_fonts`, which the app and this test crate explicitly enable. +/// Text symbols must keep working with `PDFCRAFT_SYSTEM_FONTS=0`. +#[test] +fn bundled_monochrome_symbols_have_glyphs() { + let mut fonts = Fonts::new(TextOptions::default(), theme::font_definitions()); + for id in families() { + for text in ["✔", "▲", "▼"] { + assert!(fonts.has_glyphs(&id, text), "{id:?} lacks {text} with default_fonts + monochrome_emoji_fonts"); + } + } +} + /// Lays `text` out in every interface family and returns each galley's width. fn layout_widths(fonts: &mut Fonts, text: &str) -> Vec { let mut view = fonts.with_pixels_per_point(2.0); @@ -280,42 +293,60 @@ fn catalog_han(catalog: &str) -> String { han.into_iter().collect() } -/// In a Chinese interface built without a `Hans` face (every release so far, #826), a Chinese -/// face installed on this machine goes before the Japanese faces, which lack hundreds of the -/// catalog's characters, and draws every Han character of both Chinese catalogs. +/// One Chinese preference against its catalog: the installed face must be in every family, +/// before the Japanese faces, and cover every Han character the catalog uses. +/// +/// Returns false when this machine has no installed face for the preference (or system fonts +/// are off); the caller reports that as a skip. A bundled `Hans` face is never allowed to +/// stand in for Traditional Chinese. +fn installed_chinese_face_covers(cjk: theme::CjkPreference, catalog: &str, name: &str) -> bool { + let defs = theme::installed_font_definitions(cjk); + if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { + eprintln!("skipping {name}: no installed Chinese face for {cjk:?} (or PDFCRAFT_SYSTEM_FONTS=0)"); + return false; + } + for (family, stack) in &defs.families { + let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); + assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); + let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); + assert!(own < system, "{family:?}: {stack:?}"); + if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { + assert!(system < first_ja, "{family:?}: {stack:?}"); + } + } + let han = catalog_han(catalog); + assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); + assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); + } + true +} + +/// In a Simplified Chinese interface built without a `Hans` face, a Simplified Chinese face +/// installed on this machine goes before the Japanese faces, which lack hundreds of the +/// catalog's characters. #[test] -fn chinese_ui_uses_an_installed_chinese_face() { +fn simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one() { if !pdfcraft_fonts::ui_chinese_fonts().is_empty() { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: a Hans face is bundled"); + eprintln!("skipping simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one: a Hans face is bundled"); return; } - for (cjk, catalog) in [ - (theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv")), - (theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv")), - ] { - let defs = theme::installed_font_definitions(cjk); - if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: no installed Chinese face (or PDFCRAFT_SYSTEM_FONTS=0)"); - return; - } - for (family, stack) in &defs.families { - let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); - assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); - let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); - assert!(own < system, "{family:?}: {stack:?}"); - if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { - assert!(system < first_ja, "{family:?}: {stack:?}"); - } - } - let han = catalog_han(catalog); - assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in families() { - let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); - assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); - } - } - // Other languages keep the installed face last, as before. + installed_chinese_face_covers(theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv"), "Simplified"); +} + +/// A Traditional Chinese interface keeps its installed Traditional face before the Japanese +/// faces even when the build embeds a Simplified-only `Hans` face: that face cannot supply +/// Traditional-only characters, and Japanese-first fallback would otherwise win. +#[test] +fn traditional_chinese_ui_keeps_an_installed_hant_face_before_japanese() { + installed_chinese_face_covers(theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv"), "Traditional"); +} + +/// Other languages keep the installed face last. +#[test] +fn non_chinese_ui_keeps_the_installed_face_last() { let defs = theme::installed_font_definitions(theme::CjkPreference::Default); for stack in defs.families.values() { assert!(stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).is_none_or(|i| i + 1 == stack.len()), "{stack:?}"); @@ -346,7 +377,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let defs = theme::font_definitions(); assert_eq!(&defs.families[&FontFamily::Monospace][..2], ["JetBrainsMono", "Inter"]); let fallback = &defs.font_data["Inter"]; - let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); + let fallback_font = skrifa::FontRef::from_index(fallback.font.as_ref().as_ref(), fallback.index).unwrap(); let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ ("cs", include_str!("../../src/i18n/cs.tsv")), @@ -384,7 +415,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { } for name in ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"] { let data = &defs.font_data[name]; - let font = skrifa::FontRef::from_index(&data.font, data.index).unwrap(); + let font = skrifa::FontRef::from_index(data.font.as_ref().as_ref(), data.index).unwrap(); let charmap = font.charmap(); let mut missing: Vec = text.chars().filter(|c| charmap.map(*c).is_none() && (name != "JetBrainsMono" || fallback_charmap.map(*c).is_none())).collect(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 436792553..dcaa7510b 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -64,7 +64,7 @@ fn hovering_a_field_shows_its_tooltip() { let mut h = harness(); assert!(h.query_by_label("Your name").is_none(), "no tooltip before the hover"); rest_on_field(&mut h, "name"); - assert!(h.query_by_label("Your name").is_some(), "the name field's /TU shows on hover"); + assert!(h.query_all_by_label("Your name").next().is_some(), "the name field's /TU shows on hover"); rest_on_field(&mut h, "city"); assert!(h.query_by_label("Your name").is_none(), "another field doesn't keep the tooltip"); // Nor shows one of its own: none has been shown since the pointer left `name` (well past the @@ -84,7 +84,7 @@ fn a_read_only_fields_tooltip_shows_on_hover() { let mut h = harness(); set_city(&mut h, pdfcraft_engine::FieldProps { tooltip: Some("Where you live".into()), read_only: Some(true), ..Default::default() }); rest_on_field(&mut h, "city"); - assert!(h.query_by_label("Where you live").is_some()); + assert!(h.query_all_by_label("Where you live").next().is_some()); } /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). @@ -404,7 +404,7 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == "name").unwrap(); let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); - (rect, egui::Id::new(("form-field", s.views[0].id.0, "name", 0usize))) + (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) }; // From inside the first word to past the end of the text. let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 78a8b0b3b..f2b6c0e90 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -300,13 +300,15 @@ fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { // --------------------------------------------------------------------------------------------- // Things drawn over the page keep the page's cursor, tips and presses off. +/// Check that the command palette's frame keeps the default cursor over the page while the page beside it +/// still shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_command_palette() { let mut h = open(link_pdf(), "300", false); stamp_tool(&mut h); h.state_mut().palette_open = true; h.run_steps(4); - let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); // On the palette's frame margin, where none of its widgets sets a cursor, over the page. let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0); assert!(page(&h).contains(p), "the palette must lie over the page here"); @@ -324,7 +326,7 @@ fn link_tip_stays_off_while_the_palette_is_open() { let mut h = open(link_pdf(), "300", false); h.state_mut().palette_open = true; h.run_steps(4); - let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); let beside = [60.0, 100.0, 150.0, 200.0, 240.0] .into_iter() .flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y))) @@ -392,21 +394,30 @@ fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() { assert!(tip_shown(&h), "and shows its tip"); } +/// Send one wheel event over the page and let its scrolling settle. fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { h.hover_at(egui::pos2(800.0, 500.0)); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); for _ in 0..10 { h.run_steps(2); } } +/// Check that the quick bar's frame keeps the default cursor over the page while the page below it still +/// shows the stamp tool. #[test] fn stamp_cursor_stays_off_the_quick_bar() { let mut h = open(link_pdf(), "400", true); stamp_tool(&mut h); // Bring the page under the quick bar's left end. wheel(&mut h, egui::vec2(-200.0, 0.0)); - let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown"); + let bar = h.ctx.memory(|m| m.area_rect(egui::Id::unique("quick-bar"))).expect("quick bar shown"); // Inside the bar's frame margin: no button there. let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0); assert!(page(&h).contains(p), "the quick bar must lie over the page here"); @@ -435,7 +446,7 @@ fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { /// Whether the page's hover tip was drawn in the last frame. fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool { - let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover")); h.ctx.memory(|m| m.areas().is_visible(&tip)) } @@ -445,6 +456,7 @@ fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); } +/// Check that the link tip and pointing cursor disappear under the Unlock prompt. #[test] fn link_tip_stays_under_the_unlock_prompt() { let mut h = open(link_pdf(), "300", true); @@ -461,18 +473,19 @@ fn link_tip_stays_under_the_unlock_prompt() { for _ in 0..4 { h.run_steps(2); } - let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock"))); + let card = h.ctx.memory(|m| m.area_rect(egui::Id::unique("unlock"))); assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}"); assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt"); assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); } +/// Check that moving onto a link's floating tip still reports the link's pointing-hand cursor. #[test] fn the_link_tip_lets_the_pointer_through_to_the_link() { let mut h = open(link_pdf(), "150", false); let p = at(&h, 100.0, 340.0); assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); - let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip"); + let tip = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).expect("the link's tip"); // A quick move lands on the tip, drawn beside where the pointer was, and still on the link. let onto = tip.min + egui::vec2(6.0, 6.0); assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link"); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 936b847d8..806fa7153 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1028,9 +1028,7 @@ fn escape_in_a_name_field_leaves_the_field_not_the_table() { let mut h = harness(); draw_table(&mut h); // The app's own text boxes (zoom, page number) are inputs too: take the one in the window. - let window = h.get_by_label("Text Field Table").rect(); - let field = - h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field"); + let field = h.get_by_role_and_label(Role::TextInput, "Column 1").rect(); h.hover_at(field.center()); h.drag_at(field.center()); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 21aeca128..81025e756 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -23,6 +23,15 @@ fn harness_with_settings(settings: Option) -> Harness<'static, PdfCraftA h } +/// The page-range field is labelled by the Pages radio button rather than floating unnamed. +#[test] +fn the_page_range_field_is_named_for_screen_readers() { + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_role_and_label(Role::TextInput, "Pages"); +} + #[test] fn current_print_page_saves_the_viewed_page_when_labels_restart() { // Five distinct painted pages, labelled i, ii, iii, 1, 2. @@ -62,7 +71,7 @@ fn current_print_page_saves_the_viewed_page_when_labels_restart() { h.run_steps(3); assert!(h.state_mut().execute("print.dialog")); h.run_steps(3); - h.get_by_label("Current page").click(); + h.get_by_role_and_label(Role::RadioButton, "Current page").click(); h.run_steps(3); assert_eq!(h.state().print_draft.current_page, current); h.get_by_label("Sheet 1 of 1"); @@ -278,8 +287,8 @@ fn printers_listed_in_the_background_preselect_the_default_unless_one_was_picked h.state_mut().printer_source = Some(Arc::new(move || { let _open = wait.lock(); vec![ - Printer { name: "Office".into(), default: false, two_sided: None }, - Printer { name: "Front Desk".into(), default: true, two_sided: None }, + Printer { name: "Office".into(), default: false, two_sided: None, description: String::new() }, + Printer { name: "Front Desk".into(), default: true, two_sided: None, description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -335,7 +344,10 @@ fn the_picked_printer_is_kept_for_the_next_print_while_it_is_listed() { let source = listed.clone(); h.state_mut().printer_source = Some(Arc::new(move || { let names = source.lock().map(|n| n.clone()).unwrap_or_default(); - names.into_iter().map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None }).collect() + names + .into_iter() + .map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None, description: String::new() }) + .collect() })); let open = |h: &mut Harness<'static, PdfCraftApp>| { h.state_mut().dialog = None; @@ -422,8 +434,8 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { let mut h = harness(); h.state_mut().printer_source = Some(std::sync::Arc::new(|| { vec![ - Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }, - Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }, + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -885,10 +897,10 @@ fn a_booklet_decides_two_sided_itself() { h.run_steps(3); h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); h.run_steps(1); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); h.get_by_label("Booklet").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_none(), "hidden while Booklet is chosen"); let d = &h.state().print_draft; assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); @@ -898,6 +910,6 @@ fn a_booklet_decides_two_sided_itself() { } h.get_by_label("Size").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 3e3f3a593..2d8258eed 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -173,6 +173,8 @@ fn encrypted(bytes: Vec, password: &str) -> Vec { pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +/// Check that an encrypted document is autosaved without plaintext and recovered only after the password is +/// entered. #[test] fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { let bytes = encrypted(fixture(2), "pw"); @@ -186,6 +188,13 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() h.get_by_label_contains("password-protected"); h.get_by_label("Recover").click(); support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some()); + // The prompt is laid out in the pass after the one that set it. + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } let field = h.get_by_role(Role::PasswordInput); field.focus(); field.type_text("pw"); @@ -320,6 +329,8 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi } } +/// Check that an encrypted snapshot without metadata still asks for its password and is recovered without +/// rewriting its bytes. #[test] fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let s = store("encrypted-orphan"); @@ -330,6 +341,12 @@ fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let mut h = harness(s.clone()); h.get_by_label("Recover").click(); support::wait_until(&mut h, "orphan recovery password prompt", |app| app.password_prompt.is_some()); + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } assert!(h.state().views.is_empty()); let field = h.get_by_role(Role::PasswordInput); field.focus(); diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 2b6304416..7f7358bfe 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -189,9 +189,10 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) } +/// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { - use egui_kittest::kittest::Queryable; + use egui_kittest::kittest::{NodeT, Queryable}; let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); @@ -199,7 +200,7 @@ fn selected_text_copy_edit_and_context_menus() { h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); + h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); h.run_steps(3); h.get_by_label_contains("Copy").click(); let mut did_copy = false; @@ -336,6 +337,7 @@ fn selected_text_copy_preserves_unicode_and_page_clipboard() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); } +/// Check that a focused search input owns Copy and the selected page text does not also consume it. #[test] fn selected_text_copy_keeps_focused_input_clipboard_ownership() { let (mut h, at) = harness(|_| {}); @@ -344,7 +346,7 @@ fn selected_text_copy_keeps_focused_input_clipboard_ownership() { h.state_mut().views[0].open_find(); h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); h.run_steps(3); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input"))); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.run_steps(2); @@ -394,7 +396,13 @@ fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { /// One wheel event, with time to scroll (egui spreads a wheel line over a few frames). fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) { - h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + h.event(Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(10); } diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 1559838dd..e5e930a97 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -38,10 +38,10 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme ⏵").hover(); + h.get_by_label("Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index a047e916e..12723274d 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -596,6 +596,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { delta: egui::vec2(-500.0, 0.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -718,6 +719,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { delta: egui::vec2(0.0, -500.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -1717,6 +1719,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { delta: egui::vec2(0.0, -200.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 01e733e4d..28eb6fd76 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -70,7 +70,7 @@ fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> Harness<'s /// A vertical wheel event; negative `dy` scrolls down. fn wheel(unit: MouseWheelUnit, dy: f32, phase: TouchPhase) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE, source: egui::MouseWheelSource::Unknown } } #[cfg(target_os = "linux")] @@ -710,7 +710,13 @@ fn frames_after_a_notch(system_reduced: bool, animation_effects: &str, zooming: h.run_steps(2); let before = rect(&h, 0).expect("page 1 on screen").min.y; let (delta, modifiers) = if zooming { (egui::vec2(0.0, 1.0), Modifiers::COMMAND) } else { (egui::vec2(0.0, -1.0), Modifiers::NONE) }; - h.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta, + phase: TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); (0..40) .map(|_| { h.step(); @@ -1029,7 +1035,7 @@ fn view_menu_page_display_uses_the_same_commands() { h.run_steps(4); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -2007,15 +2013,15 @@ fn long_view_menu_fits_a_short_window() { h.run_steps(8); h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + h.get_by_label("View").hover(); h.run_steps(4); - let before = h.get_by_label("Side panels ⏵").rect().bottom(); + let before = h.get_by_label("Side panels").rect().bottom(); h.get_by_label("Fit to height ").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels ⏵").rect().bottom(); + let after = h.get_by_label("Side panels").rect().bottom(); assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels ⏵").hover(); + h.get_by_label("Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 915c47b41..f2a12b69a 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -154,7 +154,7 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(n, 15); // Surrender text edit focus so canvas shortcut handler receives key events. h.state_mut().views[0].find.as_mut().unwrap().focus = false; - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("find-input"))); h.run_steps(2); let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); // ⌘G advances to next match. @@ -201,6 +201,141 @@ fn find_shortcuts_work_from_the_find_field() { assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); } +/// Open the find bar on the first document, search for the single uppercase "PAGE" on each +/// page, and wait until every text layer has answered. Returns the document's view index. +fn find_one_match_per_page(h: &mut Harness<'static, PdfCraftApp>) -> usize { + h.state_mut().active = Some(0); + h.run_steps(2); + let view = h.state().active.expect("a document is shown"); + { + let v = &mut h.state_mut().views[view]; + v.open_find(); + let f = v.find.as_mut().expect("the find bar is open"); + f.query = "PAGE".into(); + f.case_sensitive = true; + v.rerun_find(); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[view].find.as_ref().is_some_and(|f| f.matches.len() >= 5) { + break; + } + } + assert_eq!(h.state().views[view].find.as_ref().unwrap().matches.len(), 5, "one PAGE on each of five pages"); + view +} + +/// Automatic following ends when the reader scrolls inside the viewed page, not only when the +/// page changes. This drives the production document area with a real wheel event. +#[test] +fn wheel_scrolling_ends_automatic_search_following_on_the_same_page() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + { + let f = h.state().views[view].find.as_ref().unwrap(); + assert_eq!((f.auto_page, f.follow_ended), (Some(0), false), "the automatic choice is following page 1"); + } + let center = h.state().views[view].viewport_rect().center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(12); + let v = &h.state().views[view]; + assert_eq!(v.current, 0, "a small wheel scroll stays within page 1"); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "the reader's wheel ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); +} + +/// A programmatic search flash is not reader scrolling, even while an unrelated drag is held. +/// Holding a text selection on the page keeps a pointer drag active; the flash still needs to +/// move the viewport, and automatic following must survive so a late layer can still follow it. +#[test] +fn a_search_flash_preserves_following_while_an_unrelated_drag_is_held() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let start = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").center(); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Move enough to turn the press into a text-selection drag. That drag owns the pointer but + // does not scroll the document; only the document scroll area scrolls it. + let held = start + egui::vec2(24.0, 0.0); + h.event(egui::Event::PointerMoved(held)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "an unrelated page drag is held"); + assert!(h.state().views[view].page_screen_rect(1).is_none(), "page 2 starts off screen"); + + // A flash to page 2 visibly moves the viewport from under the held drag. + { + let f = h.state_mut().views[view].find.as_mut().unwrap(); + f.query = "Page 2".into(); + f.case_sensitive = true; + } + h.state_mut().views[view].rerun_find(); + h.run_steps(3); + let v = &h.state().views[view]; + assert_eq!(v.current, 1, "the automatic choice moved to page 2"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "a flash's own jump is not the reader scrolling"); + assert_eq!(f.auto_page, Some(1)); + assert!(v.page_screen_rect(1).is_some(), "page 2 is visibly on screen after the flash"); + + // Releasing the unrelated drag leaves the automatic follow in place. + h.event(egui::Event::PointerButton { pos: held, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[view].find.as_ref().unwrap().follow_ended, "the page drag never ended following"); +} + +/// The document scroll bar is its own scroll surface: a track click moves the offset with no +/// pointer motion, and dragging from that press keeps moving it. Both are the reader scrolling, +/// so automatic following ends. A global "any drag is scrolling" check would miss the click. +#[test] +fn scroll_bar_track_click_and_drag_end_automatic_search_following() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let vp = h.state().views[view].viewport_rect(); + // Floating bars sit in the outer few points of the document area (see theme). Aim 75% down + // the vertical track: below the handle at this scroll position, and clear of the corner + // where the horizontal bar's track overlaps. + let track = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.75); + let before = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").top(); + h.event(egui::Event::PointerMoved(track)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + { + let v = &h.state().views[view]; + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a track click that jumps the view ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + let after = v.page_screen_rect(0).map(|r| r.top()); + assert!(after.is_none_or(|top| top < before - 100.0), "the track click moved the viewport ({before} -> {after:?})"); + } + + // Keep dragging from the press: the scroll bar follows the pointer, still reader scrolling. + let dragged = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.4); + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + let v = &h.state().views[view]; + assert!(v.find.as_ref().unwrap().follow_ended, "the track drag is the reader scrolling"); + assert!(v.page_screen_rect(0).is_none_or(|r| r.top() < before - 100.0), "dragging the track kept moving the viewport"); + h.event(egui::Event::PointerButton { pos: dragged, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); @@ -339,7 +474,7 @@ fn page_input_focused(h: &Harness<'static, PdfCraftApp>) -> bool { let focused = h.ctx.memory(|m| m.focused()); let view = &h.state().views[h.state().active.unwrap()]; // Also identifies the original editor when running this test-only patch on the baseline. - focused == Some(egui::Id::new(("page-input", view.id.0))) || focused == Some(egui::Id::new("page-input")) + focused == Some(egui::Id::unique(("page-input", view.id.0))) || focused == Some(egui::Id::unique("page-input")) } /// Exercise the real navigation editor, including its focus and Enter handling. @@ -967,7 +1102,7 @@ fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { let left = h.get_by_label("Left Edge").rect(); let right = h.get_by_label("Right Edge").rect(); // Properties requests a fixed width. Measure its card in each viewport. - let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + let card = h.ctx.data(|d| d.get_temp::(egui::Id::unique("dialog").with("card"))).expect("Properties is shown"); for (n, rect) in &buttons { for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); @@ -1556,7 +1691,13 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[i].page_screen_rect(0).expect("page 1 on screen").min.y; let before = top(&h); h.event(egui::Event::PointerMoved(h.state().views[i].viewport_rect().center())); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); // egui spreads a wheel notch over a few frames. h.run_steps(30); before - top(&h) @@ -1857,6 +1998,7 @@ fn mouse_wheel_scrolls_while_selecting_text() { delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(20); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1896,7 +2038,13 @@ fn selection_wheel_delta(unit: egui::MouseWheelUnit, amount: f32, held: bool, an } h.run_steps(2); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -amount), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -amount), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); before - h.state().views[view].page_screen_rect(0).unwrap().top() } @@ -1930,7 +2078,13 @@ fn selection_control_wheel_still_zooms() { for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { let (mut h, view, _) = selection_wheel_harness(); let before = h.state().views[view].page_screen_rect(0).unwrap().width(); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().width(); assert!(after > before + 1.0, "control wheel should zoom: {before} -> {after}"); @@ -1948,6 +2102,7 @@ fn selection_wheel_outside_document_does_not_scroll() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1975,6 +2130,7 @@ fn selection_wheel_does_not_scroll_behind_preferences() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1991,6 +2147,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -30.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(delay); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -2002,6 +2159,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); diff --git a/crates/ui-egui/tests/snapshots/panel_comments.txt b/crates/ui-egui/tests/snapshots/panel_comments.txt index c5bed5873..ca9e50db8 100644 --- a/crates/ui-egui/tests/snapshots/panel_comments.txt +++ b/crates/ui-egui/tests/snapshots/panel_comments.txt @@ -4,11 +4,11 @@ Button "Close" [off] Button "More options" [off] Button "Filter comments" [off] Button "Search comments" [off] -TextInput +TextInput "Add a comment" Button "Collapse" [off] Label = "Page 1" Label = "1" Label = "Reviewer" Label = "2026-01-01 12:00" Label = "Check this heading" -Unknown +Unknown "Note — click to show it on the page" diff --git a/deny.toml b/deny.toml index ed9c26c20..f6c2028bb 100644 --- a/deny.toml +++ b/deny.toml @@ -34,7 +34,13 @@ unknown-registry = "deny" unknown-git = "deny" # Shared UI components and spooler; Cargo.toml pins the immutable revision. craft-print pins # storytold's winprint fork (Windows printing, #1107) to an immutable revision of its own. -allow-git = ["https://github.com/storytold/craft-libs", "https://github.com/storytold/winprint.rs"] +allow-git = [ + "https://github.com/storytold/craft-libs", + "https://github.com/storytold/winprint.rs", + # The reviewed egui 0.37 and winit 0.30.13 fork revisions (immutable pins in Cargo.toml). + "https://github.com/storytold/egui", + "https://github.com/storytold/winit", +] [advisories] yanked = "warn" diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..2871a57f7 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,812 @@ +# Current integration — 2026-10-25 + +The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`. + +Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. + +Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. The 5,190 result comes from managed run `egui037-pdf-combined-ci-20261025-1340` and qualifies the integration of main `d764a8ebca2dc289841b34ea3048f613a9869bbb` with `Cargo.lock` SHA-256 `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`, published as `fbfad2039c650a02b39ceee5c3f0e4d6fd4b3f74`. The older 5,183-pass result covered commit `9032aeb35f7abe7777f66d4b835fd461a865c516`, integrating main `71b47b51`; it is a separate checkpoint and is not evidence for the current tree. Both runs recorded 22 ignored executions across 89 summaries. + +Remaining acceptance includes native/browser GUI, native IME, packaging and paired performance. Shared-pane F6 cycling and explicit RTL edge mirroring are not enabled. New side-pane command labels still need catalog translations; English fallback remains. Current panel checks must exercise Tools/inspector visibility, Read mode, resizing and persistence, rail selection, F4/Shift+F4, capture and accessible Pane/Splitter labels, rather than the removed floating/tab-group workflows. + +The qualification tables and original porting protocol below are historical records. Their old pins, lock hashes and docking design describe those checkpoints only; this section supersedes them for the current integration. + +> Historical records begin below this notice. Their source identities, lockfile hashes and test counts describe earlier checkpoints only; they do not qualify the current `d764a8eb` integration. The current integration and its 5,190-pass qualification are recorded above. + +# PdfCraft — egui 0.37 porting plan + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `418ac6a2`, including suppression of page link hover while dragging a panel, page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused pointer regressions plus CI recorded 5,130 passing test executions, zero failures and 28 ignored across 143 summaries. The dragged-panel link regression passes on egui 0.37; independent review accepted preservation of the upstream fix. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `24edde65`, including page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused dialog regressions plus CI recorded 5,111 passing test executions, zero failures and 22 ignored across 143 summaries. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status: implemented locally against `a2ec27b4775f53c40cad3456936d9406611e3a8d`; not published. Production code, manifests, vendor provenance and tests in this checkout carry the migration. The 2026-10-25 qualification section below supersedes the historical external-config and missing-IME statements; the 2026-10-24 amendment records the earlier local state. The manifests pin egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-ui/craft-quit/craft-print `25da1314d443171b4adeaad2cd9454b273f35c6f` directly by immutable git revision, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; `Cargo.lock` is `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`, and the target-main observation is `a7801dc84e74dd12757aecbb6be324dce7a33939`. The repository quality gates passed as recorded below; publication still requires fresh human approval. This revision also incorporates the independent adversarial review of the prior plan (`f781bf52d03d59cd2fb4539771c7a0d196e2f49e64c48635e72c224c9bdcdcd0`). + +## Historical qualification state — 2026-10-25 + +The managed run `egui037-pdfcraft-reviewed-ui-ci-20261025-0541` passed the first nine CI stages, including formatting, strict lint, workspace tests, WASM compilation, assets and dependency checks. It recorded 5,025 passing test executions, zero failures and 16 ignored across 198 summaries (including four focused regression executions). The final parity stage found one stale test reference after the Arabic logical-order test was renamed; the reference was corrected without changing executable source. Formatting and parity then passed in `egui037-pdfcraft-parity-final-20261025-0555`. An intervening submission `0553` was refused by the storage guard before execution and is not validation evidence. Final documentation/attribution corrections were checked by formatting, assets generation/validation and parity in `egui037-pdfcraft-final-doc-assets-20261025-0571`. + +The page-text editor keeps the prior four-by-two inset under egui 0.37, and print tests locate the Two-sided control by ComboBox role. Both narrow/wide text image regressions and booklet regressions passed before the full suite. Canonical dependency/source guards retain the vendored security and rendering provenance. The repository WASM gate checks the library layers. The separate web application shell also passed `cargo check --locked --target wasm32-unknown-unknown -p pdfcraft-web` in `egui037-pdfcraft-web-shell-check-20261025-0575`. Native GUI and IME/PRIMARY, browser interaction, packaged WASM size, real-world old settings and paired app performance remain open; these checks do not establish cross-platform acceptance. + +This section supersedes every historical statement below that the egui, winit or +craft-libs revisions resolve through `local-integration-config.toml`, that the +checked-in manifest does not pin them, or that E21b/P7b surrounding-text support is +absent from the candidate. Those statements are planning-era history; the current +tree does not use an external Cargo config. + +| Item | Current value | +|---|---| +| egui family | `6d518fc799681571963842f95b55a6b68431b15a` (storytold/egui) | +| winit | `a517b3846ee8457d84ee4975110bfda6797c44dc` (storytold/winit) | +| craft-ui / craft-quit / craft-print | `25da1314d443171b4adeaad2cd9454b273f35c6f` (storytold/craft-libs) | +| `Cargo.lock` SHA-256 | `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80` | +| Target-main observation | `a7801dc84e74dd12757aecbb6be324dce7a33939` | + +`Cargo.toml` pins those revisions directly and `Cargo.lock` resolves them to the +same immutable git sources; no `local-integration-config.toml` is read. The +retained `vendor/winit` and `vendor/egui-wgpu` copies remain provenance for the +patch text and source guards, not runtime path patches. + +The pinned egui `6d518fc` tree contains the `TextEdit` outgoing +surrounding-text/`IMEOutput` producer and egui-winit calls +`set_ime_surrounding_text`. PdfCraft adds no custom adapter. Native platform +execution and the outgoing-text tests remain unqualified. + +## 1. Baseline and scope + +| Item | Value | +|---|---| +| PdfCraft source | current owned checkout `a2ec27b4775f53c40cad3456936d9406611e3a8d`; the original planning baseline was `0dc77fe4ed8810b9e9f25b9dd050afae662c062a` (`docs/egui-037-plan-20261022`, tree `ebb7259286988cf937ade02ab08db67064b04084`) | +| PdfCraft lockfile | current `Cargo.lock` is the direct-pin qualification lock (SHA-256 `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`); it is not publication evidence. The planning baseline lock SHA-256 was `eaee6973f7c1ba79f5bfc1bfa3da50ef457d13affeb51a3d50d27437804d4cf7`. | +| Planning-baseline egui family | `egui`, `eframe`, `egui-winit`, `egui_extras`, `egui_kittest` 0.36.2 from crates.io; `egui-wgpu` 0.36.2 vendored | +| Planning-baseline winit | 0.30.13 vendored; upstream tag commit `e9809ef54b18499bb4f2cac945719ecc2a61061b` | +| Planning-baseline craft-libs pins | `craft-ui` `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-quit` `f58bc1a3df490992d633393d0898af0588ce882c`, `craft-print` `666c358065261a2816b376fa3bb10538dbf53040`, `craft-docpath` `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` | +| Planning-era shared-libs integration base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; an ancestor of the current craft-libs pin `25da1314d443171b4adeaad2cd9454b273f35c6f` | +| Historical planning observation of craft-libs PR 199 | OPEN / CONFLICTING at the then-inspected head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; API-port ancestor `8614e4a04fae516fba16c22b31ff1008cb6be10b`; audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` | +| Target egui | 0.37.0, upstream release commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | +| Planning-era reviewed egui candidate | `aiongg/craft/0.37` HEAD `a4f9a17f64a6bae61350fb85804c9df0d9865916`; the current pin is `6d518fc799681571963842f95b55a6b68431b15a` | +| Planning-era reviewed winit candidate | `craft/0.30.13-lang` HEAD `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; it lacks W15P PRIMARY, which the current pin `a517b3846ee8457d84ee4975110bfda6797c44dc` contains | +| Shared rollout contract | `craft-libs/docs/egui-037-suite-rollout.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared library plan | `craft-libs/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared egui plan | `egui/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | + +The following paragraph records the pre-merge planning assessment; PR 199 subsequently merged, and the current pinned shared-library candidate includes its integration and later main work. At planning time, PR 199 was an input to the shared-library work, not a branch to merge. Its head and `ec8840a` are stale relative to current main: they omit `craft-platform` and revert security, settings, docking and printing fixes. Only the API-port hunks from `8614e4a` and the README change from `1764bca` are reused, applied onto `3832c47`; the final library revision must be reviewed and published separately. + +This plan covers only PdfCraft. It does not adopt, reset or migrate any other checkout, job, VM or shared target. Historical diagnostics retained in the private task evidence for `egui037-audit-20261022` are pointers only; they are not acceptance evidence. + +Entry points: + +- Native: `apps/pdfcraft/src/main.rs::main` calls `eframe::run_native`; `app_creator` (`main.rs:365`) builds `PdfCraftApp` and wires storage, control, recovery and GPU state. +- Web: `apps/pdfcraft-web/src/main.rs::main` uses `eframe::WebRunner::start` with `WebOptions { renderer: Glow }`, `?file=`, `?host=parent` and the before-unload warning. +- Headless: `crates/ui-egui/examples/shot.rs` uses `egui_kittest::Harness::build_eframe` plus `Harness::render`; `apps/pdfcraft-cli` has no egui/eframe dependency. +- Tests: `crates/ui-egui/tests/it/main.rs` links the integrated UI tests; `apps/pdfcraft/tests/it/main.rs` links twelve winit source guards; `crates/ui-egui/src/control.rs` has the in-crate control/pointer probes; `crates/ui-egui/tests/perf.rs` is the existing timing harness. + +Targets to keep green: + +- `apps/pdfcraft` (native binary; wgpu plus glow fallback, accesskit, persistence, links, x11/wayland, native clipboard). +- `apps/pdfcraft-web` (wasm32, eframe glow; no egui-winit clipboard feature is needed or available there). +- `apps/pdfcraft-cli` (no egui dependency; unchanged). +- `crates/ui-egui` (library, unit tests, `tests/it` integrated binary, `tests/perf.rs`, `examples/shot.rs`). +- `xtask` gates (`fmt`, clippy, test, layers, wasm, assets, deny, parity). + +## 2. Actual lifecycle, features and compatibility surface + +### 2.1 App boundary + +`PdfCraftApp` is declared in `crates/ui-egui/src/lib.rs`; the eframe implementation is `impl eframe::App for PdfCraftApp`: + +- `raw_input_hook` applies line-scroll speed and injects the OS drag pointer position before egui reads the frame. +- `save` repairs minimized-window storage through `window_state::WindowState::keep`. +- `logic` owns host-side work: context capture, window repair, i18n selection, font installation, UI scale, drag/drop completion, inbox/open queues, OS events, control-channel requests, quit guard, autosave, file watching, update/print/export/OCR/optimize/action polls, deferred shortcuts, per-view input blocking, pending edits and picker completion. +- `ui` owns per-pass layout: title command, full-screen or normal panels, tab strip, mode bar, right rail, docking or read-mode central panel, palette, dialogs, progress, toasts, then `finish_render_frame`. +- `finish_render_frame` suspends hidden tabs' render pools and commits the visible/compare views' frame demand. + +`crates/ui-egui/src/canvas.rs` is a second lifecycle: `DocView::begin_render_frame` resets demand and the frame queue; `DocView::finish_render_frame` builds the render queue, feeds the pool and calls `DocView::receive` for a bounded upload batch. + +### 2.2 Lifecycle truth: `logic` is not a frame clock + +At the pinned 0.37 source, both native eframe (`crates/eframe/src/native/epi_integration.rs::update`) and web (`crates/eframe/src/web/app_runner.rs::logic`) call `app.logic` and then `app.ui` inside the `Context::run_ui` closure. A discarded layout reruns both. Hidden or occluded windows can call `App::logic` through `Context::run_logic`, which updates only raw viewport/window-focus information; events, input time, layout and normal pass state remain from the previous UI pass. Input therefore belongs only in a known UI phase (`App::ui` or an explicit method it calls), never in background work that reads `ctx.input` under the assumption that the window is hidden. + +Two corrections are binding: + +- `ViewportInfo::visible()` describes minimized/occluded state; it does not prove that the current callback is logic-only. A hidden UI paint can be requested for screenshots or injected input. +- After a normally completed `run_ui`, `current_pass_index()` is zero even after multiple passes: `ContextImpl::end_pass` takes the viewport output with `mem::take`, and `run_dyn` restores the count only immediately before the next UI pass. A first-pass guard does not by itself skip ordinary hidden `run_logic` calls. It is still not a frame clock and cannot supply fresh input. + +No `run_ui_with_logic`/E22 scheduler API is adopted. There is no stable one-frame boundary inside `run_ui`, and both hosts already run logic per pass. Queues keyed by consumed identity, command/generation identity and monotonic deadlines provide the required behavior. + +### 2.3 Hidden work and current input + +- `App::logic` may run repeatedly in one visible frame and may run alone while hidden. Autosave, recovery, playback/audio, open completion, file watching, polls and undo application stay there, but use monotonic elapsed time (`std::time::Instant`, `web_time::Instant` on wasm), not `ctx.input(|i| i.time)`. +- Fresh wheel, pointer, keyboard and IME input is handled in `App::ui` before panels. `Motion::begin_pass` moves from `logic` into `ui` (before any widget reads wheel input) or is guarded by a fresh-UI-pass token; it must never consume the same `ctx.input` event twice. +- The grid-drop deadline and control-adjacent timers use monotonic deadlines too. +- UI-produced commands may commit promptly from the UI. The requirement is exactly-once consumption, not "never commit on a discarded pass": egui cannot know at commit time whether a later widget will request a discard. Screenshot completion and render queues may be cancelled without side effects; an edit/undo transaction may not be duplicated when the pass is replayed. + +### 2.4 Feature and cfg inventory + +| cfg / feature | Where it matters | +|---|---| +| `target_arch = "wasm32"` | `apps/pdfcraft-web`; `ui-egui` disables `file_watch`, `pickers`, `system_fonts`; `fill_sign` image inbox and host bridge; no native window and no egui-winit clipboard | +| `windows` | hidden-maximized restore, DPI change, DWM frame, IME cursor, auto language, measurement units, DX12 presentation (`apps/pdfcraft/src/main.rs`, `motion.rs`, `single_instance.rs`, winit patches W1/W3/W4/W18/W19) | +| `target_os = "macos"` | Apple events, traffic lights/full-size content view, ⌘Q menu (`craft-quit`), title-bar drag, IME commit/discard/emoji/Korean (W6–W10, W12, W22–W24, W26) | +| `target_os = "linux"` | GNOME/Wayland frame decision, compositor GPU selection, Wayland regular clipboard and W15P PRIMARY (W15/W15P/E4), X11 DPI/IME (W5/W11/W20/W21), Wayland scroll phases (W13) | +| `unix, not macos` | single-instance Unix socket, Wayland/X11 detection, fontconfig directory parsing | +| `not unix` / other | Windows and wasm branches above; no silent fallback to a native-only API | +| Native eframe features | `default_fonts`, `wgpu`, `glow`, `accesskit`, `persistence`, `links`, `wayland`, `x11`, **`clipboard`** | +| `ui-egui` eframe features | `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`, **`clipboard`** for native consumer/test parity | +| Web eframe features | `default_fonts`, `glow`, `persistence`, `accesskit`; no winit clipboard path | +| egui features | `bytemuck` in `ui-egui`; `default_fonts` explicit; **`monochrome_emoji_fonts`** in every egui-consuming target that draws PdfCraft UI text | +| eframe `system_fonts` | stays off; `NativeOptions::system_font_fallback = false` | + +`cargo xtask wasm` checks crates below L8; `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` remains required for the actual web shell. A CI feature-identity assertion must show `egui-winit/clipboard` enabled for native roots after the migration, and `cargo deny`/`cargo metadata` must show exactly the approved fork sources. + +### 2.5 Control channel and screenshots + +`crates/ui-egui/src/control.rs` is the user-observable automation surface. Today it installs `ControlPlugin`, stores the AccessKit tree and injects queued input in hooks, handles requests in `Control::tick`, receives screenshots as `egui::Event::Screenshot`, sends `ViewportCommand::Screenshot(UserData::new(tag))`, crops and PNG-encodes in `screenshot_png`, and times out after 5 seconds. Its tests live both in `src/control.rs` cfg-test modules and `crates/ui-egui/tests/it/control.rs` (65 tests, linked by `tests/it/main.rs`), including discard handling and `screenshots_of_window_and_region`. PR 1 updates both sets. + +egui 0.37 removes `Event::Screenshot` and changes `ViewportCommand::Screenshot` to a one-shot `ScreenshotCallback`. A pending screenshot command itself asks the integration to paint a hidden window; `RequestPaintWhileHidden` is additionally required when injected input must be applied without a screenshot. + +`Shared::passes` is a pass counter, not a frame counter. The migration renames the unit to accepted frames and increments it in `output_hook` only when `output.platform_output.request_discard_reasons.is_empty()`; an intermediate discarded pass does not count. A request made before a forced discard is not answered before the intended number of accepted frames. + +### 2.6 Panels, docking and window chrome (planning baseline) + +PdfCraft uses the 0.37 `Panel` API already in its 0.36 form: + +- `chrome::tab_strip`: `Panel::top("tab_strip")`; +- `chrome::mode_bar`: `Panel::top("mode_bar")`; +- `chrome::right_rail`: `Panel::right("rail")` and `Panel::bottom("rail-nav")`; +- read-mode inspector: `Panel::right("read_right_panel")`; +- central panel in `lib.rs`. + +0.36 stores `PanelState` under the panel `Id` passed to the constructor. 0.37 derives it as `parent_ui.scope_id().with_salt(IdSalt::new(id_salt))`; the panel child `Ui` is also salted with the new panel ID. Every named panel key therefore changes, and in-panel widget/focus/scroll state may reset. This is not left to implementation: §6.2 migrates the persisted keys in the first compatibility PR and tests the upgrade from a 0.36 fixture. + +Docking is `crates/ui-egui/src/docking.rs`; `DockArea::new(egui::Id::unique("pdfcraft-docking"))` wraps `craft_ui::docking`. Persistence is PdfCraft's own `Workspace` JSON, not egui panel memory. `DockArea::direction` is a new opt-in stock-compatible API planned in craft-libs CL-3; the explicit RTL adoption in PR 4 waits for that reviewed revision. Its visual policy preserves saved physical splits, rectangles and logical tab order. + +### 2.7 Window frame, decorations and restoration + +`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. + +`craft-window` owns `Frame`, `Layout` and `Button`; `window_controls.rs` currently has distinct duplicate types and its own GNOME `button-layout` parser. The migration makes `window_controls` re-export `craft_window::{Button, Layout}` and removes the duplicate parser. `decorations::app_drawn` uses `craft_window::Choice::probe(Prefix::new("PDFCRAFT"))`; `PDFCRAFT_TITLEBAR` accepts `app`, `system` and `desktop` (the last two force the desktop frame), matching craft-window. PdfCraft still applies `with_decorations(false)` only when it will draw its own header bar, and the runtime “Use system title bar” preference still toggles `ViewportCommand::Decorations`; craft-window decides the startup frame, not the runtime preference. The probe is called once at startup and never from `App::ui`. + +### 2.8 Fonts and language + +`theme::install_fonts_for` calls `ctx.set_fonts(installed_font_definitions(cjk))`; `installed_font_definitions` appends one installed face plus optional script fallbacks. `theme::on_interface_baseline` computes `FontTweak::y_offset_factor` from primary and fallback metrics. `system_fonts::fallback` is the single-face, last-resort policy; `PDFCRAFT_SYSTEM_FONTS=0` disables it. + +At the planning baseline, the i18n layer preordered right-to-left translations at catalog load (`i18n::LangInfo.rtl`, `catalog.map_translations(display_rtl)`), and `bidi::display_rtl`, `bidi::visual`, `bidi::wrap` and `bidi::visual_auto` preordered runs because egui 0.36 does not. The current migration removed catalog preordering; `display_rtl` and the other preordering helpers remain test-only. The fork's E5/E12/E13/E15/E16/E17/E20 own reordering now. Keeping `display_rtl` in production alongside E5 would double-reorder text. + +### 2.9 Clipboard, IME and input + +Text copy/paste goes through egui events; images and file paste use `arboard` directly. Native eframe 0.37 no longer enables `egui-winit/clipboard` implicitly when `default-features = false`; without the explicit feature, text cut/copy/paste loses OS integration and the Wayland clipboard/PRIMARY path is compiled out. `arboard` image/file code can mask that loss in a superficial check. + +PdfCraft uses ordinary `egui::TextEdit` for its dialogs, forms, comments composer and text editor; it has no custom IME editor producer requiring an app-side surrounding-text adapter. The pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` surrounding-text path, and egui-winit calls `set_ime_surrounding_text`; native platform execution and outgoing-text tests remain unqualified. + +## 3. Dependency, lockfile and vendor audit + +### 3.1 Manifests + +| Manifest | egui/eframe facts and required change | +|---|---| +| `Cargo.toml` workspace | egui/eframe/egui_extras/egui_kittest 0.36; `[patch.crates-io]` contains `winit = vendor/winit` and `egui-wgpu = vendor/egui-wgpu`. Replace window-stack path patches with approved immutable fork revisions; retain PDF vendors | +| `crates/ui-egui/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`; add `clipboard`; egui features gain `monochrome_emoji_fonts`; egui_kittest dev-dep `eframe`, `wgpu` | +| `apps/pdfcraft/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11`, `persistence`, `links`; add `clipboard`; Linux dev-dep winit 0.30 for the scroll-policy test | +| `apps/pdfcraft-web/Cargo.toml` | eframe features `default_fonts`, `glow`, `persistence`, `accesskit`; no clipboard feature or egui-winit dependency | +| `apps/pdfcraft-cli/Cargo.toml` | no egui/eframe; unaffected | + +egui 0.37 no longer enables `default_fonts` by default. PdfCraft explicitly enables it in its own features. `monochrome_emoji_fonts` is chosen (not replacing `✔`/`▲`/`▼` with new SVG glyphs): the fonts are already OFL/MIT, attributed, and preserve existing text symbols deterministically with `PDFCRAFT_SYSTEM_FONTS=0`. The painted submenu arrow is not a font glyph after 0.37 and needs no emoji font. + +### 3.2 Lockfile duplicates and family resolution + +At planning time the lockfile contained one egui family at 0.36.2; the current qualification lockfile resolves the family at 0.37.0 (see `Current qualification state — 2026-10-25`). Several `vello_common`/`vello_cpu` minors still come through the PDF renderer and epaint. The target state must prove one egui family, one epaint family and one winit: + +- `egui`, `epaint`, `emath`, `ecolor`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_extras`, `egui_kittest` all at 0.37.0 from one source. +- `winit` at 0.30.13 with the craft language patches, from one source. +- `wgpu` remains 30.x; `egui-wgpu` matches the resolved wgpu. +- `accesskit` moves 0.24.x → 0.25.x; `accesskit_winit` 0.32.x → 0.34.x; epaint moves to `vello_cpu` 0.2.0 while hayro remains on 0.0.8. E11 is not required for PdfCraft because these versions cannot unify (verify with `cargo tree -e features -i vello_cpu`). +- Verify `cargo metadata` package versions and sources, not tag names. + +### 3.3 Vendored winit provenance and compatibility + +`vendor/winit` is 0.30.13 (`vendor/winit/.cargo_vcs_info.json` = `e9809ef5…`) and carries **W1–W13**, not W1–W15. It has no language APIs. The planning-era candidate winit fork `8b35388b` carried W1–W15 plus W17–W27 but not W15P; W16 is not in this cut. W14 (`c488d2e9`, macOS cancelable termination plus `dpi` from crates.io) and W15 (`cf73a29c`, `b05ce129`, per-seat Wayland data device and regular clipboard) are in the fork, not the current vendor. + +W14 remains opt-in in the shared winit revision and is **not enabled** by PdfCraft; its existing termination path stays `craft-quit`. Validate that `dpi` resolves once with the git source. W15 regular clipboard is required by E4. In the planning-era candidate `8b35388b`, the **W15P** PRIMARY implementation was a prerequisite added by the shared winit work, using SCTK 0.19.2's `PrimarySelectionManagerState`/device/source/offer on the existing connection and event queue, and had to land before egui P2b. The current pin `a517b384` contains W15P (`PATCHES.md` W15P row; `src/platform_impl/linux/wayland/clipboard.rs`) and W27; native evidence remains open. No second regular `wl_data_device` is acceptable. + +The egui fork requires companion winit APIs on their target platforms. Integrate and review the series in the approved public storytold fork, compare every current PdfCraft vendor delta, transfer behavioral tests, then replace the path patch with the final immutable git revision. Record provenance and old/new source hashes in migration evidence. Keep the original vendor until equivalence is established; do not publish an interim personal-fork branch dependency. + +### 3.4 Source-guard migration and exact inventory + +`apps/pdfcraft/tests/it/main.rs` links twelve winit guard modules. All twelve must be transferred to the winit fork (or replaced by equivalent fork-owned immutable tests), and only behavioral/ABI tests remain in PdfCraft: + +`winit_macos_emoji_picker`, `winit_macos_ime_commit`, `winit_macos_ime_discard`, `winit_macos_korean_first_key`, `winit_macos_korean_space`, `winit_macos_title_bar_drag` (W12), `winit_wayland_scroll` (W13), `winit_wayland_text_input` (W2), `winit_windows_hidden_maximized` (W4), `winit_windows_ime_cursor` (W3), `winit_x11_dpi` (W11), `winit_x11_ime_focus` (W5). + +PdfCraft keeps the retained `vendor/winit` and `vendor/egui-wgpu` copies as provenance and may read them only for provenance assertions (W1 patch text, base hashes, the W1–W13/E2+E3 rows), never as proof of the code the app runs. The resolved graph is checked from `Cargo.lock`: `vendor_graph.rs` requires the exact canonical storytold git URL, 40-character revision and fragment for the two crates it inspects, `winit` and `egui-wgpu`, and rejects path sources. W17 covers W2, W3, W5–W10, W12 and W13; add W1, W4, W11 and the egui-wgpu behavioral coverage to the same central evidence map. The W15/W15P guard belongs on the winit fork or a behavior test, never on `vendor/winit`. + +### 3.5 Vendored egui-wgpu provenance + +`vendor/egui-wgpu` is 0.36.2 (`49682f8b…`) with E2+E3. The target is 0.37.0 plus the same two patches. The reviewed fork's E2+E3 commit is `9dd799ab` and adds a `surface_fit` unit test; PdfCraft also has stronger guards: + +- `apps/pdfcraft/src/main.rs::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it` +- `apps/pdfcraft/src/main.rs::surface_fit_never_empties_or_overflows_a_surface` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_surface_fit_identity` +- `apps/pdfcraft/src/main.rs::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_catch_errors_identity` +- `apps/pdfcraft/src/main.rs::pipeline_creation_error_is_returned_and_device_remains_usable` + +Integrate E2+E3 and the behavioral regressions into the central 0.37 fork, then consume the approved revision. Replace the source-path/source-absence assertions with immutable package identity checks. `surface_texture_limits` and `configure_gpu` remain: the first raises the enabled 2D limit, the second preserves adapter selection, DX12 presentation and indirect-validation behavior. + +### 3.6 craft-libs: port only, and account for craft-print/GDI/rgb drift + +PR 199's egui port changes `craft-ui` roles, `Id::unique`/`Ui::scope_id`, `egui_extras`/`egui_kittest` 0.37, and the docking fixture's `⋮` → `…`. Reuse those API hunks on current main; do not import the stale tree. PdfCraft needs `craft-ui` for docking/icons/tabs/layout, `craft-window` for the frame decision, and no wholesale move of its PDF-specific print UI into `craft_ui::printing`. + +Bumping all four craft-libs crates from their four current pins listed in section 1 to a revision based on `3832c47` carries more than a new field: + +- `craft-print` adds the unconditional `craft-winprint-gdi` dependency (`storytold/winprint.rs`, revision `417ac3c4…`), a public `rgb` re-export of the GDI transport, `lpstat -l` description parsing and `Printer.description`. +- Audit `craft-winprint-gdi` licensing/target cfg/`cargo deny`/Windows build, and the non-Windows `rgb::PrintError::Unsupported` behavior. +- `Printer` now has `description`; update every literal in the repository. At baseline there are 19 `Printer { … }` literals across `crates/print` and UI tests, not 13. This is a mechanical compile fix, with the craft-print tests and Windows native checks re-run. +- Keep PdfCraft's own `print_ui.rs` sheet preview and options. `description` is not automatically displayed; a product change to show it is separate. +- The current main already includes intervening security bounds, settings merge, docking permissions and linear printer-name collision checks; preserve them. The shared-library plan's CL-1 diff gate remains mandatory. + +### 3.7 Publication and duplicate-resolution rules + +- Pin immutable revisions. A branch name is not a pin. +- `[patch.crates-io]` in the egui or winit repository does not propagate into PdfCraft. PdfCraft's root must patch every resolved egui-workspace package, including `egui_glow` for GL, `epaint_default_fonts`, the used core/render/test crates, and winit. +- Use approved public immutable storytold fork revisions after vendor equivalence review. Never add a machine-local absolute path patch or publish another vendor fork to bypass dependency publication. +- Add a CI assertion that `cargo metadata` resolves exactly one package ID for each family crate and one winit, and that no `path` dependency points outside the repository. + +### 3.8 Vendors outside the UI port + +`vendor/hayro*`, `vendor/lopdf` and `vendor/vello_common` are renderer/text-engine vendors. Keep their revisions, patches and tests. Inspect vello package identities and feature unification; incompatible versions may legitimately coexist. Do not force unrelated major versions to unify or edit PDF renderer vendors merely to make `cargo tree -d` empty. + +## 4. egui 0.37 breaking changes that touch PdfCraft + +| Change | PdfCraft use | Required action | +|---|---|---| +| `default_fonts` no longer a default feature | explicit in ui-egui, pdfcraft, web, craft-ui dev-dep | keep explicit | +| `monochrome_emoji_fonts` gates emoji faces | `✔`, `▲`, `▼` and previously `⏵` text | enable the feature; add `egui-icons.ttf` attribution; keep painted submenu arrows out of font coverage | +| eframe `clipboard` becomes explicit | native eframe with `default-features = false` | enable in native app and native UI tests; web needs none; add feature-identity check | +| `WidgetType` deprecated alias for `Role` | 52 references, 56 `widget_info` closures | map roles explicitly | +| `Id::new` deprecated → `Id::unique` | 74 references | semantic rename | +| `Ui::id()` deprecated → `Ui::scope_id()` | 20 references | mechanical rename; `scope_id()` is the value `id()` returned, not a new inherited ID | +| `UiBuilder::id` deprecated → `scope_id` | `canvas.rs:2607`; not `chrome.rs:697` | rename only the UiBuilder call | +| `Panel` constructors take `AsIdSalt`; panel keys derive from parent scope | 5 panels | migrate 0.36 `PanelState` keys in PR 1 and test the upgrade | +| `Ui::shrink_clip_rect` for panels | all panel content | test clip intersection and overlays | +| `ViewportCommand::Screenshot` callback; `Event::Screenshot` removed | `control.rs` | bounded weak-slot callback; screenshot command itself paints a hidden window | +| `Event::MouseWheel` gains `source: MouseWheelSource` | 41 references | add source; use `Unknown`/`Wheel`/`Trackpad`/`Momentum` only | +| `ScrollStyle.kinetic` / rubber-banding | `fling.rs`, `wheel_pager.rs`, `canvas.rs` | keep PdfCraft fling as document owner; leave egui kinetic for ordinary ScrollAreas; prove one curve | +| `NativeOptions.system_font_fallback` and `system_fonts` feature | not enabled today | set false and guard | +| kittest panics on missing glyphs and unnamed inputs | 204 harness constructions in `tests/it` | fix names/glyphs in PR 1; no global disabling | +| `Event::PasteImage`, `MiddleClickPaste`, PRIMARY | image paste via arboard | adopt the W15P PRIMARY contract; no app-level filtering of the event | +| `Response::accessible_name`, `Ui::label_inputs_by`, `Ui::name_inputs` | custom widgets and text fields | use for accessible names | +| Submenu arrow is a painted `arrow_atom` | test labels `"File ⏵"`, `"View ⏵"`, etc. | update labels/accessible names and keep an arrow-painted assertion | + +## 5. Fork patch audit: E1–E21 and E21b (planning-era baseline) + +Base: `0.37.0` = `841a328b`. The E1–E21 audit below was made against the planning-era fork head `a4f9a17f`; the current qualification pin is `6d518fc` (see `Current qualification state — 2026-10-25`), and the E21b row records the later pin. The patch table in the fork's `PATCHES.md` is provenance; each row was re-audited against the source at `a4f9a17f` and PdfCraft's needs, so rows naming `a4f9a17f` are planning-era history. + +| # | Fork commits | Crate | Upstream status at 0.37.0 | PdfCraft need | PdfCraft tests / removal condition | +|---|---|---|---|---|---| +| E1 | `8f3abf06` | eframe | not upstream; `ViewportHooks` absent | none today: PdfCraft opens one root viewport; `--new-window` is a separate process | compile only; keep for craft-tablet. Remove when upstream adds per-viewport hooks or craft-tablet no longer needs it | +| E2+E3 | `9dd799ab` | egui-wgpu | not upstream; #8361 open | required: `Surface::configure` panic and start-up errors | six `apps/pdfcraft/src/main.rs` surface/GPU guards plus fork-owned behavioral tests; remove when upstream fixes #8361 and the start-up error path | +| E4 | `66bacf23` | egui-winit | upstream #8657 draft; 0.37 still uses smithay | required on Hyprland and every Wayland compositor serving one data device | W15 regular clipboard plus W15P PRIMARY; native Wayland single-data-device test; remove when upstream has the behavior | +| E5 | `0e27d87e`, `777977b7`, `f7e89e23`, `5b2b8e19` | epaint, egui | #8577 open, #8698 reviewed; not in 0.37 | required for Arabic, Kurdish, Hebrew and mixed-direction text | bidi shaping, caret, selection, mark and no-default-fonts tests; remove only when upstream merges #8577 plus fixes | +| E6 | `0abfabcf` | egui | not proposed | required: caret moves must not kill IME composition | text-edit IME focus test; remove when upstream has equivalent | +| E7 | `0670c58a` | egui-winit | not upstream | required: shortcuts on Russian/Arabic/Hebrew Windows layouts | native Windows layout test plus source guard; remove when upstream | +| E8 | `1b03c10f` | egui | not upstream | required: Thai/Vietnamese/Arabic marks above and below the row | TextEdit pixel/ink test; remove when upstream | +| E9 | `b2f5cafc`, `e1341a3d`, `5105330d`, `75a5b3d3` | egui | not upstream | required for Arabic/Kurdish chrome (menus, scroll bars, combo boxes, headers) | RTL chrome geometry tests, no double-mirroring; remove when upstream | +| E10 | `dc90c13e`, `c8e573f7`, `34b2c0d4` | eframe web | not upstream; 0.37 has no `web_keys.rs` | required for the web build on non-Latin layouts and named keys | web build + browser keyboard test; remove when upstream | +| E11 | `92e61afd` | epaint | #8641 draft; not in 0.37 | not required by PdfCraft: epaint 0.2 vello_cpu cannot unify with hayro 0.0.8 | verify with `cargo tree -e features -i vello_cpu`; keep because other suite apps enable multithreading; remove when upstream flushes | +| E12 | `e955bb18`, `485c0b89`, `0185887c`, `7015ac23`, `4067b2c4`, `620e070e` | epaint, egui | not upstream; follow-up to #8577 | required for paragraph direction, RTL alignment, ellipsis at the visual end, UAX #9 L1 and linear reordering | direct direction/ellipsis/L1 tests; remove when upstream | +| E13 | `5e4da7f4` | epaint, egui | not upstream | required for caret/hit-test/Delete/Backspace by grapheme cluster | emoji, combining accent, Devanagari cluster tests; remove when upstream | +| E14 | `cbd77549`, `b3722ac0` | epaint, egui | not upstream | required: fallback faces on the primary baseline and rows growing to fit | replace PdfCraft's manual `y_offset_factor`; baseline/row-height tests; remove when upstream | +| E15 | `f1cae609` | epaint, egui | not upstream | required: `LineBreaker` trait, grapheme-safe emergency breaks, `LayoutJob::language` | CJK/Thai/Hindi line breaking tests; remove when upstream | +| E16 | `b3ef9771`, `bed6dfe3` | epaint, egui | not upstream | required: visual cursor movement with affinity, Home/End in reading order, password mask per cluster, neutral paragraph direction | bidi cursor tests and password-mask test; remove when upstream | +| E17 | `520435df` | egui | not upstream | required: `Context::set_language`, `UiBuilder::language`, AccessKit root language, one text run per bidi level | AccessKit language and text-run tests; remove when upstream | +| E18 | `89de8f3c` | eframe | fork-only | required as the bridge from PdfCraft's language to E17 and web screen reader/IME `lang` | `Frame::set_ui_language` test; remove when upstream | +| E19 | `d64047bf`, `642b74f7`, `baeb4515` | egui-winit, egui | fork-only until winit has the APIs | required for Latin shortcut letters on non-Latin layouts | needs W23/W25; app shortcut test; remove when upstream | +| E20 | `23955535` | egui | fork-only | required so widget text uses the `Ui` language for shaping and line breaking | language/line-break test; remove when upstream | +| E21 | `a70470b0` | egui-winit, egui | not proposed | required for macOS press-and-hold/dictation and Wayland IME replacing exact bytes | needs W26/W27; byte-count IME test; remove when upstream | +| E21b/P7b | `6d518fc799681571963842f95b55a6b68431b15a` | egui-winit, egui | present in the pinned fork; native execution unqualified | bounded outgoing surrounding-text payload, TextEdit producer and Wayland W27 adapter are present in the pinned tree | logical/byte-boundary/password/focus/over-limit tests; native Wayland trace remains open | + +### 5.1 Audit notes and traps + +- **E2+E3 are not one patch.** The surface fit and the error scopes protect different failures. +- **E4 + W15P.** Regular clipboard uses the winit data device (W15). PRIMARY is a distinct protocol object on the same connection/event queue and per-seat lifecycle (W15P), using SCTK's PRIMARY objects. It must not bind a second regular `wl_data_device`. Preserve password exclusion and no unchanged-selection reclaim. No “filter `MiddleClickPaste`” substitute is acceptable. +- **E21b is present in the pinned tree.** The planning-era candidate lacked the producer; pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` path and egui-winit calls `set_ime_surrounding_text`. PdfCraft uses ordinary TextEdit, so it needs no custom model adapter; native platform execution and outgoing-text tests remain open. +- **E5/E12/E13/E16 are a dependency chain.** Do not cherry-pick E12 without E5 or E13/E16 without E5. +- **E14 supersedes PdfCraft's baseline hack but not its Windows hinting.** Keep JetBrains Mono `HintingTarget::Smooth`. +- **E15/E20 change line breaking.** Native wrapping must be the only wrapper once active. +- **E17/E20 do not automatically force base direction.** PdfCraft sets `TextDirection::RightToLeft` where the UI locale requires it. +- **E19 is useless without W23/W25; E21 is useless without W26/W27.** +- **E11 is not required by PdfCraft.** + +### 5.2 winit patch provenance and compatibility + +Base: `v0.30.13` = `e9809ef54b18499bb4f2cac945719ecc2a61061b`. + +The retained vendor carries W1–W13. The planning-era reviewed language branch `8b35388b` carried W1–W15 plus W17–W27 but not W15P. W16 is not in this cut. The current pin `a517b384` contains W15P PRIMARY and W27 (`PATCHES.md`); native evidence remains open. + +| Group | Patches | Purpose | Upstream / removal | +|---|---|---|---| +| Windows DPI/frame/IME | W1 `b357a332`, W3 `fd104a4c`, W4 `3fffb7ce`, W18 `894bf2d7`, W19 `fa2e1b3d` | mixed-DPI suggested rect, Korean IME cursor, hidden maximized restore, hidden system caret, IME-consumed key presses | W1 merged on master; W3/W4 open; W18/W19 to be proposed | +| Wayland | W2 `13ca693b`, W13 `5506b1a9`, W15 `cf73a29c`+`b05ce129`, W15P (present in the pinned fork), W27 `f40c76a2` | text input owned by the seat, scroll phase policy, per-seat data device and regular clipboard, PRIMARY on the same connection, text-input-v3 incoming delete plus the outgoing surrounding-text API that E21b sends | W2 merged; W13 to be proposed; W15 #4729 open; W15P present in the pinned fork; W27 has master equivalents | +| X11 | W5 `283370ce`, W11 `4df8554b`, W20 `019c95f1`, W21 `23bb19a7` | focused replacement IME context, server DPI fallback, one input context per window, quiet IME spot | W5 open; W11/W20/W21 to be proposed; W20 supersedes W5 | +| macOS | W6 `a82b90fb`, W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W12 `7facf001`, W14 `c488d2e9`, W22 `0816871e`, W23 `c8cd8aa1`, W24 `4b5bc5a0`, W26 `2b9cd1cf` | NSTextInputClient commit flow, discard marked text, emoji picker, Korean space/first key, title-bar drag, cancelable termination, IME ranges, ⌘-layout names, reset, byte-count delete | W14 is opt-in and not enabled by PdfCraft; others open/to be proposed | +| Language/tests | W17 `2be27382`, W25 `eb4007da` | regression source guards; `platform::latin_layout::KeyEventExtLatinLayout` | tests go with their patches; W25 required by E19 | + +Compatibility rules: + +- The egui fork's `egui-winit` calls `winit::platform::latin_layout::KeyEventExtLatinLayout`, `KeyEventExtMacOS::key_with_command`, `WindowExtMacOS::take_ime_delete_surrounding` and `WindowExtWayland::take_ime_delete_surrounding`; E21b also calls `WindowExtWayland::set_ime_surrounding_text`. +- W14 remains compiled in the shared fork but not opted into by PdfCraft; `craft-quit` remains its terminate path. +- Re-run winit policy/unit tests in the selected fork and app-facing regressions. Native IME/compositor acceptance still requires actual workflows. + +## 6. Migration design + +### 6.1 Frame boundary and exactly-once side effects + +Keep the existing `eframe::App` hooks. Adding a private phase type is allowed only if implementation proves it simplifies real state ownership; do not add a scheduler API or rerun the whole app through a blanket `Context` bridge. + +1. `logic` remains the owner of queues, autosave, recovery, file watching, open completion, background polls, control request admission, undo application and other host state. Replayed passes must not re-enqueue an action, duplicate a command or add a second undo step. +2. UI-produced editing commands may apply in the UI, as they do today (`chrome.rs`, `canvas.rs`, `dialogs.rs` call `execute`/`apply_edit`). Preserve promptness. The rule is exactly-once by command/response ID or document generation, not “zero commits on discard”. A discarded pass may repeat layout; it must not repeat the edit. +3. Inventory every UI-side `execute`/`apply_edit`/undo path and add a forced-late-discard test using a real toolbar action. A per-pass pending-command queue keyed by `Response::id`/command identity is preferred over moving all UI side effects into logic. +4. `DocView::begin_render_frame` may collect per-pass demand. Render/upload commits use document/render generation, not `!ctx.will_discard()` as the correctness boundary. A late discard may discard demand work but must not lose an already-produced first-pass texture. +5. `Control::tick` remains in `logic` for queue servicing. It sends `ViewportCommand::RequestPaintWhileHidden` while a screenshot or injected input waits. Injected input is consumed once; a replay does not replay the click. +6. Autosave, recovery, grid-drop timeouts and playback use monotonic time. `Motion::begin_pass` is called from `ui` before panels; no logic-only call consumes `ctx.input` or smooth-scroll state. +7. `Frame::set_ui_language` runs after language resolution, once per pass only when the resolved language changes; it does not read stale input from `logic`. +8. Never infer a logic-only phase from `visible() == Some(false)`; hidden UI paint is a known UI phase and must handle current input. + +### 6.2 Panels, docking and persistence + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +The first compatibility PR implements an actual one-time panel-key migration. For each of `"tab_strip"`, `"mode_bar"`, `"rail"`, `"rail-nav"` and `"read_right_panel"`: + +- old key (0.36): `Id::new(salt)`; +- new key (0.37 root panel): `ctx.viewport_id().root_ui_id().with_salt(IdSalt::new(salt))` (equivalently `parent_ui.scope_id().with_salt(IdSalt::new(salt))` for the root). +- Before the first 0.37 UI pass, if the new key has no persisted `PanelState` and the old key does, read the public `PanelState` and insert it under the new key. Keep the old key untouched for rollback. Do not reset a width that can be migrated. + +Test with a synthetic 0.36 memory fixture: write the old key with a known outer width, run the 0.37 app, and assert the panel width survives within 1 point. Dump the old/new IDs for one panel and one child widget. A one-time reset of transient in-panel focus/scroll state is explicitly accepted only if the upgrade test demonstrates it; persisted widths are not reset. PdfCraft's own `docking::Workspace` layout is not touched. + +Further tests: + +- Panel order: tab strip → mode bar → right rail → central panel. Read-mode inspector must not reorder the rail or duplicate a panel ID. +- A panel's content clip is a subset of the panel rect and parent clip; popups/comment overlays use `Area`/`Window` layers. +- Floating docking, tab detach, group move and split resize survive restart from `Workspace` JSON, not egui memory. +- `DockArea::new(Id::unique("pdfcraft-docking"))`; keep the same salt for craft-ui state. If RTL is selected, pass the explicit direction; persisted split/rectangles/logical tab order stay unchanged. + +### 6.3 Viewports + +PdfCraft has no deferred or immediate child viewports. Keep `ViewportId::ROOT` as the only production viewport. `window_state` and tests already use it. E1 `ViewportHooks` remains a suite feature, not a PdfCraft requirement. Do not add popout document windows in this port. + +### 6.4 Screenshot callback correlation + +Replace `ViewportCommand::Screenshot(UserData)` plus `Event::Screenshot` with a bounded, cancelable request record: + +- one completion slot per admitted request, owned by that request; the callback holds only a weak reference to the slot; +- callback stores at most one `Arc` and wakes the owning root viewport after releasing the slot lock; it never encodes PNG, writes a file, runs commands, or strongly captures `Context` in a cycle; +- `Context::request_screenshot` or `ViewportCommand::Screenshot` with the one-shot callback; `finish_pending` takes each completed slot exactly once and replies on the app thread; +- cancellation, timeout and viewport close drop/cancel the slot so a late callback cannot produce a file or second reply; +- admission defaults are 4 requests, 128 MiB/image and 256 MiB aggregate reserved + in-flight/completed image bytes. These cap pending requests and aggregate image bytes. Reserve checked bytes before issuing a capture, using the allowed surface dimensions; revalidate dimensions before issuance after resize/DPI changes. Release reservations on completion, cancellation, oversize/stale rejection and shutdown; tests assert retained bytes return to zero; +- store issuance-time target-UI logical viewport rect, `pixels_per_point`, requested region + and geometry epoch in the slot. Reject/retry any resize/DPI/zoom/surface epoch change + before save, even a change back. Callback arguments contain pixels only. The callback image may be smaller because E2 surface fitting reduced it; derive x/y image scale from the returned dimensions and recorded logical bounds, verify aspect/rounding, and do not use `ctx.pixels_per_point()` at completion. A resize/DPI generation mismatch retries within the deadline or returns a defined error; +- PR 1 waits for the approved craft-ui CL-2F completion-slot helper and adopts it directly; no temporary app-local implementation. Preserve PdfCraft request/path/region/reply policy in `control.rs`. + +Hidden-window tests are separate: + +- a hidden window with queued injected input needs `RequestPaintWhileHidden` to apply it; without the command the input times out; +- a pending screenshot command itself requests a paint. A request still queued in app + logic, or awaiting settling before UI issuance, needs `RequestPaintWhileHidden` to reach + that point; test admission and already-issued capture separately; +- keep late-callback, cancellation, two-region/out-of-order, close, admission overflow and DPI-change tests. Assert exact reply counts, dimensions/pixels, bounded memory and eventual queue drain. + +The dependency boundary also requires P2b's bounded asynchronous W15/W15P transfer API +(total deadline, cumulative byte cap, bounded workers/queue, completion wake and cancellation), +and P7's emitted-key identity paired by physical key for repeat/release. PdfCraft adds slow/trickle, +oversized/canceled paste and event-loop responsiveness checks, plus ordinary held-key/Ctrl+Z +layout-switch, release-modifier-first and focus-loss cases. Do not copy those internals locally; +consume the reviewed revisions and assert app-visible behavior. Clipboard shortcut keydown may +be intercepted before egui sees a Key event, so Ctrl+C/Cmd+V alone is not a stuck-key test. + +### 6.5 Fonts and glyph coverage + +Keep PdfCraft's single-face, last-resort system-font policy. Do not enable eframe's `system_fonts` provider silently. Set `NativeOptions.system_font_fallback = false` and keep a source/feature guard that `eframe/system_fonts` stays off. + +Glyph decision: enable `egui/monochrome_emoji_fonts` in `pdfcraft-ui-egui`, the native app and the web dependency graph. This keeps the existing `✔`, `▲` and `▼` text symbols and the OFL/MIT attributed Noto Emoji/emoji-icon fonts deterministic with `PDFCRAFT_SYSTEM_FONTS=0`; do not replace them with new SVG assets in this migration. The new painted submenu arrow is an atom, not text, and is removed from glyph expectations. + +Attribution and assets in the first green PR: + +- treat the 0.37 font change as a deliberate fixture change: `⋮` (U+22EE) was never covered by the bundled faces, and the craft-libs test fixture moves to `…`; do not restore a tofu-drawing fixture. Production `…`/`−` remain covered; +- update all four `[[bundled]]` `epaint_default_fonts` entries from 0.36.2 to 0.37.0; +- add `egui-icons.ttf`, SHA-256 `122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8`, recorded as the MIT-licensed `emoji-icon-font` subset by John Slegers, with its exact usage; +- run `cargo xtask assets --write` and then `cargo xtask assets`. + +Adopt E14 for fallbacks: set `FontTweak::fallback_align = FallbackAlign::Baseline` for script fallbacks; remove metric-derived `y_offset_factor`; keep Windows JetBrains Mono hinting. Add a glyph-coverage test for every UI literal and catalog translation with `MissingGlyphPolicy::Panic` (or `Fonts::has_glyphs`) for embedded-only, craft-fonts, each script fallback, and `PDFCRAFT_SYSTEM_FONTS=0/1`. A missing glyph is a failure, not a snapshot update. + +### 6.6 Language and RTL + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +End state: + +- `logic` resolves the language and calls `frame.set_ui_language(Some(code))` (E18), setting `Context::language` (E17). +- Translated UI text stays logical in catalogs. Remove `catalog.map_translations(display_rtl)`, `bidi::display_rtl` and the matching label preordering. Rely on E5/E12. +- Use explicit `TextDirection::RightToLeft` for `ar`/`ckb` labels whose first strong character may be Latin or a placeholder; use `LeftToRight` for file/path labels where the extension must stay at the end; use `Auto` for document-authored text. A small local direction helper is used only where multiple call sites need it; no new fork-only API. +- Replace `bidi::wrap`/`widgets::wrapped_text` with native `Label`/`LayoutJob` wrapping once E15 is active. A remaining custom wrapper must measure with the same direction/language it paints. +- Set `Style::right_to_left` for `ar`/`ckb` after auditing manual `Layout::right_to_left` call sites. E9 mirrors chrome, not every manually positioned control. Pass an explicit direction to `craft_ui::DockArea`; do not reverse saved workspace order. +- Keep `bidi::visual` only for painter paths that cannot use a `LayoutJob`; never combine it with native bidi on the same text. +- AccessKit labels and copied text stay logical; only painting is reordered. + +### 6.7 Input, IME and clipboard + +- Add `source` to every `Event::MouseWheel` constructor. The only variants are `Unknown`, `Wheel`, `Trackpad`, `Momentum`. On Wayland, Windows, X11 and web, winit reports `Unknown`; macOS reports `Wheel`, `Trackpad` or `Momentum`. +- PdfCraft's `fling.rs` remains the document-motion owner. On non-macOS platforms it keeps the existing `MouseWheelUnit::Point` + `TouchPhase::End` heuristic, explicitly including `Unknown`; never use the invented `Finger` or `WheelTilt` egui variants. On macOS its custom fling stays off because the OS sends `Momentum`; test that egui kinetic does not add a second document glide. Ordinary widget `ScrollArea`s keep the egui kinetic default; wheel deltas do not create coast velocity, only pointer-drag velocity and momentum do. +- E10/E19/W23/W25: verify shortcuts on non-Latin layouts through synthetic `RawInput` and native Windows/macOS/Linux tests. +- E21/W26/W27: test incoming `DeleteSurroundingBytes` and outgoing E21b surrounding text after delete/commit, deletion-only batches, preedit exclusion, reversed selection, UTF-8 boundaries, long/over-limit selections, field/window switching, password clearing and late serials. +- Text copy/paste stays on egui events; keep `arboard` for image/file clipboard. Enable eframe `clipboard` for native builds. PRIMARY uses W15P: keyboard-only focus, serial freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers, password exclusion and no unchanged-selection reclaim. Native evidence on wlroots/KWin/Mutter. No second regular data device. + +### 6.8 Accessibility + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +- Map every `WidgetInfo` role explicitly (`Role::TextInput`, `Role::CheckBox`, etc.). +- Add accessible names to every input widget. 0.37 kittest fails unnamed inputs; use `labelled_by`, `Ui::label_inputs_by`, `Ui::name_inputs` or `Response::accessible_name`. +- Keep logical labels in AccessKit. Do not set preordered visual strings. +- `ControlPlugin` copies the AccessKit tree; `ui.inspect` must keep stable widget IDs across panel migration. Test roles/labels for text field, slider, tab and custom button. +- Panel resize handles expose `Role::Splitter`. + +### 6.9 Rendering and GPU + +- Keep `surface_texture_limits` and `configure_gpu`. E2+E3 remain required. +- Do **not** skip `DocView::receive` uploads on `will_discard`. A first-pass texture is carried into the final merged `TexturesDelta`; skipping it can lose a page. Use generation identity to avoid re-scheduling renders and avoid duplicate whole-image deltas. +- Assert the final merged `FullOutput.textures_delta.set` contains one whole delta per texture generation; a forced-late-discard test must show the page after the next accepted pass. +- Keep `RESULTS_PER_FRAME`/`UPLOAD_BYTES_PER_FRAME`, hidden-tab suspension, `PDFCRAFT_RENDERER=wgpu|gl`, OpenGL retry and software-renderer notice. +- Screenshot readback is callback-driven; never assume it is ready in the same input frame. + +### 6.10 Commands, undo and persistence + +No command ID, tool ID, `parity/` entry, undo transaction or saved-settings key changes in this port. Preserve `commands.rs` registry IDs, `Session` document generations and undo steps, `Workspace` JSON, `window_state` storage key `window`, and `persist()` keys `pdfcraft`/legacy `printcraft`. The only persistence migration is egui's panel `PanelState` keys in §6.2. + +## 7. PR stages and rollback + +All publication is gated by explicit human approval. No push or PR is authorized by this plan. + +### Stage 0 — harness and baseline (before production change) + +- Land only this document and a test-only measurement driver/harness; no production behavior changes. +- The harness must run on the unmodified baseline `a2ec27b` and, where its adapter permits, stock 0.36.2, stock 0.37.0 and the fork. +- Record baseline frame/idle/RSS samples, `Context::tessellate` with 120 ASCII labels (#8731), startup phase split (instance/adapter/device → pipeline creation → first frame; #8736), and font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider on/off (#8512). +- Record `cargo metadata`, lock hash, vendor hashes, hardware, renderer/driver, DPI, fixtures and actual commands. A baseline may not be measured after production changes. + +### Shared fork prerequisites (external to PdfCraft) + +The implementation is blocked until the shared plans publish approved immutable revisions containing: + +- the reviewed winit W1–W15, W17–W27 and **W15P** PRIMARY path (all present in the current pin `a517b384`); +- the reviewed egui E1–E21 and **E21b/P7b** outgoing surrounding-text integration; +- the craft-libs API-port hunks on current main, with `craft-window` and `craft-print` drift reviewed. + +PdfCraft does not vendor a private copy or bypass missing publication with a path patch. + +### PR 1 — atomic first-green compatibility change + +Depends on: Stage 0 and the complete approved egui/winit revisions (P2b bounded W15/W15P, P7 key release pairing, P7b IME lifecycle, P7c selection fix), plus craft-libs CL-1/CL-2F. PR 4 RTL docking additionally waits for CL-3. + +Scope (one reviewable change set; commits may split by file only if every commit is green): + +- Pin the egui 0.37 family and winit 0.30.13 fork revision; update `Cargo.lock`. +- Enable native eframe `clipboard` in `apps/pdfcraft` and `crates/ui-egui`; add the feature-identity assertion. State that web has no egui-winit clipboard. +- Enable `monochrome_emoji_fonts`; update `ATTRIBUTION.toml` bundled versions and add `egui-icons.ttf`; run assets write/check. +- Add storytold egui/winit git URLs to `deny.toml` `allow-git`; keep `unknown-git = "deny"`. +- Rename `WidgetType`/`Id::new`/`Ui::id`/`UiBuilder::id`; map roles; fix all kittest accessible-name and missing-glyph failures (204 harnesses). +- Update painted submenu labels: `"File ⏵"` → `"File"`, etc.; update AccessKit/name expectations across `commands.rs`, `theme.rs`, `editing.rs`, `control.rs`, `text_select.rs`, `view.rs`; assert the arrow is painted. +- Add `MouseWheel.source` with the real variants; migrate the screenshot path to the bounded callback; add panel-key migration. +- Port from craft-libs current main using only the reviewed API hunks and `craft-window`; add `craft-window` type reuse and retain `PDFCRAFT_TITLEBAR`/runtime preference semantics. Add `Printer.description` to every literal (19 at baseline) and audit craft-print/GDI/rgb drift. +- Transfer winit/egui-wgpu source guards centrally; replace PdfCraft source-path and `source =` assertions with immutable git graph checks. +- Remove catalog preordering and `display_rtl` as the fork becomes the sole UI bidi owner; + include the focused logical-text/selection regression in this first fork pin. +- Do not defer names, glyphs, attribution, required bidi adaptation or `deny` to PR 3. + +Tests: fmt, clippy `-D warnings`, `cargo test -p pdfcraft-ui-egui`, `cargo test -p pdfcraft`, wasm check, `cargo xtask layers/assets/deny/parity`, native feature/graph assertions, source-guard replacements. Snapshot changes must be reviewed, never auto-accepted. + +Rollback: revert PR 1; keep the 0.36 lock and vendor directories in history. Do not leave a 0.37 manifest with a 0.36 lock. + +### PR 2 — lifecycle, control and render commit + +Depends on: PR 1. + +Scope: + +- Replace `ctx.input` clocks in autosave/recovery/grid/control timers with monotonic deadlines. +- Move `motion.begin_pass` into UI before panels; remove stale event reads from logic-only iterations. +- Complete exactly-once command/edit/undo handling with a real forced-late-discard test; remove any “no commit on discard” assumption. +- Finish screenshot slot stress: hidden injected input, screenshot command paint, cancel/late/out-of-order/DPI, aggregate reservations. +- Rename `Pending::Frames`/`Shared::passes` to accepted completed frames; increment only when `request_discard_reasons` is empty; add a forced-discard timing test. +- Add generation-safe texture tests; do not gate uploads on `will_discard`. + +Rollback: revert PR 2; PR 1 retains a complete 0.37 callback and basic regression coverage. + +### PR 3 — language, fonts, RTL and accessibility completion + +Depends on: PR 2. + +Scope: + +- `Frame::set_ui_language`; AccessKit language. +- Expand native wrapping and explicit `TextDirection` tests; catalog preordering and + `display_rtl` removal already landed in PR 1 and must not be restored independently. +- E14 fallback alignment; glyph coverage; `system_font_fallback = false`. +- `Style::right_to_left` after manual-layout audit; craft-ui `DockArea::direction`. +- Role/name tests already required by PR 1 are strengthened, not introduced here. + +Rollback: revert PR 3; PR 2 remains. If native bidi proves wrong, revert the whole 0.37 family to the 0.36 fork tag `767b1cad` with the old preordering. + +### PR 4 — platform window, packaging and documentation + +Depends on: PR 1 (can run in parallel with PR 2/3 after rebase). + +Scope: + +- The type re-export/parser replacement lands in PR 1; PR 4 completes extended native policy tests for `craft-window`: `window_controls` re-exports `craft_window::{Button, Layout}`; `decorations` uses `Choice::probe(Prefix::new("PDFCRAFT"))`; remove duplicate parser; keep runtime `Use system title bar` toggle. +- Native Linux/Windows GUI checks, web build, packaging checks and clipboard/PRIMARY/IME workflows. +- Update `vendor/README.md`/`SOURCE.json` only for retained vendors; document removed window-stack copies and exact replacement revisions. +- Update `docs/development.md` and version statements. + +Rollback: revert PR 4; PR 1–3 remain. + +### PR 5 — performance evidence and final gates + +Depends on: all. + +Scope: run the Stage 0 harness on the candidate; add paired release results, stock-0.37 control, peak RSS, idle CPU/repaint, texture counters and startup/font/tessellation measurements; update `ROADMAP.md`/status docs. No behavior change. + +DAG: + +``` +Shared forks published -> Stage 0 -> PR1 -> PR2 -> PR3 -> PR5 + \-> PR4 -> PR5 +``` + +PR4 may be rebased alongside PR2/PR3, but do not land two PRs that both rewrite `lib.rs` without a rebase review. + +## 8. Test plan with user-observable assertions + +### 8.1 Existing suites to keep green + +- `crates/ui-egui/tests/it` (one binary): accessibility, actions, panels, controls, comments, content, editing, export, fonts, forms, links, pickers, print, recovery, signing, text selection, viewer, XFA. +- `crates/ui-egui/tests/perf.rs`: collapsed comments, 500-page scrolling and panel budget tests. +- `apps/pdfcraft/tests/it`: twelve winit guards (transferred/replaced), and the Wayland scroll-policy compile test. +- `apps/pdfcraft/src/main.rs`: GPU/surface/adapter guards. +- `examples/shot.rs`: headless screenshot of the real shell. +- `crates/ui-egui/src/control.rs`: in-crate control, pointer and multi-pass probes. + +Include `crates/ui-egui/tests/it/control.rs`, its discarded-pass requests and `screenshots_of_window_and_region` in PR 1 migration and the existing-suite gate. + +### 8.2 New or strengthened assertions + +| Area | Test | Assertion | +|---|---|---| +| Lifecycle | `logic_runs_per_pass_but_one_action_commits_once` | Native/web-shaped driver runs logic + UI twice in one frame; one click yields one edit/undo step | +| Hidden logic | `hidden_logic_advances_monotonic_deadlines` | Repeated `run_logic` with advancing wall clock and no new events autosaves once and changes no scroll/zoom | +| Hidden input | `hidden_injected_input_needs_a_ui_pass` | Queued click times out without `RequestPaintWhileHidden`; applies exactly once with it | +| E22 | `completed_multipass_resets_pass_index` | After completed `run_ui`, `current_pass_index()` is zero; a hidden call still runs; no first-pass guard is used as a scheduler | +| Panels | `upgrade_from_036_panel_memory` | Old `Id::new(salt)` width migrates to root-scope key; width within 1 pt; ID dump recorded | +| Panels | `panel_widget_id_stays_stable` | A widget in a panel keeps the same `scope_id`-derived interaction ID across consecutive 0.37 frames; `unique_id` is not substituted for state | +| Panels | `panel_clip_is_intersected_with_parent` | Child clip no larger than panel/parent; overlay uses `Area` and remains visible | +| Docking | `dock_float_detach_and_split_survive_restart` | Same `Workspace` JSON round-trips; panel bodies once; Canvas cannot float/share tabs | +| Screenshots | `two_screenshot_requests_keep_their_slots` | Two regions complete out of order with own dimensions/colours; each reply once | +| Screenshots | `hidden_screenshot_command_paints_itself` | A pending screenshot paints a hidden window; no false `RequestPaintWhileHidden` requirement | +| Screenshots | `late_screenshot_is_dropped_after_cancel` | Timeout/close/cancel produces no second reply/file; reservations return to zero | +| Screenshots | `dpi_change_uses_capture_geometry` | Request-time ppp/rect correlate the image; mismatch retries or errors, never crops with new scale | +| Multi-pass | `discard_does_not_duplicate_edit_or_undo` | One toolbar action with `max_passes=2`: one edit, one undo step, one generation change | +| Texture | `discard_keeps_first_pass_texture_and_deduplicates` | Final merged delta has one whole image per generation; page visible next accepted pass; no upload skip | +| Control | `after_frames_counts_accepted_frames` | Forced discard does not answer early; answers after intended accepted frames | +| Input | `unknown_wheel_source_keeps_wayland_fling` | `Unknown` + Point + End glides on Linux; `Trackpad`/`Momentum` policy covered; no `Finger`/`WheelTilt` | +| Input | `one_finger_lift_glides_once` | Offset after 0.5 s follows `fling.rs`; egui kinetic adds no second document curve | +| Fonts | `every_ui_string_has_all_glyphs` | All `tl!` literals and catalogs pass `MissingGlyphPolicy::Panic` for embedded + craft-fonts + system-font-off | +| Fonts | `fallback_rows_share_the_baseline` | Fallback baseline equals primary; row grows; `y_offset_factor` not the mechanism | +| Language | `arabic_ui_labels_use_an_explicit_paragraph_direction` | With an Arabic face, the label's paragraph direction is `RightToLeft` and its Latin run sits right of the Arabic; English stays `Auto` | +| Language | `arabic_catalog_stays_logical` | Catalog and command lookups stay in logical order and placeholders keep their positions | +| Language | `a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name` | AccessKit label is logical; paint is visual only | +| Editing | `grapheme_caret_and_delete_are_cluster_safe` | Emoji ZWJ/combining accent move/delete as one cluster | +| IME | `delete_surrounding_bytes_replaces_exactly` | Exact reported bytes deleted; one undo step | +| IME | `outgoing_surrounding_text_is_fresh` | After delete+commit, preedit exclusion, cursor/selection changes, long/over-limit/password/focus cases; E21b payload present and bounded | +| Accessibility | `all_input_widgets_have_names` | Every text input role has non-empty label; no global check disable | +| Accessibility | `panel_tree_roles_and_order` | Tab strip, mode bar, rail and inspector once; expected roles/order | +| Menu | `submenu_arrow_is_painted_not_named` | `"File"` etc. accessible labels; arrow atom visible; no `"File ⏵"` dependency | +| Clipboard | `native_egui_winit_clipboard_is_enabled` | Native graph enables `egui-winit/clipboard`; web does not require it | +| Clipboard | `wayland_text_clipboard_uses_one_data_device` | Regular clipboard works on Hyprland without a second regular device | +| PRIMARY | `primary_selection_uses_w15p` | Password excluded, unchanged selection not reclaimed, serial/seat/offer/self-paste/cancel cases; single data device | +| Selection | `textedit_selection_to_toolbar` | Select a subrange in the paragraph TextEdit, click Bold/colour/underline/spacing in Format and assert only that byte range receives `RangeStyle`, with one undo. Repeat via keyboard/focus surrender. `LineEditor.selection` is populated from cursor_range and used by `LineEditor::style` in `apply_line_format`; right-rail-before-central draw order may protect a path but is not proof. Final fork P7c owns #8763 fix (#8764 remains open/unmerged). #8748 is closed/unmerged and root-only PdfCraft has no child case | +| Web | `web_named_keys_and_non_latin_shortcuts` | Named keys not typed as text; `Ctrl+S` works on Russian layout | +| GPU | existing surface tests | 8600×3423 at 250% fitted to 8192; bad surface errors; pipeline error returns `CreateRenderer` | + +### 8.3 Deliberate broken-behavior probes + +Before accepting each test, temporarily revert the fix and confirm the named test fails: + +- remove `surface_fit` → surface-fit test fails; +- remove `catch_errors` → GPU setup error test fails/panics; +- re-add `display_rtl` to catalog lookups → `i18n::tests::arabic_catalog_stays_logical` fails; +- switch E21 back to character counts or remove E21b payload → IME tests fail; +- enqueue the same UI command on a replayed pass → exactly-once test fails; +- remove the hidden UI paint command for injected input → hidden-input test times out; +- remove an accessible name, or restore `"File ⏵"` as the only label → accessibility/menu tests fail; +- skip uploads on discard → texture/page test fails; +- count passes instead of accepted frames → control timing test answers early. + +## 9. Performance protocol and thresholds + +### 9.1 Measurement identity + +Record for every run: + +- exact source SHA, lockfile SHA-256, `cargo metadata` package IDs and sources; +- features (`CRAFT_FONTS_DIR` set/unset, `PDFCRAFT_SYSTEM_FONTS=0/1`, `PDFCRAFT_RENDERER=wgpu/gl`, `monochrome_emoji_fonts`, `clipboard`); +- fixture hash (`tests/perf.rs::big(500)`, `named_comments(10_000)`, panel fixture); +- OS/kernel, CPU, RAM, GPU/adapter/driver/backend, DPI/scale, window size (1400×900), renderer, profile; +- software adapter (llvmpipe/WARP) versus real GPU; +- host load and concurrent builds. + +Baseline is measured **before** any production change, on `a2ec27b`, in its own checkout/target/evidence. Candidate runs use the same machine, boot, fixture and environment. The first report and every investigated regression include stock-0.36 and stock-0.37 controls where the harness can run them, with all three identities recorded. + +### 9.2 Harness-first method + +The Stage 0 harness must exist on the baseline before PR 1. It has three layers: + +1. **Frame/idle/memory**: open the 500-page fixture and named-comment fixture; warm up, then alternating paired runs. +2. **Targeted engine measurements**: `Context::tessellate` of 120 ASCII labels for #8731; startup phases (instance/adapter/device → pipeline creation → first frame) for #8736 on WARP and a real GPU; font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider enabled/disabled in an isolated fixture for #8512. +3. **Operations**: open + first frame, zoom, page scroll, dock/float, save/reopen. + +Method: + +- Build/run only through `maintenance/task.py run/status/finish` with a dedicated task, source and evidence directory; no direct cargo fallback. Submit each build/test directly; do not nest a multi-resource driver. +- Use release binaries and separate processes for baseline/candidate. +- At least 20 warm-up frames, 1,000 measured frames per revision for frame distributions; 10 warmups/30 timed iterations for steady operations; 20 fresh processes for startup. Record every raw sample; report median/p95/p99 and sample counts. +- Alternate baseline/candidate order; use paired runs, separate allocations, no concurrent builds or noisy workloads. +- Measure idle after settling for 5 s: zero repaints after 3 s, process CPU from `/proc//stat` or platform equivalent. +- Peak RSS from `getrusage(RUSAGE_SELF).ru_maxrss` or `/usr/bin/time -v`. +- Texture/allocation counters from `FullOutput.textures_delta` and `DocView::receive` upload count/bytes; add test-only counters if needed. +- Never infer UI performance from compile duration; never auto-update a golden or snapshot. + +### 9.3 Predefined thresholds + +Relative to the paired baseline on the same machine: + +| Metric | Acceptable candidate | +|---|---| +| Frame median | ≤ baseline median × 1.05 | +| Frame p95 | ≤ baseline p95 × 1.10 | +| Frame p99 | ≤ baseline p99 × 1.15, with at least 1,000 observations | +| Peak RSS | ≤ baseline × 1.10 | +| Idle CPU | ≤ 1% of one core; zero repaints after 3 s | +| Texture bytes/frame | ≤ baseline × 1.05 + 1 MiB | +| Open + first frame | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | +| Operation latency | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | + +Noise policy: if baseline median varies by more than 3% across repetitions, discard that run and repeat on a quiet machine; never relax a threshold to make a candidate pass. If a candidate fails, re-run once, then profile before deciding. Existing debug `BUDGET = 40 ms` smoke gates remain; stricter existing budgets win. Use the suite's 0.05 ms floor only for sub-millisecond measurements. + +## 10. Quality gates and platform matrix + +Required for every PR: + +- `cargo fmt --check` +- `cargo clippy -p pdfcraft-ui-egui -p pdfcraft -p pdfcraft-web --all-targets -- -D warnings` +- `cargo test -p pdfcraft-ui-egui` +- `cargo test -p pdfcraft` +- `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` +- `cargo xtask layers` +- `cargo xtask assets` +- `cargo xtask deny` +- `cargo xtask parity` +- `cargo xtask ci` in CI +- `cargo tree -d`/`cargo metadata` duplicate/source assertions +- native feature assertion for `egui-winit/clipboard` and `monochrome_emoji_fonts` +- source-guard evidence from the immutable winit/egui-wgpu revisions + +Platform rows: + +| Platform | Required evidence | +|---|---| +| Linux X11 | real GUI: panels, docking, control screenshot, clipboard, IME (fcitx5/IBus), wheel/zoom | +| Linux Wayland GNOME | app-drawn frame, window controls, resize edges, panel clipping, PRIMARY | +| Linux Wayland KDE/Sway | server-side frame, same layout and shortcuts, regular clipboard | +| Linux Wayland Hyprland | text clipboard/PRIMARY single-device, screenshot control | +| Windows 11 | real GPU and WARP, mixed-DPI drag, hidden maximized restore, >8192 surface, Korean IME, screenshot, craft-print/GDI build | +| macOS | traffic lights/title-bar drag, ⌘Q save prompt via craft-quit (W14 not opted in), press-and-hold/dictation IME, emoji picker, Korean space | +| FreeBSD | CI or explicitly unresolved; no GUI claim without a real session | +| Web (Chromium/Firefox) | `trunk build`, open a PDF, Arabic UI, shortcuts on a non-Latin layout, image paste, no named-key text leakage | + +Headless `egui_kittest` is not proof of native focus, IME, file pickers, clipboard/PRIMARY or GPU presentation. A row is either backed by real evidence or listed as unresolved. Record actual selected test counts and failures; zero selected tests are not acceptance. + +## 11. Risks and migration traps + +1. **Double bidi.** Adopting E5 while keeping `display_rtl`/`bidi::visual` reverses Arabic/Kurdish twice. Remove preordering in the same language PR. +2. **Panel keys.** 0.37 derives panel IDs from the parent scope; widths reset unless the first compatibility PR migrates `PanelState`. Child widget state may reset once and is measured. +3. **`Ui::scope_id` is the old `Ui::id`.** Rename mechanically; do not switch existing `ui.id().with(...)` state to `unique_id()`. +4. **Multi-pass side effects.** `logic` and `ui` run per pass; edits/undo/render queues must be exactly-once. Do not impose “no commit on discard”. +5. **Hidden windows and stale input.** `run_logic` leaves input events/time stale. Current input belongs in the UI; background work uses monotonic deadlines. +6. **E22 pass-index fallacy.** After a completed `run_ui`, `current_pass_index()` is zero; a first-pass guard does not skip ordinary hidden logic. No E22 scheduler API. +7. **Screenshot assumptions.** A screenshot command itself paints a hidden window; injected input needs `RequestPaintWhileHidden`. Callback completion may be off-thread and has no built-in DPI metadata. +8. **Texture uploads.** Skipping uploads on `will_discard` can lose first-pass textures. Use generation identity and the final merged delta. +9. **Kinetic scroll.** Use real `MouseWheelSource` variants; `Unknown` is normal on Wayland/Windows/X11/web. Keep one document glide. +10. **Font provider policy.** eframe's system-font provider can read many installed faces, including proprietary ones; keep it off and preserve `PDFCRAFT_SYSTEM_FONTS=0`. +11. **Default-font glyph loss.** 0.37 gates emoji fonts; enable `monochrome_emoji_fonts`, add `egui-icons.ttf`, and keep the kittest glyph panic. +12. **Kittest stricter accessibility.** Unnamed inputs fail; fix labels rather than disabling checks. +13. **`WidgetType::TextEdit` does not exist as a `Role`.** It is `Role::TextInput`; `Checkbox` is `Role::CheckBox`. +14. **craft-print drift.** The bump adds `craft-winprint-gdi`, `rgb`, `lpstat -l` descriptions and `Printer.description`; audit all, fix 19 literals and preserve current main's security fixes. +15. **Winit source guards.** Twelve app modules read old vendor paths; move them centrally and use exact git identities. +16. **Snapshot churn is not acceptance.** Review every diff and keep semantic pixel tests. +17. **PRIMARY.** W15P is required and is present in the current winit pin; filtering `MiddleClickPaste` does not avoid the protocol risk and is not the selected design. +18. **IME surrounding text.** The planning-era candidate lacked E21b/P7b; pinned `6d518fc` contains the producer. Native Wayland execution remains unqualified, so do not describe W27 as complete integration. + +## 12. Resolved decisions and external gates + +Decisions made by this plan: + +- **E11:** not required by PdfCraft; epaint's `vello_cpu` 0.2.0 cannot unify with hayro's 0.0.8. Verify with `cargo tree -e features -i vello_cpu`. +- **Kinetic scrolling:** PdfCraft's `fling.rs` owns document motion; ordinary widget `ScrollArea`s keep egui's kinetic default. Use `Unknown`/`Wheel`/`Trackpad`/`Momentum`; macOS custom fling is off. +- **Panels:** migrate persisted `PanelState` keys in PR 1; transient in-panel focus/scroll may reset once only if the upgrade test proves it. +- **PRIMARY:** adopt W15P on the existing winit connection/event queue; no app-level filtering and no second regular data device. +- **RTL:** remove catalog preordering; explicit `TextDirection` for UI labels; `Style::right_to_left` after manual-layout audit; explicit craft-ui dock direction; saved workspace order unchanged. +- **Fonts:** `monochrome_emoji_fonts` on; no broad system provider. +- **Window frame:** `craft_window::Choice` at startup; `PDFCRAFT_TITLEBAR=app|system|desktop`; `window_controls` uses craft-window types; runtime preference remains a `ViewportCommand::Decorations` toggle. +- **Screenshot helper:** PR 1 waits for and directly adopts the reviewed craft-ui helper after CL-2F; no local duplicate. +- **Performance:** harness and baseline precede production; stock-0.37 control accompanies first baseline and regressions. + +External gates that cannot be decided from this host: + +- final approved immutable storytold egui/winit/craft-libs revisions, including W15P and E21b/P7b; +- native Wayland PRIMARY/IME evidence on wlroots/KWin/Mutter; +- Windows real-GPU/WARP and native GUI evidence; macOS and FreeBSD hardware evidence; +- browser runtime and non-Latin keyboard evidence; +- explicit human approval of every branch/tag push, PR or publication. + +## 13. Limitations of this plan + +- No build, test, clippy, fmt, wasm, GUI or performance run was performed. The current tree was read only. +- No compiler diagnostics were generated for this baseline. Historical audit diagnostics are stale. +- Upstream status comes from the provided `upstream-discussions.json` snapshot and local refs; it is not a live GitHub query. #8763 and #8764 are an open issue/PR, not release fixes. #8748 is closed and N/A because PdfCraft uses only `ViewportId::ROOT`. +- No native Wayland compositor, macOS or Windows session was available; PRIMARY, IME, focus and real-GPU behavior remain unverified. +- No performance baseline has been measured. The thresholds in §9 are predeclared relative limits; the absolute baseline must be recorded before implementation. +- The exact publication revisions for egui, winit and craft-libs remain external prerequisites, not user choices. +- Only `docs/egui-037-migration-plan.md` was written. No production file, manifest, lockfile, vendor tree or test was changed. + +## 14. Source index used for this plan + +PdfCraft: + +- `Cargo.toml`, `Cargo.lock`, `deny.toml`, `ATTRIBUTION.toml`, `xtask/src/assets.rs`. +- `apps/pdfcraft/Cargo.toml`, `apps/pdfcraft-web/Cargo.toml`, `apps/pdfcraft/src/main.rs`, `apps/pdfcraft/src/decorations.rs`, `apps/pdfcraft/tests/it/*.rs`. +- `crates/ui-egui/Cargo.toml`, `crates/ui-egui/src/lib.rs`, `control.rs`, `canvas.rs`, `chrome.rs`, `window_controls.rs`, `window_state.rs`, `theme.rs`, `system_fonts.rs`, `motion.rs`, `recovery.rs`, `fling.rs`, `wheel_pager.rs`, `wheel_boost.rs`, `scroll_speed.rs`, `bidi.rs`, `widgets.rs`, `print_ui.rs`. +- `crates/print/src/tests.rs`, `crates/ui-egui/tests/it/*.rs`, `crates/ui-egui/tests/perf.rs`, `crates/ui-egui/examples/shot.rs`. +- `vendor/README.md`, `vendor/winit`, `vendor/egui-wgpu`, retained `vendor/hayro*`, `vendor/lopdf`, `vendor/vello_common`. + +Target/reference source: + +- egui 0.37 `841a328b`, planning-era candidate `a4f9a17f` (current pin `6d518fc`): `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `Ui::scope_id`/`unique_id`, `UiBuilder`, `PanelState`/`Panel::id`, `ScreenshotCallback`, `MouseWheelSource`, `ScrollStyle`/`ScrollArea`, `menu::SubmenuButton::arrow_atom`, `epaint_default_fonts`. +- winit vendor 0.30.13, planning-era candidate `8b35388b` and current pin `a517b384`: `PATCHES.md`, `platform/wayland.rs::set_ime_surrounding_text`, per-seat clipboard/data-device APIs. +- craft-libs `3832c47` (planning-era; ancestor of the current `25da131` pin), PR 199 `b68de8c`/`8614e4a`/`ec8840a`: `crates/ui`, `crates/window`, `crates/print`, `docs/egui-037-suite-rollout.md`. +- Shared evidence: `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, `upstream-discussions.json`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +The owned checkout now HEADs at `a2ec27b4775f53c40cad3456936d9406611e3a8d`; `57e531ccca0047597809748b5cb4276336d6051a` and the earlier baseline SHAs above remain historical review evidence, and both refs remain reachable. Current integration uses this checkout and its qualification lock; paired measurements must record the exact source and lock they used. + +Preserve document-folder native dialogs, colour popup close policy, panel-header spacing, bookmark contrast, and org_drag cancellation after document changes. Preserve new integration regressions and generate fresh baseline geometry. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation amendment — 2026-10-24 + +**Historical note (superseded by the 2026-10-25 qualification state).** The migration is implemented locally but remains unpublished. At the time of this amendment, `egui`/`eframe`/`egui-wgpu` 0.37.0, the reviewed winit worktree and the shared craft-libs helpers resolved through `local-integration-config.toml`; the checked-in `Cargo.toml`/`Cargo.lock` did not yet pin them. The current tree pins egui `6d518fc`, winit `a517b38` and craft-libs `25da131` (craft-ui, craft-quit and craft-print) directly, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` (see the top section). The repository quality gates passed as recorded below; publication still requires fresh human approval. No push, PR or release is authorized by this amendment. + +The font contract now includes the native-only Simplified Chinese face: the pinned craft-fonts input supplies `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite, OFL-1.1), `crates/fonts/build.rs` embeds it on native builds only under a 16 MiB cap, and a `CRAFT_FONTS_REQUIRED` build fails rather than skipping an absent or over-cap required Chinese face. Traditional Chinese is served by an installed `Hant` face placed before the Japanese faces even when LXGW is embedded. CI, release and FreeBSD keep an immutable craft-fonts commit pin; the current candidate is recorded in the task evidence and the pin must be updated only to a published central commit. + +Fallback faces now use egui 0.37's `FontTweak::fallback_align = FallbackAlign::Baseline` for the existing one-baseline intent (the metric-derived `y_offset_factor` compensation is gone). Control-channel synthetic input that times out after a press keeps one bounded release batch, so a key, pointer button or modifier cannot stay held when the window resumes. Accepted-frame counting remains one count per presented frame, verified at `max_passes` above two. diff --git a/docs/gaps.md b/docs/gaps.md index 4349df592..56ec3ae74 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (gap 6 #3673: Character encoding CJK mapping routes corrected; no readiness re-estimate) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -37,7 +37,7 @@ update the numbers. |---|---|---|---|---| | 2 | Rendering is borrowed and its fidelity unmeasured | feature, spec | 110–190 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 3 | Editing existing text is fragile on real files | feature | 100–180 | [target-app-parity.md](target-app-parity.md) | -| 5 | Chinese UI shows missing-glyph boxes in releases | localization | 6–12 | [localization-parity.md](localization-parity.md) | +| 5 | Chinese UI lacks a web glyph face | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | | 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | @@ -46,7 +46,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -117,18 +117,19 @@ update the numbers. how a document looks. - **Estimate:** 100–180 h (the longest feature pole, M7). **Doc:** [target-app-parity.md](target-app-parity.md). -### 5. Chinese UI shows missing-glyph boxes in releases +### 5. Chinese UI lacks a web glyph face -- **Missing:** a Simplified Chinese (`Hans`) UI face in release builds and a working fallback. +- **Missing:** a Simplified Chinese (`Hans`) UI face in the web build and a working fallback. Native release builds embed LXGW WenKai Lite from the pinned craft-fonts native Simplified Chinese manifest. - **Evidence:** [#826](https://github.com/storytold/pdfcraft/issues/826), [#688](https://github.com/storytold/pdfcraft/issues/688), [#728](https://github.com/storytold/pdfcraft/issues/728), [#758](https://github.com/storytold/pdfcraft/issues/758), - [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). craft-fonts has Noto Sans - CJK SC, but at 16.4 MB it is too large to embed whole; the interface needs a cut of it - (storytold/craft-libs#213). -- **Impact:** the second-largest language group sees a broken interface although the catalog is 97% - translated. + [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak) predate the native face; the + web build still embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. + craft-fonts has Noto Sans CJK SC, but at 16.4 MB it is too large to embed whole; a web face needs + a cut of it (storytold/craft-libs#213). +- **Impact:** the second-largest language group sees a broken interface in the web build although + the catalog is 97% translated. - **Estimate:** 6–12 h (cut CJK faces through craft-fonts-build plus the fallback chain). **Doc:** [localization-parity.md](localization-parity.md). @@ -305,14 +306,18 @@ update the numbers. to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean +### 14. Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean -- **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships - Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). +- **Missing:** end-to-end qualification of right-to-left interface workflows on native platforms + and in the browser (typed RTL text, IME composition, clipboard/selection and complex multi-line + bidirectional text); catalogs for Hindi, Indonesian and Korean (Acrobat ships Korean; it ships + Arabic in its Middle East edition; it ships none of Hindi or Indonesian). The logical catalog, + explicit right-to-left label direction and mirrored stock chrome are implemented and + unit-tested; complex RTL workflows are not yet accepted. Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` like other non-English languages (no translated help; engine/OS details stay English). -- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. +- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned (end-to-end + qualification pending). - **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). @@ -398,6 +403,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 04434712a..fee2ba3e7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Brazilian Portuguese now covers the 2,674-key catalog union; twelve-language mean still rounds to ≈68%; Spanish 100% of the current key union) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -13,7 +13,7 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R **That union is a moving target**: every change that adds a string widens it, so a count is only comparable with others measured on the same date, and a catalog that reached 100% falls behind without losing a single translation. Every figure here therefore carries its date. The current - union is **2,674** keys as of 2026-10-20, against the **2,143-key snapshot** of 2026-10-10; earlier + union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier revisions quote the union of their own date (see the revision history), not this one. The twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is @@ -34,22 +34,22 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | -| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work, but **release builds have no Hans face**: many characters show as boxes (#826, #688, #728, #758; Flatpak #689) | no | partial | yes | 6–12 | -| Spanish | `es` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | -| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); no Devanagari face in craft-fonts | no | none | no | 10–18 | -| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Letters joined and lines reordered at load, but **layout not mirrored**, wrapped lines break at the wrong end, typed text runs left to right (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | +| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 | +| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | | French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | -| Portuguese (Brazil) | `pt-br` | 2,674 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | | Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | +| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | **Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only 60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** -to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and @@ -63,33 +63,34 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | | Russian | `ru` | 2,043 (95.3%) | partial | yes | | Bulgarian | `bg` | 2,094 (97.7%) | partial | no | -| Czech | `cs` | 2,674 entries (every key of the other catalogs, the engine's sentences, weekdays and certificate fields) | partial | yes | +| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes | | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | -| Kazakh | `kk` | 2,327 entries | partial | no | -| Polish | `pl` | 2,287 entries | partial | yes | -| Slovenian | `sl` | 2,324 entries | partial | no | -| Greek | `el` | 2,341 entries | partial | no | -| Turkish | `tr` | 2,313 entries | partial | yes | -| Lithuanian | `lt` | 2,674 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -| Finnish | `fi` | 2,674 entries | partial; AI-assisted, native proofreading pending | yes | -| Albanian | `sq` | 2,344 entries | partial; not yet reviewed by a native speaker | no | -| Catalan | `ca` | 2,592 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | -| Romanian | `ro` | 2,673 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -| Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | -| Persian | `fa` | 2,776 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes | +| Kazakh | `kk` | 2,539 entries | partial | no | +| Polish | `pl` | 2,611 entries | partial | yes | +| Slovenian | `sl` | 2,536 entries | partial | no | +| Greek | `el` | 2,545 entries | partial | no | +| Turkish | `tr` | 2,525 entries | partial | yes | +| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | +| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | +| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | +| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | **20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12–14 (Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-25 | minor | Added Persian (`fa`, 2,776 entries, every bundled key); right-to-left like Arabic and Kurdish with the same layout limits; AI-assisted, not yet native-reviewed | +| 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged | +| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | diff --git a/docs/localization.md b/docs/localization.md index 234542c75..e2ce23ef5 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -14,56 +14,49 @@ The control channel exposes the setting through `ui.set`: The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Kurdish, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Romanian, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each), and Czech (2,674 entries) covers the same surface. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs carry most of the current localizable surface (roughly 2,400–2,800 entries each); Czech (2,794 entries) has all but four keys, and Finnish, Vietnamese, Catalan, Brazilian Portuguese, Spanish, Lithuanian and Romanian have all 2,798. Persian has 2,785. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). -The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. +The Ukrainian catalog has 2,530 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Vietnamese catalog has 2,798 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,798 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. -The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. +The Catalan catalog has 2,798 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. -The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Romanian catalog (`ro`, Română) has 2,798 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. +The Norwegian Bokmål catalog has 2,707 entries, all but 91 of the current key union, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases includes the `Hebr` face, so releases draw the missing letters and accept Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and in a build without craft-fonts every Chinese character does, on the web and on a desktop machine with no installed Chinese face). Until the interface embeds a `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese uses the pinned craft-fonts input too. On native builds, `fonts/simplified-chinese-manifest.txt` adds LXGW WenKai Lite as a `Hans` face (at most 16 MiB, never embedded on the web); Simplified Chinese puts the `Hans` faces first, so a line never mixes faces with different baselines. Traditional Chinese has no embedded `Hant` face: the desktop app places a Traditional Chinese face already installed on the machine (Microsoft JhengHei on Windows, PingFang TC on macOS, a Noto CJK TC or WenQuanYi Zen Hei on Linux) before the Japanese faces, even when LXGW is bundled, because a Simplified-only face cannot supply Traditional-only characters. Without an embedded `Hans` face, Simplified Chinese likewise uses an installed Simplified Chinese face (Microsoft YaHei, PingFang SC, …) placed before Japanese (#826). The web build has no installed fonts to fall back on, so it shows a replacement box for characters the Japanese face lacks. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). A build supplied with a craft-fonts `Telu` face draws Telugu and retains that face in the web payload; egui shapes its conjuncts. The release-pinned craft-fonts input has no Telugu face, so its Telugu labels show replacement boxes. -The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). +The Brazilian Portuguese catalog has 2,798 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). The Albanian catalog (`sq.tsv`, interface name **Shqip**) is written in Standard Albanian with the imperative for commands (`Ruaj`, `Hap`, `Fshi`). Albanian uses two plural forms like English: 1 is singular and every other count, including 0, is plural (`1 faqe`, `2 faqe`). Month and weekday names are lowercase (`mars`, `e shtunë`), and the letters `ë` and `ç` are covered by the bundled Latin faces. It has not been reviewed by a native Albanian speaker yet. ## Arabic and right-to-left text -The Arabic catalog has 2,137 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. +The Arabic catalog has 2,783 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. -egui joins the letters of an Arabic word and runs them right to left, but it places the words of a line left to right in the order they are written. So catalog entries are written in logical (typing) order, and `i18n` puts each one into display order when the catalog loads (`bidi::display_rtl`): the line reads right to left, Latin words and numbers inside it read left to right, `{name}` placeholders and keyboard shortcuts stay whole, spaces at either end stay where the code concatenates, and a short caption keeps its colon on the right, beside the widget it captions. Lookups (`tl!`, `tr`, `trn`) therefore return display-order text for Arabic; `i18n::fmt` inserts values as they are. The command search matches a query of several Arabic words word by word. +egui 0.37's epaint shapes every line with the Unicode bidirectional algorithm (UAX #9). Catalog entries stay in logical (typing) order and are never preordered: `tl!`, `tr` and `trn` return logical text, `bidi::display_rtl` and `bidi::visual` exist only for the tests that prove the old preordering is gone, and epaint joins and reorders Arabic on every line. Interface labels use an explicit `TextDirection::RightToLeft` in an Arabic, Kurdish or Persian interface, so a label whose first strong character is Latin or a `{placeholder}` still reads right to left. File and path labels use `TextDirection::LeftToRight`, so an Arabic file name keeps `.pdf` at its visual end; document-authored text uses `Auto`. Stock chrome is mirrored through `Style::right_to_left` (menus, combo boxes, scroll bars, headers), and `i18n::fmt` inserts values in one pass. The command search matches a query of several Arabic words word by word. -What Arabic does not have yet: +Wrapped paragraphs break between words in logical order and epaint reorders each resulting line, so a wrapped label reads in the right direction from the first line down. Text typed into a field is shaped and reordered while editing, and the stored value stays logical. -- **The layout is not mirrored.** Panels, menus, toolbars and alignment are as in the left-to-right languages, with Arabic labels in them. -- **A wrapped paragraph breaks at the wrong end.** A label too long for its line continues on the next line with its *first* words, so wrapped descriptions and tooltips read from the last line up. Single-line labels are right. -- **Text you type into a field** (search, comments, form values) has joined letters but its words run left to right while you edit it. What is stored is correct. -- Labels built by joining translated pieces in code (a list such as `A · B · C`) keep the code's left-to-right order. +Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` selects the explicit right-to-left direction; epaint reorders it, so it behaves as Arabic does above. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). -These need bidirectional support in the text layout itself, not in the catalog. +The Persian catalog (`fa.tsv`, interface name **فارسی**) has 2,785 entries, covering 2,785 of the current 2,798-key union; the 13 keys added after it was written still show English. It is written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish: `rtl: true` selects the explicit right-to-left direction, the entries stay in logical order and epaint reorders each line, and the stock chrome is mirrored as it is for Arabic. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. -Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). +Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic and Hebrew faces. Its Noto Sans CJK SC face is 16.4 MB and is left out by the general CJK cap; native builds take the separately pinned Simplified Chinese face (LXGW WenKai Lite) from `fonts/simplified-chinese-manifest.txt`, and the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. -The Persian catalog (`fa.tsv`, interface name **فارسی**) covers the current 2,776-key union, written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish (`rtl: true`, `bidi::display_rtl` at load; entries are written in logical order here) and has the same four limits above. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. +File names and document titles use the craft-fonts `Arab` face (Noto Sans Arabic) for Arabic and the `Hebr` face (Noto Sans Hebrew) for Hebrew when those faces are included in the build input; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order. -Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). - -File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. - -Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Catalog coverage does not guarantee glyph coverage. Native builds embed LXGW WenKai Lite as the Simplified Chinese face from the pinned craft-fonts input (at most 16 MiB), while the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces, so Chinese characters none of those faces has show replacement boxes on the web. Traditional Chinese has no embedded face and uses a `Hant` face installed on the machine, placed before the Japanese faces even when LXGW is bundled. German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. @@ -98,3 +91,5 @@ Error details that come from the operating system, and faults rather than mistak 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. + +The egui 0.37 migration pins craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. That manifest supplies Arabic, Hebrew, Thai and Devanagari faces selected for the web payload, but contains no Telugu face. The build code accepts a `Telu` row when a future input provides one; it does not establish Telugu glyph coverage with the current pin. diff --git a/docs/releasing.md b/docs/releasing.md index ec2e5853b..496ef4a67 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -148,7 +148,18 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - The portable zip holds both executables, the README, the licences, the OFL licence of each embedded craft-fonts family, and `portable.txt`. That marker beside `pdfcraft.exe` keeps the settings, logs, crash recovery and new digital IDs in `PdfCraftData\` next to the exe instead of - `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). + `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). The MSI carries the same + OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or + `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that + the compiled MSI has OFL notice rows when expected (and none for a no-font build). + Before either package is created, `font-notices.ps1` validates every declared OFL notice in + `fonts/manifest.txt` and the native `fonts/simplified-chinese-manifest.txt` when present + (required when `CRAFT_FONTS_REQUIRED` is set). Missing or empty family notices fail packaging; + one family's notice cannot cover another. The check conservatively includes declared faces + omitted by build-time size caps. `test-font-notices.ps1` exercises these failures in CI. + The supplied checkout must match the existing binaries when using `-SkipBuild`. + That presence check does not establish byte-for-byte payload or installation coverage; + final package qualification still checks all notices against the staged files. - **Signing:** `packaging/windows/sign.ps1` signs both executables and the MSI with `signtool` (SHA-256, RFC 3161 timestamp), from a `.pfx` (`WINDOWS_CERTIFICATE`) or Azure Trusted Signing (`AZURE_*`), whichever is configured. It is the one place to change when signing changes. diff --git a/docs/roadmap.md b/docs/roadmap.md index 832fabf54..40fa711c2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -135,15 +135,15 @@ In order. Each item names its entry in [gaps.md](gaps.md). Encrypted output that Acrobat opens shipped with #836 (#774). 2. **Fix the crash and wrong-result reports** filed against shipped features (gap 6). The first batch (#799–#821, including the panic in #816) is fixed. -3. **A Chinese UI face in releases** (gap 5, #826). +3. **A Chinese UI face in the web build** (gap 5, #826). 4. **A fidelity harness against Acrobat, then the renderer decision** (gaps 12 and 2). Owner decision needed: our own M2 devices, or #841's proposal as the bridge. 5. **Editing existing content** (gap 3, M7): fonts, subsets, reflow, CJK and RTL. 6. **Pro workflows:** OCR languages (gap 7), Office export/import (gap 8), Preflight and PDF/A/X/UA (gap 9), signatures with tokens, timestamp servers and online LTV (gap 10), accessibility tagging (gap 11). -7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), right-to-left layout and the - missing languages (gap 14), keyboard-only operation, a screen-reader audit. +7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), end-to-end right-to-left + qualification and the missing languages (gap 14), keyboard-only operation, a screen-reader audit. Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. @@ -199,7 +199,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -208,7 +208,7 @@ total matches the parity estimate. | Rank | Next milestone | Closes | Estimate (h) | |---|---|---|---| | 0 | Convert and share: Create PDF from Office, Word export keeps layout | part of gap 8 | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese UI face (web printing done) | gaps 5, 6 | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese web UI face (web printing done) | gaps 5, 6 | 50–90 | | 2 | Fidelity harness and renderer decision (M2) | gaps 2, 12 | 110–190 | | 3 | Editing existing content (M7) | gap 3 | 100–180 | | 4 | Pro conversion: OCR languages and clean-up, Office export/import (M10) | gaps 7, 8 | 95–175 | @@ -242,6 +242,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. Synthetic measurement and optimization regressions cover the change. Estimates unchanged. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b019bdf3d..6c7920e33 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding CJK mapping routes corrected; scores and estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -183,7 +183,7 @@ features total and are shown for orientation; the others add to it. | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | | Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | @@ -249,7 +249,7 @@ stage set to pre-alpha; see the revision history.) | Beta requirement | Hours | |---|---| | An interop suite that opens our outputs in other readers (encrypted output already opens in Acrobat, #774, #836; hours not re-estimated) | 8–15 | -| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); Chinese UI faces in releases | 80–140 | +| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); a Chinese web UI face | 80–140 | | Editing existing text robust on real files: subset fonts, CJK, RTL, rotated text | 70–120 | | Printing on Windows and the web | 15–30 | | OCR in the major languages (accents, Cyrillic, CJK), deskew and rotation | 35–60 | @@ -294,6 +294,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | | 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index b3b9c5384..8ae41ae97 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -48,6 +48,7 @@ modules: build-commands: - cargo --offline build --release --locked -p pdfcraft - install -Dm755 target/release/pdfcraft /app/bin/pdfcraft + - install -Dm644 NOTICE /app/share/doc/pdfcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.desktop /app/share/applications/ai.storyteller.pdfcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.mime.xml /app/share/mime/packages/ai.storyteller.pdfcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index df292455e..fd486c392 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -153,7 +153,6 @@ if has appimage; then fi APPDIR="$WORK/PdfCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/pdfcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 2e2ffc83f..50701e0c1 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/pdfcraft" "$APP/Contents/MacOS/PdfCraft" cp "$ROOT/assets/app-icon/pdfcraft.icns" "$APP/Contents/Resources/PdfCraft.icns" +# Required embedded-font notices are independent of optional font inputs. +cp "$ROOT/NOTICE" "$APP/Contents/Resources/NOTICE" # The standard About panel (PdfCraft menu > About PdfCraft) shows Credits.rtf under the version: # the website and Discord links the in-app About dialog has (#1287). cp "$HERE/Credits.rtf" "$APP/Contents/Resources/Credits.rtf" diff --git a/packaging/windows/font-notices.ps1 b/packaging/windows/font-notices.ps1 new file mode 100644 index 000000000..6d8fdc5a1 --- /dev/null +++ b/packaging/windows/font-notices.ps1 @@ -0,0 +1,48 @@ +# Validate one non-comment manifest row before adding its family notice. +function Get-CraftFontNoticeRow([string] $rootPath, [string] $manifest, [string] $line) { + $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } + # Manifest notice paths must name a family-local file, never escape the font input. + $relative = $fields[5].Replace('\', '/') + if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } + $family = $Matches[1] + $fontPath = $fields[2].Replace('\', '/') + if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { + throw "Font and notice must belong to the same family directory: $fontPath / $relative" + } + $notice = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { + throw "Missing or empty OFL notice for $($fields[0]): $notice" + } + return @{ Family = $family; Notice = Get-Item -LiteralPath $notice } +} + +# Validate every declared native font notice before either Windows package is created. +# Checking all declared faces is deliberately conservative: size-capped faces may not be embedded. +function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) { + $rootPath = (Resolve-Path -LiteralPath $FontRoot).Path + $notices = @{} + foreach ($name in 'manifest.txt', 'simplified-chinese-manifest.txt') { + $manifest = Join-Path $rootPath "fonts/$name" + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + if ($name -eq 'manifest.txt' -or $RequireChinese) { throw "Missing font manifest: $manifest" } + continue + } + foreach ($line in Get-Content -LiteralPath $manifest) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $entry = Get-CraftFontNoticeRow $rootPath $manifest $line + $notices[$entry.Family] = $entry.Notice + } + } + if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" } + foreach ($family in @($notices.Keys | Sort-Object)) { $notices[$family] } +} + +# MSI FileName may be short|long. Compare the long names with the staged inventory. +function Assert-CraftFontNoticeNames([string[]] $Expected, [string[]] $Actual) { + $names = @($Actual | ForEach-Object { ($_ -split '\|')[-1] }) + foreach ($name in $Expected) { + if ($name -notin $names) { throw "The MSI is missing staged font notice: $name" } + } +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 524280b97..dccc21f55 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -16,16 +16,30 @@ .EXAMPLE pwsh packaging/windows/package.ps1 -Arch x64 - pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild + pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild # set CRAFT_FONTS_DIR, or -BuiltWithoutCraftFonts pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools #> param( [ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64', - [switch] $SkipBuild + [switch] $SkipBuild, + # -SkipBuild only: the existing binaries were built without CRAFT_FONTS_DIR, so they embed no + # craft-fonts faces. Without CRAFT_FONTS_DIR this assertion is required: package.ps1 cannot + # otherwise know whether the binaries need the fonts' OFL notices. + [switch] $BuiltWithoutCraftFonts ) $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +if ($BuiltWithoutCraftFonts -and -not $SkipBuild) { + throw '-BuiltWithoutCraftFonts describes an existing build made without CRAFT_FONTS_DIR; use it only with -SkipBuild.' +} +if ($BuiltWithoutCraftFonts -and $env:CRAFT_FONTS_DIR) { + throw '-BuiltWithoutCraftFonts cannot be combined with CRAFT_FONTS_DIR.' +} +if ($SkipBuild -and -not $env:CRAFT_FONTS_DIR -and -not $BuiltWithoutCraftFonts) { + throw 'package.ps1 -SkipBuild needs CRAFT_FONTS_DIR to stage the OFL notices of embedded craft-fonts, or -BuiltWithoutCraftFonts when the binaries were built without it.' +} + function Invoke-Native([string] $What, [scriptblock] $Block) { Write-Output "==> $What" & $Block @@ -108,17 +122,38 @@ if (-not (Test-Path (Join-Path $Models 'ATTRIBUTION.txt')) -or -not (Get-ChildIt throw "no OCR models in $Models after cargo xtask models" } +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + # Validate both native manifests before copying anything. One family's notice cannot + # stand in for another's; retain complete files, including copyright and reserved names. + $fontNotices = @(Get-CraftFontNotices $env:CRAFT_FONTS_DIR (Test-Path Env:CRAFT_FONTS_REQUIRED)) + foreach ($ofl in $fontNotices) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "pdfcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'pdfcraft.wxs') -arch $Arch ` (Join-Path $PSScriptRoot 'installer-ui.wxs') ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "ModelsDir=$Models" -d "IconPath=$(Join-Path $Root 'assets\app-icon\pdfcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so # its Windows Installer database handle is gone before signtool opens the MSI. -Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi } +Invoke-Native 'test-msi' { + & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` + -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs +} # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi @@ -136,13 +171,10 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } -# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship their -# licences, fonts\\OFL.txt -> OFL-.txt. -if ($env:CRAFT_FONTS_DIR) { - Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts') -Directory -ErrorAction SilentlyContinue | ForEach-Object { - $ofl = Join-Path $_.FullName 'OFL.txt' - if (Test-Path $ofl) { Copy-Item $ofl (Join-Path $Portable "OFL-$($_.Name).txt") } - } +# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship the +# same verified notices the MSI carries, fonts\\OFL.txt -> OFL-.txt. +if ($HasCraftFontLicences -eq 1) { + Copy-Item (Join-Path $Docs 'OFL-*.txt') $Portable } # portable.txt beside pdfcraft.exe switches on portable mode: settings, logs, recovery files and new # digital IDs go to PdfCraftData\ next to the exe instead of %APPDATA% (#157; see diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index d96f2cd78..65e76f254 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -12,6 +12,9 @@ PdfCraft arrive on its command line. --> + + + + + + + + + + + diff --git a/packaging/windows/test-font-notices.ps1 b/packaging/windows/test-font-notices.ps1 new file mode 100644 index 000000000..4d2c81b08 --- /dev/null +++ b/packaging/windows/test-font-notices.ps1 @@ -0,0 +1,43 @@ +param([string] $CraftFontsRoot) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'font-notices.ps1') +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('pdfcraft-font-notices-' + [guid]::NewGuid()) +function Assert-Rejected([scriptblock] $Action, [string] $Message) { + $rejected = $false + try { & $Action | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw "Expected rejection: $Message" } +} +try { + foreach ($family in 'core', 'chinese') { + New-Item -ItemType Directory -Path (Join-Path $fixture "fonts/$family") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $fixture "fonts/$family/OFL.txt") -Value 'Synthetic notice fixture' + } + $core = 'Core | Regular | fonts/core/face.ttf | Latn | OFL-1.1 | fonts/core/OFL.txt' + $chinese = 'Chinese | Regular | fonts/chinese/face.ttf | Hans | OFL-1.1 | fonts/chinese/OFL.txt' + Set-Content (Join-Path $fixture 'fonts/manifest.txt') @('# comment', $core, $core) + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + if (@(Get-CraftFontNotices $fixture $true).Count -ne 2) { throw 'Expected both unique family notices' } + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', 'fonts/core/OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'another family cannot supply this notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + Remove-Item (Join-Path $fixture 'fonts/chinese/OFL.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'one remaining OFL cannot cover the Chinese family' + Set-Content (Join-Path $fixture 'fonts/chinese/OFL.txt') '' -NoNewline + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'empty family notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', '../OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'notice path escapes font input' + Remove-Item (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'required Chinese manifest missing' + if (@(Get-CraftFontNotices $fixture).Count -ne 1) { throw 'Optional Chinese manifest may be absent' } + Set-Content (Join-Path $fixture 'fonts/manifest.txt') 'malformed' + Assert-Rejected { Get-CraftFontNotices $fixture } 'malformed metadata' + Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('SHORT.TXT|OFL-core.txt', 'OFL-chinese.txt') + Assert-Rejected { Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('OFL-core.txt') } 'MSI lost one staged notice' + if ($CraftFontsRoot) { + $actual = @(Get-CraftFontNotices $CraftFontsRoot $true) + Write-Output "Verified $($actual.Count) notices from the actual native manifests" + } + Write-Output 'Font notice regression checks passed' +} finally { + Remove-Item -LiteralPath $fixture -Recurse -Force +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 45e1147fa..24a4c6d75 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -4,7 +4,13 @@ .EXAMPLE pwsh packaging/windows/test-msi.ps1 dist/release/pdfcraft-0.2.1-windows-x64.msi #> -param([Parameter(Mandatory)] [string] $Path) +param( + [Parameter(Mandatory)] [string] $Path, + # 1 when package.ps1 staged craft-fonts OFL notices, 0 when the binaries embed no craft-fonts. + # -1 (the default) skips the check when the MSI is inspected by hand. + [int] $CraftFontLicences = -1, + [string] $ExpectedFontNoticesDirectory +) $ErrorActionPreference = 'Stop' $Installer = New-Object -ComObject WindowsInstaller.Installer $Database = $Installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0) @@ -69,6 +75,30 @@ foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { } $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' + +# Embedded craft-fonts must ship their OFL notices; a build without them must not claim any. +# Check the compiled File table, not only the WiX source, so a staging or component-group mistake +# cannot silently omit the licences. +$fontNotices = @() +$fileView = $Database.OpenView('SELECT `FileName` FROM `File`') +try { + [void] $fileView.Execute() + while ($record = $fileView.Fetch()) { + $name = $record.StringData(1) + if ($name -like '*OFL-*') { $fontNotices += $name } + } +} finally { [void] $fileView.Close() } +if ($CraftFontLicences -eq 1 -and $fontNotices.Count -eq 0) { + throw 'the MSI carries no craft-font OFL notices' +} +if ($CraftFontLicences -eq 0 -and $fontNotices.Count -gt 0) { + throw "the MSI carries craft-font notices that packaging did not stage: $($fontNotices -join ', ')" +} +if ($ExpectedFontNoticesDirectory) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + $expected = @(Get-ChildItem -LiteralPath $ExpectedFontNoticesDirectory -File -Filter 'OFL-*.txt' | ForEach-Object { $_.Name }) + Assert-CraftFontNoticeNames $expected $fontNotices +} $status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProgress'' AND `Control` = ''Status''' 1 Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message' Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 914411254..8d2f3ecf6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -10012,8 +10012,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms. Catalogs stay logical and egui 0.37's epaint applies UAX #9 reordering; Arabic and Kurdish labels take an explicit right-to-left paragraph direction and stock chrome is mirrored through Style::right_to_left. Missing: native and browser end-to-end RTL input, IME and complex bidirectional workflows are not yet qualified; Czech dialogs, Chinese and Telugu font delivery (native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Chinese face beyond BIZ UDPGothic and the release-pinned craft-fonts has no Telugu face), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" @@ -10145,7 +10145,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] -notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." +notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Simplified Chinese has a native release LXGW WenKai Lite face but the web build has no Hans face (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic labels stay logical with explicit right-to-left direction and mirrored stock chrome while native/browser/IME RTL workflows are not yet qualified (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] id = "misc.rtl-ui" @@ -10157,7 +10157,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language." +notes = "Implemented and unit-tested: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop), keep logical text and are laid out left to right so the extension stays at the visual end; Arabic and Kurdish interface labels use an explicit right-to-left paragraph direction and stock chrome mirrors through Style::right_to_left (crates/ui-egui/src/bidi.rs, theme.rs); catalogs are never preordered. Still not qualified: native and browser end-to-end RTL input, IME composition, clipboard/selection and complex multi-line RTL workflows. Status stays planned until those workflows are accepted." [[feature]] id = "misc.installers" diff --git a/vendor/README.md b/vendor/README.md index e5f57ec1a..20fb0c75a 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,5 +1,27 @@ # Vendored crates +## Window-stack provenance after the egui 0.37 migration + +The checked-in, resolved Cargo graph pins `winit` to storytold/winit +`a517b3846ee8457d84ee4975110bfda6797c44dc` and the egui crates to storytold/egui +`6d518fc799681571963842f95b55a6b68431b15a`. Neither `vendor/winit` nor +`vendor/egui-wgpu` is a root `[patch.crates-io]` path entry. The retained copies +record the app's earlier patches; their source-reading tests establish provenance, +while the resolved forks and their behavioral tests establish runtime behavior. + +The retained winit copy has upstream base `e9809ef54b18499bb4f2cac945719ecc2a61061b`, +as recorded in `vendor/winit/.cargo_vcs_info.json`. Its thirteen patch rows below +match the fork's `PATCHES.md` entries W1–W13 commit for commit (W1 `b357a332`, +W2 `13ca693b`, W3 `fd104a4c`, W4 `3fffb7ce`, W5 `283370ce`, W6 `a82b90fb`, +W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W11 `4df8554b`, +W12 `7facf001`, W13 `5506b1a9`). The reviewed winit fork additionally carries +W14–W27 (W14 is opt-in and is not enabled by PdfCraft). `vendor/egui-wgpu` carries +E2 (`surface_fit`) and E3 (`catch_errors`); the egui-wgpu 0.37 fork retains both, +including its `surface_fit` tests exercised by PdfCraft's behavioral tests. + +Keep both copies while their provenance tests remain in use. Renderer and security +vendors below retain their independent path patches and licenses. + Permissively licensed crates we carry small patches for. Each directory keeps the upstream licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, is covered by a PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases @@ -23,11 +45,18 @@ Other resource categories retain their existing parent-chain lookup. | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | -| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | | hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | +`hayro-postscript` is the crates.io 0.1.0 package plus patch (1). The local Cargo +registry cache metadata for that package (`.cargo_vcs_info.json`) records upstream +VCS commit `a2354aebaf86f5ab13e5fd6b6b0521306488bf9d`. The registry copy and this vendored tree's +source files differ only in `src/object.rs` (the patch above); the vendored tree +omits the registry dotfiles `.cargo_vcs_info.json` and `.cargo-ok`, so the upstream +commit is recorded here. + Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline is returned as `WgpuError::CreateRenderer`, allowing PdfCraft's existing startup OpenGL retry.