From 14f40c408c67d2cb43e30a47dd179aada6dba53a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:48:52 -0600 Subject: [PATCH] Migrate DeckCraft to egui 0.37 (#1350) * egui037-20261022: migrate DeckCraft to egui 0.37 Preserve current-main document and PDF behavior alongside the reviewed input, capture, font, ribbon and notice-packaging migration. Validation: cargo xtask ci passed all eight stages, with 1,877 workspace tests and three doctests passed, six tests ignored, strict native lint and 23 WASM crates checked. Native GUI, platform and performance qualification remains separate. * E21: restore Latin coverage and enforce required font inputs * Fix migration review findings in font validation and UI controls * M-egui037: document UI lifecycle and regression contracts --- .github/workflows/ci.yml | 35 +- .github/workflows/freebsd.yml | 6 +- .github/workflows/release.yml | 5 +- ATTRIBUTION.md | 24 +- Cargo.lock | 417 +-- Cargo.toml | 24 +- NOTICE | 25 +- apps/deckcraft-web/src/web.rs | 23 +- apps/deckcraft/src/main.rs | 8 +- crates/engine/src/cmd/text.rs | 557 ++- crates/engine/src/keys.rs | 12 +- crates/engine/src/lib.rs | 35 +- crates/engine/src/tests.rs | 35 +- crates/engine/src/tools.rs | 2 +- crates/fonts/Cargo.toml | 2 +- crates/fonts/build.rs | 495 ++- crates/fonts/src/lib.rs | 22 + crates/fonts/tests/build_input.rs | 4 + .../fonts/tests/fixtures/latin-families.txt | 52 + crates/model/src/chart.rs | 3132 ++++++++--------- crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/src/backstage.rs | 103 +- crates/ui-egui/src/backstage_tests.rs | 95 + crates/ui-egui/src/canvas.rs | 49 +- crates/ui-egui/src/capture.rs | 1000 ++++++ crates/ui-egui/src/control.rs | 81 +- crates/ui-egui/src/credits.rs | 7 +- crates/ui-egui/src/datetime_tests.rs | 3 +- crates/ui-egui/src/dialogs.rs | 169 +- crates/ui-egui/src/eyedropper.rs | 3 +- crates/ui-egui/src/eyedropper_tests.rs | 3 +- crates/ui-egui/src/fillui.rs | 27 +- crates/ui-egui/src/huge_deck_tests.rs | 2 + crates/ui-egui/src/ime_tests.rs | 1804 +++++++++- crates/ui-egui/src/last_color_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 2450 ++++++++++++- crates/ui-egui/src/media.rs | 59 +- crates/ui-egui/src/opening.rs | 21 +- crates/ui-egui/src/panes.rs | 126 +- crates/ui-egui/src/paragraph_dialog_tests.rs | 18 +- crates/ui-egui/src/pattern_fill_tests.rs | 5 +- crates/ui-egui/src/photo_album_tests.rs | 6 +- crates/ui-egui/src/preferences_tests.rs | 7 +- crates/ui-egui/src/replacefonts_tests.rs | 31 +- crates/ui-egui/src/ribbon.rs | 418 ++- crates/ui-egui/src/selpane.rs | 28 +- crates/ui-egui/src/show.rs | 293 +- crates/ui-egui/src/sorter.rs | 36 +- crates/ui-egui/src/split_button_tests.rs | 136 + crates/ui-egui/src/squiggles.rs | 20 +- crates/ui-egui/src/status.rs | 61 +- crates/ui-egui/src/theme.rs | 186 +- crates/ui-egui/src/thumbs.rs | 6 +- crates/ui-egui/src/toast.rs | 5 +- crates/ui-egui/src/ui_soak_tests.rs | 5 +- crates/ui-egui/src/uifonts.rs | 39 +- crates/ui-egui/src/widgets.rs | 306 +- docs/egui-037-migration-plan.md | 1496 ++++++++ docs/localization-parity.md | 5 +- packaging/common.sh | 2 +- .../flatpak/ai.storyteller.deckcraft.yml | 1 + packaging/windows/deckcraft.wxs | 12 + packaging/windows/package.ps1 | 18 +- 63 files changed, 11517 insertions(+), 2553 deletions(-) create mode 100644 crates/fonts/tests/build_input.rs create mode 100644 crates/fonts/tests/fixtures/latin-families.txt create mode 100644 crates/ui-egui/src/capture.rs create mode 100644 crates/ui-egui/src/split_button_tests.rs create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105be52e..907aa336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,12 +17,45 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # Keep the early manifest preflight separate from the reusable workflow. The shared gate + # also runs with require-craft-fonts: true, so the build script enforces actual required faces. + # Keep this pin identical to gate, freebsd.yml and release.yml. + font-pin: + runs-on: ubuntu-latest + env: + # Same craft-fonts commit as release.yml. + CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69 + steps: + - name: Check out craft-fonts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + - name: Font pin gate + env: + # The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not + # a proposed future SHA; update it together with all three workflow pins. + PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + run: | + manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt" + if [ -f "$manifest" ]; then + echo "craft-fonts pin carries the reviewed simplified-Chinese manifest." + elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then + echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts." + else + echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it." + exit 1 + fi + gate: + needs: font-pin # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libasound2-dev 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 989b69ba..94ccca50 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -27,4 +27,8 @@ 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: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes + # CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not + # qualify the required Chinese input after the pin advances; that gate stays explicitly + # pending in docs/egui-037-migration-plan.md section 5.1. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c402be8..9d061bce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,10 @@ jobs: with: app-name: deckcraft app-title: DeckCraft - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Keep identical to ci.yml and freebsd.yml. The shared release workflow sets + # CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese + # manifest fails the release instead of silently skipping it. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3299a639..0e51ed27 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -41,8 +41,9 @@ artwork. The ribbon and menu icons stay original drawings. | Asset | Author | Source | Licence | Used for | |---|---|---|---|---| -| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback | -| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs | +| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback | +| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs | +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | ## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`) @@ -58,9 +59,14 @@ families excluded by `crates/fonts/build.rs`: | Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria | | Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New | | Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text | +| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text | +| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text | +| NanumGothic | OFL-1.1 | Korean text and interface fallback | +| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback | -Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular. +Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest +provides one) and BIZ UDPGothic Regular. ## PDF writer colour profiles @@ -75,3 +81,13 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE | `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles | | `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles | | `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles | + +### Optional native Chinese fallback + +Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present. +This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain +in craft-fonts, including the LXGW Reserved Font Names and original Klee notice. + +| Family | Author | Source | Licence | Use | +|---|---|---|---|---| +| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` | diff --git a/Cargo.lock b/Cargo.lock index 0b943728..324fff7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ 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 = [ "uuid", ] [[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", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[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.3", + "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.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,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", @@ -89,13 +83,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 0.62.2", "windows-core 0.62.2", @@ -103,11 +97,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", @@ -228,6 +223,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -554,42 +550,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.5", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cast" version = "0.3.0" @@ -1141,8 +1113,8 @@ dependencies = [ "kurbo", "log", "skrifa 0.47.0", - "vello_common 0.2.0", - "vello_cpu 0.2.0", + "vello_common", + "vello_cpu", ] [[package]] @@ -1174,11 +1146,12 @@ dependencies = [ "deckcraft-text", "egui", "egui_kittest", - "js-sys", "log", "serde", "serde_json", + "skrifa 0.47.0", "toml", + "web-time", ] [[package]] @@ -1292,9 +1265,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[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", @@ -1302,9 +1274,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", @@ -1326,6 +1297,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1337,9 +1309,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", @@ -1357,9 +1328,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1377,31 +1347,31 @@ 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", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", - "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[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", @@ -1413,9 +1383,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 = [ "dify", "eframe", @@ -1440,9 +1409,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[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", ] @@ -1490,9 +1458,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", @@ -1508,16 +1475,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu", ] [[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 = "equivalent" @@ -1609,6 +1577,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1640,6 +1614,12 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[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" @@ -1818,22 +1798,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1841,14 +1805,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "png", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1989,6 +1953,15 @@ dependencies = [ "smallvec", ] +[[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" @@ -1997,7 +1970,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2006,7 +1979,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2418,12 +2391,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]] @@ -2705,6 +2678,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3165,6 +3147,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 = "parking" version = "2.2.1" @@ -3226,10 +3218,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "phf" -version = "0.13.1" +name = "petgraph" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -3238,9 +3241,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3248,9 +3251,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3261,9 +3264,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -3718,6 +3721,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" @@ -3886,8 +3895,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -3904,44 +3913,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.5", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -4390,7 +4361,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4416,6 +4386,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 = "ttf-parser" version = "0.25.1" @@ -4534,21 +4515,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -4565,18 +4531,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -4585,13 +4539,13 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -4774,32 +4728,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -5456,8 +5384,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5465,7 +5392,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation", @@ -5486,7 +5413,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", @@ -5529,6 +5456,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 = "write-fonts" version = "0.48.1" @@ -5920,3 +5865,23 @@ 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_extras" +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 ed3d2d18..b4bea39e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,10 +66,11 @@ quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = { version = "0.37", default-features = false } +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" +web-time = "1.1" rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -104,3 +105,18 @@ debug = 0 [patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +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 79a0babb..438030f2 100644 --- a/NOTICE +++ b/NOTICE @@ -18,7 +18,13 @@ list (author, source, license) is in ATTRIBUTION.md. - Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans - Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages. + Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2, + Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release + packages (the exact font files and OFL texts are retained in craft-fonts releases). Native + builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW + WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font + and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is + not in the web payload. - Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0, commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide. ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather @@ -80,3 +86,20 @@ Code dependencies (https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources and hashes are in ATTRIBUTION.md. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +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/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index dabad8af..f8e68a35 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -11,6 +11,8 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"]; const CANVAS_ID: &str = "deckcraft_canvas"; const LOADING_ID: &str = "deckcraft_loading"; +/// Start the browser shell: install logging, locate the canvas, launch the eframe runner, +/// build the sample or blank deck, and replace the loading element when launch finishes. pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); wasm_bindgen_futures::spawn_local(async { @@ -36,6 +38,7 @@ pub fn start() { if let Some(rs) = &cc.wgpu_render_state { log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend); } + deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); let inbox: Inbox = Inbox::default(); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); let q = query(); @@ -74,11 +77,17 @@ struct WebShell { } impl eframe::App for WebShell { + /// Delegate one eframe logic pass to the wrapped application. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); - for f in dropped { - // Capture the document and slide now. The bytes arrive later, after the user may - // have switched slides. + self.app.logic(ctx); + } + + /// Consume browser drops with fresh input, capture the identity before the asynchronous read, + /// and deliver the id, name, and bytes through the inbox. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Browsers cannot read dropped files synchronously. Consume this pass's drops here, with + // fresh input, and capture the document/slide before the asynchronous read finishes. + for f in std::mem::take(&mut raw.dropped_files) { let id = self.app.begin_external_file("drop"); let inbox = self.inbox.clone(); let ctx = ctx.clone(); @@ -97,11 +106,7 @@ impl eframe::App for WebShell { } }); } - self.app.logic(ctx); - } - - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + self.app.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index e6bd6c47..b4afbb6b 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -42,8 +42,9 @@ impl eframe::App for App { apple_events::poll(&self.1, &mut self.0, ctx); self.0.logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + /// Forward native raw input to the wrapped application once per pass. + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.0.ui(ui); @@ -180,6 +181,8 @@ fn app_icon() -> Option { eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok() } +/// Install logging, parse startup arguments and environment, launch the native app, and +/// propagate an eframe startup failure. fn main() -> eframe::Result { // First, so every start-up record (and the engine's panic hook, installed with the first // Session) is captured; see `logging`. @@ -255,6 +258,7 @@ fn main() -> eframe::Result { "DeckCraft", options, Box::new(move |cc| { + deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); let mut app = SlideApp::new(Session::new(), services()); load_prefs(&mut app); // AutoRecover: reopen what a previous run left unsaved, then keep it current. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 4ee98fa7..22a26ea1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -10,8 +10,10 @@ use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind}; use serde_json::{Value, json}; use super::*; -use crate::{DocState, Result, Selection, Session, Target, TextSel}; +use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo}; +/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands +/// with their parameter docs and enabled predicates. pub fn specs() -> Vec { vec![ cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used), @@ -66,6 +68,30 @@ pub fn specs() -> Vec { has_text, delete ), + // An input method asks for text around the caret to be removed (macOS press-and-hold, + // dictation, Wayland text-input). Internal: the keyboard and control protocol keep the + // existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's + // `Ime::DeleteSurrounding`), otherwise characters, both in the logical text + // `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a + // range may cross paragraphs. The selection and pre-edit are kept; byte ranges round + // inward to whole scalars. A deletion while composing stays in the composition's single + // undo step. + // + // `undoable: false` is deliberate even though a non-composing deletion is undoable: the + // handler pushes its own history entry from a snapshot taken before the composition is + // replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays + // off for the same reason an input-method edit is not a replayable document command. + CommandSpec { + id: "text.deleteSurrounding", + label: "Delete Surrounding Text", + menu: &[], + shortcut: None, + params: "{before, after, bytes?: bool}", + enabled: has_text, + run: delete_surrounding, + journal: false, + undoable: false, + }, cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret), cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select), cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all), @@ -111,6 +137,240 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti Some(sh.text.get_or_insert_with(TextBody::default)) } +/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query. +/// +/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the +/// engine answer without copying a whole body or document. +pub const IME_SURROUNDING_MAX_BYTES: usize = 4000; + +/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8 +/// byte offsets. The current input-method composition (pre-edit) is removed from both the text +/// and the offsets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + pub text: String, + pub cursor: usize, + pub anchor: usize, +} + +/// How a soft line break reads in the IME view: a newline, like a paragraph break. +/// +/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would +/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines +/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change. +fn ime_char(ch: char) -> char { + if ch == '\u{b}' { '\n' } else { ch } +} + +/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`. +fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + p.runs + .iter() + .flat_map(|run| run.content().chars()) + .enumerate() + .filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))) +} + +/// The same characters, newest first, without walking the paragraph from its start. +fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator + '_ { + let mut starts = Vec::with_capacity(p.runs.len()); + let mut at = 0usize; + for r in &p.runs { + let n = r.char_len(); + starts.push((at, n)); + at += n; + } + p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| { + let content = r.content(); + content.chars().rev().enumerate().filter_map(move |(k, ch)| { + let i = start + n - 1 - k; + (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))) + }) + }) +} + +/// The composition's `(paragraph, start, end)` character range, when it is inside the body. +fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> { + let c = c?; + let para = body.paragraphs.get(c.start.0)?; + let start = c.start.1.min(para.char_len()); + let end = start.saturating_add(c.len).min(para.char_len()); + (start < end).then_some((c.start.0, start, end)) +} + +/// Return the composition character range only when its paragraph index equals `pi`. +fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> { + comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e)) +} + +/// Clip a model position to the body. An empty body only has position `(0, 0)`. +fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> { + if body.paragraphs.is_empty() { + return (pos == (0, 0)).then_some((0, 0)); + } + body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len()))) +} + +/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters), stopping at the end of the body. Returns the position reached and the characters +/// and bytes consumed. +/// +/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks +/// that paragraph's runs from their start: runs do not cache character lengths, and a position is +/// a character index. A megabyte paragraph therefore costs time proportional to the caret's +/// character offset, even though the copied surrounding text stays within `limit`. +fn walk_forward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + // `limit` comes from a (possibly hostile) request: every count is checked, never added + // blindly, so an enormous limit cannot overflow. + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + while let Some(para) = body.paragraphs.get(pi) { + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < at { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, i), chars, used); + } + used += n; + chars += 1; + at = i + 1; + } + if pi + 1 >= body.paragraphs.len() { + break; + } + // The paragraph separator between this paragraph and the next. + if !fits(used, chars, 1) { + return ((pi, at), chars, used); + } + used += 1; + chars += 1; + pi += 1; + at = 0; + } + ((pi, at), chars, used) +} + +/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole +/// characters). Returns the position reached and the characters and bytes consumed. +fn walk_backward( + body: &TextBody, + comp: Option<(usize, usize, usize)>, + from: (usize, usize), + limit: usize, + bytes: bool, +) -> ((usize, usize), usize, usize) { + let (mut pi, mut at) = from; + let (mut chars, mut used) = (0usize, 0usize); + let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit }; + loop { + let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) }; + let mut boundary = at.min(para.char_len()); + for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) { + if i >= boundary { + continue; + } + let n = ch.len_utf8(); + if !fits(used, chars, n) { + return ((pi, boundary), chars, used); + } + used += n; + chars += 1; + boundary = i; + } + // This paragraph is consumed down to its start. + if pi == 0 { + return ((0, 0), chars, used); + } + if !fits(used, chars, 1) { + return ((pi, 0), chars, used); + } + used += 1; + chars += 1; + pi -= 1; + at = usize::MAX; // all of the previous paragraph is before us + } +} + +/// The committed logical text between two model positions (excluding `end`). `None` when it is +/// longer than `cap` bytes: every caller only needs a bounded answer. +fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option { + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let mut out = String::new(); + for pi in a.0..=b.0 { + let Some(para) = body.paragraphs.get(pi) else { return Some(out) }; + let from = if pi == a.0 { a.1 } else { 0 }; + let to = if pi == b.0 { b.1 } else { usize::MAX }; + for (i, ch) in committed_chars(para, comp_in(comp, pi)) { + if i < from || i >= to { + continue; + } + out.push(ch); + if out.len() > cap { + return None; + } + } + if pi < b.0 { + out.push('\n'); + if out.len() > cap { + return None; + } + } + } + Some(out) +} + +/// Committed surrounding text for the text being edited. +/// +/// Returns `None` when the body or selection is invalid, or when the selection alone is wider +/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct +/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection. +/// +/// Only a bounded window is measured and copied: the query walks at most the budget out from the +/// selection on each side and does not copy the rest of the body. Finding the selection's +/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not +/// bounded independently of the paragraph length. +pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option { + let body = body_of(st, t)?; + let comp = composition_range(body, st.composition.as_ref()); + let caret = model_pos(body, t.caret)?; + let anchor = model_pos(body, t.anchor)?; + let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) }; + let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?; + let selection_len = selected.len(); + let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len); + let (_, _, before_available) = walk_backward(body, comp, start, budget, true); + let (_, _, after_available) = walk_forward(body, comp, end, budget, true); + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let (window_start, _, _) = walk_backward(body, comp, start, before, true); + let (window_end, _, _) = walk_forward(body, comp, end, after, true); + let before_text = logical_between(body, comp, window_start, start, budget)?; + let after_text = logical_between(body, comp, end, window_end, budget)?; + let before_len = before_text.len(); + let mut text = before_text; + text.push_str(&selected); + text.push_str(&after_text); + // The budget arithmetic above already bounds this; refuse rather than publish more. + if text.len() > IME_SURROUNDING_MAX_BYTES { + return None; + } + // The caret and anchor are the selection's own endpoints, so their offsets inside the + // window are the window's start plus either end of the selected text. + let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) }; + Some(ImeSurrounding { text, cursor, anchor }) +} + /// Edit the active text body and selection; refits auto-sizing text boxes afterwards. pub(crate) fn edit_text(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?; @@ -932,7 +1192,9 @@ fn compose(s: &mut Session, p: &Value) -> Result { // A composition whose text was changed under it is kept as it is; a new one starts. let current = match current { Some(c) if !composition_fits(s.doc()?, &c) => { - s.end_composition(); + // The text changed under the pre-edit: the app settled it, so the platform's copy + // must be cancelled too. + s.settle_composition(); None } c => c, @@ -1154,6 +1416,93 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { ok() } +/// A `before`/`after` count from a control or input-method request. +/// +/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is +/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that +/// would delete different text than asked. +fn count_param(p: &Value, name: &str) -> usize { + p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)) +} + +/// Delete at most the requested character or byte counts around the selection or pre-edit, +/// preserving selection direction and composition boundaries and recording one undo step +/// outside composition. +fn delete_surrounding(s: &mut Session, p: &Value) -> Result { + let before = count_param(p, "before"); + let after = count_param(p, "after"); + let bytes = bool_or(p, "bytes", false); + let st = s.doc()?; + let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?; + let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?; + let comp = composition_range(body, st.composition.as_ref()); + // The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a + // paragraph separator and for a soft line break. A range may therefore cross paragraphs. + let ordered = { + let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?; + if a <= b { (a, b) } else { (b, a) } + }; + // The selection (or the input method's pre-edit) stays exactly where it is. Only whole + // scalars fully inside the requested before/after ranges around it are removed. + let (start, end) = match comp { + Some((pi, cs, ce)) => ((pi, cs), (pi, ce)), + None => ordered, + }; + let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes); + let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes); + let deleted = before_chars + after_chars; + if deleted == 0 { + return Ok(json!({"deleted": 0})); + } + let before_doc = st.doc.clone(); + let before_sel = st.selection.clone(); + let composing = st.composition.is_some(); + let vertex_kind = s.tool.vertex_kind.clone(); + edit_text(s, |body, t| { + // Delete the after-range first, so the positions of the earlier range stay valid; then + // the text before the selection. + if after_chars > 0 { + ed::delete(body, end, to); + } + if before_chars > 0 { + ed::delete(body, from, start); + } + t.anchor = remap_after_delete(t.anchor, start, from, before_chars); + t.caret = remap_after_delete(t.caret, start, from, before_chars); + t.line_end = None; + })?; + if composing { + if let Some(c) = s.doc_mut()?.composition.as_mut() { + c.start = if before_chars == 0 { + start + } else if from.0 == start.0 { + (start.0, start.1 - before_chars) + } else { + from + }; + } + } else { + push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind }); + } + Ok(json!({"deleted": deleted})) +} + +/// Map a text position across the deletion of `before_chars` characters that begins at `start` +/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier +/// paragraph those paragraphs merge, so later paragraph numbers shift too. +fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) { + if before_chars == 0 || pos < start { + return pos; + } + if from.0 == start.0 { + // Only endpoints in the start paragraph move; later paragraphs keep their text. + if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos } + } else { + (from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 }) + } +} + fn delete(s: &mut Session, p: &Value) -> Result { delete_dir(s, str_param(p, "dir").unwrap_or("backward")) } @@ -1583,6 +1932,210 @@ mod rtl_promote_tests { } } +#[cfg(test)] +mod ime_surrounding_tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use serde_json::json; + + use crate::Session; + + /// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id. + fn edit(s: &mut Session) -> u64 { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("text.edit", &json!({"id": id})).unwrap(); + id + } + + /// Test helper: return the identified shape's text, or an empty string when it is gone. + fn text(s: &Session, id: u64) -> String { + let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap()); + s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default() + } + + /// Test helper: set an in-paragraph text selection through the engine command path. + fn select(s: &mut Session, anchor: usize, caret: usize) { + s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap(); + } + + /// DeleteSurrounding removes only the requested text around the selection. The selection + /// itself and its direction stay, so the following commit replaces it. + #[test] + fn selected_text_survives_the_requested_characters_or_bytes_around_it() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀 + s.delete_surrounding(1, 1, false).unwrap(); + assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 1, 3); + s.delete_surrounding(1, 1, true).unwrap(); + assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } + + /// A byte range removes only whole scalars fully contained in it. A count that ends inside + /// a character leaves that character whole (and undeleted), never splitting it. + #[test] + fn byte_counts_round_inward_to_whole_characters() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 2, 2); // after é: bytes 0..3 + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar"); + s.delete_surrounding(2, 0, true).unwrap(); + assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + + select(&mut s, 2, 2); + s.delete_surrounding(0, 1, true).unwrap(); + assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar"); + s.delete_surrounding(0, 4, true).unwrap(); + assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji"); + } + + /// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed + /// selection, removes the model-stored pre-edit, and treats an empty body as available. + #[test] + fn surrounding_text_is_committed_logical_and_bounded() { + use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding}; + + let mut s = Session::with_new(); + let id = edit(&mut s); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8)); + + s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor"); + assert_eq!(q.text, "aé😀b"); + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + s.compose_text("日本", None).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text"); + + s.execute("text.set", &json!({"id": id, "text": ""})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available"); + + let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500); + s.execute("text.set", &json!({"id": id, "text": long})).unwrap(); + s.execute("text.move", &json!({"to": "end"})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.clone().unwrap(); + let q = ime_surrounding(st, &t).unwrap(); + assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len()); + assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window"); + } + + /// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may + /// remove one and merge the paragraphs, exactly as the published offsets say. + #[test] + fn paragraph_marks_are_deletable_logical_text() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap(); + // The caret at the start of the second paragraph: after "ab" and its paragraph mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3)); + + // One byte before the caret is that paragraph mark. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge"); + let t = s.doc().unwrap().selection.text.clone().unwrap(); + assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "ab\ncd"); + + // Characters count the same mark. + s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap(); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + s.execute("edit.undo", &json!({})).unwrap(); + + // And from the other side: one character after the end of the first paragraph. + s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); + s.delete_surrounding(0, 1, false).unwrap(); + assert_eq!(text(&s, id), "abcd"); + } + + /// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the + /// platform must never see the model's `\u{b}`, which its own text filter would reject. + #[test] + fn soft_line_breaks_read_as_newlines() { + use super::ime_surrounding; + + let mut s = Session::with_new(); + let id = edit(&mut s); + s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates + + s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap(); + s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph"); + let st = s.doc().unwrap(); + let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap(); + assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2)); + + // The break is one logical byte, so removing it removes one character. + s.delete_surrounding(1, 0, true).unwrap(); + assert_eq!(text(&s, id), "ab"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "a\u{b}b"); + } + + /// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a + /// different number that would delete other text than the platform asked for. + #[test] + fn out_of_range_counts_clamp_instead_of_truncating() { + let mut s = Session::with_new(); + let id = edit(&mut s); + select(&mut s, 1, 1); + s.delete_surrounding(usize::MAX, 0, false).unwrap(); + assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic"); + let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX); + assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected); + assert!(expected > 0, "the count is clamped, not wrapped to nothing"); + assert_eq!(super::count_param(&json!({}), "before"), 0); + } + + /// A non-composing input-method deletion is exactly one undo step; the manual history entry + /// must not be duplicated by the generic command wrapper. + #[test] + fn a_non_composing_ime_deletion_is_one_undo_step() { + let mut s = Session::with_new(); + let id = edit(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + select(&mut s, 3, 3); + s.delete_surrounding(1, 0, false).unwrap(); + assert_eq!(text(&s, id), "aéb"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, id), "aé😀b"); + } +} + #[cfg(test)] mod replace_font_tests { use serde_json::json; diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index b164dbd6..0501a029 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -49,7 +49,7 @@ impl Session { /// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`). /// Returns `{handled: bool, …}`. pub fn key(&mut self, key: &str, mods: Mods) -> Result { - self.end_composition(); + self.settle_composition(); let editing = self.active().is_some_and(|d| d.selection.text.is_some()); let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty()); let k = key_token(key); @@ -266,6 +266,16 @@ impl Session { self.execute("text.commit", &json!({"text": text})) } + /// Remove text around the composition caret or selection, as an input method asks + /// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8 + /// bytes of the logical text the surrounding query publishes, so a range may cross a + /// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range + /// counts are clamped. A deletion while composing stays inside the composition's single undo + /// step. + pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result { + self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes})) + } + /// Ready to take typed `text`: editing text already, or one selected shape whose text editing /// starts with everything selected. False when there is nowhere to type (or nothing to type /// and nothing being edited). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf6d11de..0eabc5fc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -493,6 +493,8 @@ pub struct Session { /// Presentations a host read off the UI thread, waiting for `file.adopt` (by token). loaded: Vec<(u64, cmd::file::LoadedFile)>, next_loaded: u64, + /// Compositions the application settled itself (see [`Session::composition_settles`]). + composition_settles: u64, } impl Default for Session { @@ -502,6 +504,8 @@ impl Default for Session { } impl Session { + /// Install the process panic hook and construct an empty session with default preferences, + /// clipboard, tool state, and no active document. pub fn new() -> Self { guard::install_panic_hook(); Session { @@ -524,6 +528,7 @@ impl Session { panics_caught: 0, loaded: vec![], next_loaded: 0, + composition_settles: 0, } } /// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]); @@ -610,11 +615,13 @@ impl Session { self.guarded(id, |s| s.execute_unguarded(id, params)) } + /// Resolve and run a command, settling composition first, checking eligibility, rolling back a + /// failed outer command, and committing the resulting undo entry or journal record. fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; // Any other edit (or a click, a key, undo) settles an input method's composition first. if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") { - self.end_composition(); + self.settle_composition(); } if let Err(e) = (spec.enabled)(self) { let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some()); @@ -688,6 +695,9 @@ impl Session { /// Keep an input method's composition as it stands (#101): its text stays and the whole /// composition becomes one undo step. Nothing happens without one. + /// + /// This is also how the input method's own commit ends a composition: the platform has + /// already finished that one, so it must not ask for an interrupt. pub fn end_composition(&mut self) { let vertex_kind = self.tool.vertex_kind.clone(); let Some(st) = self.active_mut() else { return }; @@ -697,6 +707,27 @@ impl Session { } } + /// Settle a composition because the application did something else (a click, a key, a + /// command, a target change) rather than because the input method committed it. + /// + /// The platform still holds its own copy of that composition, so a host must publish one + /// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the + /// backend then cancels it instead of committing its text into whatever is selected next. + pub fn settle_composition(&mut self) { + if self.active().is_some_and(|d| d.composition.is_some()) { + self.composition_settles = self.composition_settles.wrapping_add(1); + } + self.end_composition(); + } + + /// How many compositions the application settled itself (clicks, keys, other commands). + /// + /// A host compares this with the value it last published; every increment is one platform + /// composition that must be interrupted. The input method's own commits do not count. + pub fn composition_settles(&self) -> u64 { + self.composition_settles + } + /// Change only the selection (no undo step). pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { let st = self.doc_mut()?; @@ -712,7 +743,7 @@ impl Session { st.revision += 1; // A composition belongs to the text body it began in: leaving that body settles it. if st.composition.is_some() && owner(&st.selection) != before { - self.end_composition(); + self.settle_composition(); } Ok(()) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 770d341e..eb0cb91d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4088,6 +4088,37 @@ fn tables_with_a_file_defined_style_edit_and_resize() { use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef}; let mut s = session(); let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]])); + // A file-defined weight needs a real regular/bold pair, independent of missing Inter + // or whichever system face happened to load first. Leave bold unset for the table style. + let fonts = deckcraft_fonts::FontDb::global(); + let regular = fonts.face("BIZ UDPGothic", "Regular"); + let bold = fonts.face("BIZ UDPGothic", "Bold"); + let metric_pair = regular.family == "BIZ UDPGothic" + && bold.family == "BIZ UDPGothic" + && regular.style == "Regular" + && bold.style == "Bold" + && regular.weight < bold.weight; + // Required native builds validate and embed both BIZ faces (crates/fonts/build.rs); + // optional local builds may only have Regular, so the weight assertion is skipped there. + assert!( + metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(), + "required native build is missing the BIZ UDPGothic Regular/Bold metric pair" + ); + if metric_pair { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let deckcraft_model::ShapeKind::Table(table) = &mut slide.shape_mut(id).unwrap().kind else { panic!("not a table") }; + for cell in &mut table.rows[0].cells { + for paragraph in &mut cell.text.paragraphs { + for run in &mut paragraph.runs { + run.props.font = Some("BIZ UDPGothic".into()); + run.props.bold = None; + } + } + } + } else { + eprintln!("optional BIZ pair absent: skipping weight-metric assertion; edit/resize checks still run"); + } s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); let built_in = caret_point(&s, 0, 8); s.execute("text.exit", &json!({})).unwrap(); @@ -4112,7 +4143,9 @@ fn tables_with_a_file_defined_style_edit_and_resize() { s.execute("edit.deselect", &json!({})).unwrap(); s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap(); let p = caret_point(&s, 0, 8); - assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + if metric_pair { + assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}"); + } s.execute("text.exit", &json!({})).unwrap(); s.execute("edit.deselect", &json!({})).unwrap(); // A double-click selects the word drawn there. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 72393183..2325cb19 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -459,7 +459,7 @@ impl Session { } // A press (not a hover) settles an input method's composition, as clicking does in PowerPoint. if !matches!(ev.kind, PointerKind::Move) { - self.end_composition(); + self.settle_composition(); } match ev.kind { PointerKind::Move => { diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 64944f0f..dd873f65 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -15,4 +15,4 @@ harfrust = { workspace = true } unicode-bidi = { workspace = true } log = { workspace = true } # Last-resort face only (Ubuntu Light); no UI code. -epaint_default_fonts = "0.36" +epaint_default_fonts = "0.37" diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 76e7dc40..6cf744f5 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -1,27 +1,61 @@ //! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its //! `docs/integration.md`). With `CRAFT_FONTS_DIR=` the fonts in its //! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as -//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter -//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the -//! local checkout: no network. +//! `CRAFT_FONTS`, Latin first. Native builds additionally read +//! `fonts/simplified-chinese-manifest.txt`; it is optional for ordinary local builds but a required +//! native input (`CRAFT_FONTS_REQUIRED`) fails without it. Unset, `CRAFT_FONTS` is empty. Web +//! (wasm32) builds embed only Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic +//! Regular, to stay within the web size budget; native builds also carry BIZ UDPGothic Bold. +//! Required builds verify the faces they promise survive exclusion (and, on wasm, the web filter), +//! so a pin without the Latin manifest or the native bold face fails instead of silently dropping +//! them. It only reads the local checkout: no network. use std::fmt::Write as _; use std::path::PathBuf; -/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design). -const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"]; +/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design, and no Noto +/// CJK rebrand of Source Han on any platform). +const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"]; +/// Reject source families prohibited by the asset policy, including Source Han and every +/// Noto CJK/Serif/Mono CJK variant. +fn is_excluded_family(family: &str) -> bool { + EXCLUDED_FAMILIES.contains(&family) + || family.starts_with("Source Han Sans") + || family.starts_with("Source Han Serif") + || family.starts_with("Noto Sans CJK") + || family.starts_with("Noto Serif CJK") + || family.starts_with("Noto Sans Mono CJK") +} + +/// The generated initialisers plus the faces that were actually embedded after exclusion and web +/// filtering. Validation must use the latter, not the raw manifest text. +#[derive(Default)] +struct FontInput { + source: String, + faces: Vec<(String, String)>, +} + +impl FontInput { + /// Concatenate another input's generated source and embedded-face list into this one. + fn append(&mut self, other: Self) { + self.source.push_str(&other.source); + self.faces.extend(other.faces); + } +} + +/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input +/// failures as warnings unless required, and always fail when the generated file cannot be written. 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"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { - 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) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + match input_fonts(dir.as_deref(), wasm, required) { + Ok(entries) => src.push_str(&entries), + Err(e) if required => println!("cargo::error={e}"), + Err(e) => println!("cargo::warning=building without craft-fonts: {e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -31,30 +65,88 @@ fn main() { } /// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let mut out = String::new(); - // The Latin presentation fonts are optional within craft-fonts (older checkouts lack them). - let latin = dir.join("fonts/latin-manifest.txt"); - println!("cargo::rerun-if-changed={}", latin.display()); - if latin.exists() { - out.push_str(&manifest(dir, &latin)?); - } - out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?); - Ok(out) +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result { + load_fonts(dir, wasm, required).map(|input| input.source) } -fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result { +/// Use no fonts when the directory is absent unless required; otherwise load the manifests. +fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result { + match dir { + Some(dir) => craft_fonts(dir, wasm, required), + None if required => Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()), + None => Ok(FontInput::default()), + } +} + +/// Load optional Latin input, required main input, and native-only Chinese input, then validate +/// the promised required faces after filtering. +fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result { + let mut input = FontInput::default(); + // The Latin presentation fonts are optional within craft-fonts (older checkouts lack them). + // A malformed or incomplete optional manifest is skipped as a unit: the main Japanese/symbol/RTL + // faces and the native Chinese addition stay usable, and required builds still fail validation. + let latin = dir.join("fonts/latin-manifest.txt"); + println!("cargo::rerun-if-changed={}", latin.display()); + let latin_exists = match latin.try_exists() { + Ok(exists) => exists, + Err(e) if required => return Err(format!("{}: {e}", latin.display())), + Err(e) => { + println!("cargo::warning=skipping optional Latin fonts: {}: {e}", latin.display()); + false + } + }; + if latin_exists { + match manifest(dir, &latin, wasm) { + Ok(entries) => input.append(entries), + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Latin fonts: {e}"), + } + } + input.append(manifest(dir, &dir.join("fonts/manifest.txt"), wasm)?); + // Native consumers opt in; older craft-fonts checkouts need not carry this manifest. + // An invalid optional addition must never discard the main Japanese/symbol/RTL faces. + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + let exists = match chinese.try_exists() { + Ok(exists) => exists, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => { + println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()); + false + } + }; + if exists { + match manifest(dir, &chinese, false) { + Ok(entries) => input.append(entries), + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + } + } else if required { + // A required native font input that silently lacks the reviewed Chinese manifest + // would make the Chinese UI/canvas test pass only by skipping. + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + } + if required { + validate_required_faces(&input.faces, wasm)?; + } + Ok(input) +} + +/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter, +/// canonicalise assets, and emit initialisers. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: 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 wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); - let mut out = String::new(); + let mut input = FontInput::default(); 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}")); }; // DeckCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones. - if EXCLUDED_FAMILIES.contains(family) { + if is_excluded_family(family) { continue; } if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) { @@ -63,12 +155,357 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + input.faces.push(((*family).to_owned(), (*style).to_owned())); let _ = writeln!( - out, + input.source, " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", scripts.join(", "), path.display().to_string(), ); } - Ok(out) + Ok(input) +} + +/// Required builds must actually carry the faces the platform promises: Inter Regular and Bold +/// (or one Variable face) and BIZ UDPGothic Regular everywhere, BIZ UDPGothic Bold natively (the +/// weight-pair metric test relies on it), plus LXGW WenKai Lite Regular for native builds. This +/// runs after exclusion and, on wasm, the web filter, so an absent manifest, empty/all-excluded +/// rows, a wrong-family row or a partial catalog cannot satisfy the gate. Wasm deliberately +/// embeds only BIZ Regular to stay within the web size budget. +fn validate_required_faces(faces: &[(String, String)], wasm: bool) -> Result<(), String> { + let has = |family: &str, style: &str| faces.iter().any(|(f, s)| f == family && s == style); + let inter = has("Inter", "Variable") || (has("Inter", "Regular") && has("Inter", "Bold")); + let biz_regular = has("BIZ UDPGothic", "Regular"); + let biz_bold = wasm || has("BIZ UDPGothic", "Bold"); + let chinese = wasm || has("LXGW WenKai Lite", "Regular"); + let mut missing = Vec::new(); + if !inter { + missing.push("Inter Regular/Bold (or Inter Variable)"); + } + if !biz_regular { + missing.push("BIZ UDPGothic Regular"); + } + if !biz_bold { + missing.push("BIZ UDPGothic Bold"); + } + if !chinese { + missing.push("LXGW WenKai Lite Regular"); + } + if missing.is_empty() { + Ok(()) + } else { + Err(format!("CRAFT_FONTS_REQUIRED is set but the embedded font set after filtering lacks {}", missing.join(" and "))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + const CHINESE_ROW: &str = "LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"; + + struct InputFixture(PathBuf); + + impl InputFixture { + /// Test helper: create a unique temporary fixture with a main manifest and asset file. + fn new() -> Self { + let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&path).unwrap(); + std::fs::create_dir(path.join("fonts")).unwrap(); + std::fs::write(path.join("fonts/fixture.ttf"), b"build-input path fixture").unwrap(); + std::fs::write( + path.join("fonts/manifest.txt"), + "BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + BIZ UDPGothic | Bold | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + ) + .unwrap(); + Self(path) + } + + /// Test helper: replace the fixture's main manifest contents. + fn main(&self, text: &str) { + std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap(); + } + + /// Test helper: write the fixture's optional Latin manifest. + fn latin(&self, text: &str) { + std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap(); + } + + /// Test helper: remove the optional Latin manifest. + fn no_latin(&self) { + let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt")); + } + + /// Test helper: install a valid Inter Regular/Bold Latin manifest. + fn valid_latin(&self) { + self.latin( + "Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + ); + } + + /// Test helper: write the optional native Chinese manifest with the supplied rows. + fn optional(&self, text: &str) { + std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap(); + } + } + + impl Drop for InputFixture { + /// Test helper: best-effort removal of the temporary fixture directory. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A required build without a font directory fails; an optional build embeds no fonts. + #[test] + fn required_input_cannot_silently_disappear() { + assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR")); + assert_eq!(input_fonts(None, false, false).unwrap(), ""); + } + + /// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog + /// unchanged in optional builds, while a required build fails. + #[test] + fn broken_optional_manifest_preserves_the_main_fonts() { + let fixture = InputFixture::new(); + let main = input_fonts(Some(&fixture.0), false, false).unwrap(); + fixture.optional("malformed row"); + assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main); + assert!(input_fonts(Some(&fixture.0), false, true).is_err()); + fixture.optional("LXGW WenKai Lite | Regular | missing.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid"); + assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main); + assert!(input_fonts(Some(&fixture.0), false, true).is_err()); + } + + /// Adding or breaking native Chinese input must not change the wasm font initialiser bytes. + #[test] + fn native_opt_in_does_not_change_wasm_embedded_bytes() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + let before = input_fonts(Some(&fixture.0), true, true).unwrap(); + fixture.optional(CHINESE_ROW); + let native = input_fonts(Some(&fixture.0), false, true).unwrap(); + assert!(native.contains("LXGW WenKai Lite")); + assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before); + fixture.optional("malformed row"); + assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before); + } + + /// Required native builds fail without the Chinese manifest; optional native and wasm builds + /// stay valid. + #[test] + fn required_native_input_requires_the_chinese_manifest() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + assert!(input_fonts(Some(&fixture.0), false, true).unwrap_err().contains("simplified-Chinese")); + // Optional native builds and wasm keep working without it. + assert!(input_fonts(Some(&fixture.0), false, false).is_ok()); + assert!(input_fonts(Some(&fixture.0), true, true).is_ok()); + } + + /// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and + /// Hebrew faces survive. + #[test] + fn all_noto_cjk_families_are_excluded_from_every_platform() { + for family in [ + "Noto Sans CJK SC", + "Noto Sans CJK TC", + "Noto Sans CJK JP", + "Noto Sans CJK KR", + "Noto Sans CJK HK", + "Noto Serif CJK SC", + "Noto Serif CJK JP", + "Noto Sans Mono CJK SC", + "Source Han Sans SC", + "Source Han Serif JP", + ] { + assert!(is_excluded_family(family), "{family} would be embedded"); + } + for family in ["LXGW WenKai Lite", "Noto Sans Hebrew", "Inter", "Noto Sans Symbols 2"] { + assert!(!is_excluded_family(family), "{family} is an allowed input"); + } + let fixture = InputFixture::new(); + fixture.valid_latin(); + let mut rows = String::from(CHINESE_ROW); + for family in ["Noto Sans CJK SC", "Noto Serif CJK JP", "Noto Sans Mono CJK KR"] { + rows.push_str(&format!("{family} | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n")); + } + fixture.optional(&rows); + let native = input_fonts(Some(&fixture.0), false, true).unwrap(); + assert!(!native.contains("Noto Sans CJK") && !native.contains("Noto Serif CJK") && !native.contains("Noto Sans Mono CJK")); + assert!(native.contains("BIZ UDPGothic"), "the allowed main manifest is retained"); + assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained"); + } + + /// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a + /// nonempty manifest. + #[test] + fn required_native_chinese_manifest_must_emit_the_reviewed_face() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + let excluded = "Noto Sans CJK SC | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + Noto Serif CJK JP | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"; + let wrong_family = "LXGW WenKai | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"; + for rows in ["", excluded, wrong_family] { + fixture.optional(rows); + let e = input_fonts(Some(&fixture.0), false, true).unwrap_err(); + assert!(e.contains("LXGW WenKai Lite Regular"), "rows={rows:?}: {e}"); + assert!(input_fonts(Some(&fixture.0), false, false).is_ok(), "optional builds still preserve the valid core"); + } + fixture.optional(CHINESE_ROW); + let native = input_fonts(Some(&fixture.0), false, true).unwrap(); + assert!(native.contains("CraftFont { family: \"LXGW WenKai Lite\", style: \"Regular\""), "{native}"); + assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained"); + } + + /// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese + /// faces; required builds still fail. + #[test] + fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() { + let fixture = InputFixture::new(); + fixture.optional(CHINESE_ROW); + let missing_asset = "Inter | Regular | fonts/missing.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"; + for latin in [None, Some("malformed row"), Some(missing_asset)] { + match latin { + Some(text) => fixture.latin(text), + None => fixture.no_latin(), + } + for wasm in [false, true] { + let catalog = input_fonts(Some(&fixture.0), wasm, false).unwrap(); + assert!(catalog.contains("BIZ UDPGothic"), "wasm={wasm}: the main manifest is retained"); + assert!(!catalog.contains("Inter"), "wasm={wasm}: the broken Latin manifest is skipped as a unit"); + if !wasm { + assert!(catalog.contains("LXGW WenKai Lite"), "the valid Chinese addition is retained"); + } + } + assert!(input_fonts(Some(&fixture.0), false, true).is_err(), "required builds must not skip the missing required faces"); + } + } + + /// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm + /// remains a three-face subset. + #[test] + fn full_prior_latin_catalog_keeps_permitted_native_families() { + let fixture = InputFixture::new(); + fixture.latin(include_str!("tests/fixtures/latin-families.txt")); + fixture.optional(CHINESE_ROW); + let native = input_fonts(Some(&fixture.0), false, true).unwrap(); + assert_eq!(native.matches("CraftFont {").count(), 46, "43 permitted Latin faces plus BIZ Regular/Bold and LXGW"); + for family in [ + "Carlito", + "Caladea", + "Liberation Serif", + "Montserrat", + "Lato", + "Open Sans", + "Roboto", + "Merriweather", + "Playfair Display", + "Poppins", + "Nunito Sans", + ] { + assert!(native.contains(family), "missing prior presentation family {family}"); + } + let web = input_fonts(Some(&fixture.0), true, true).unwrap(); + assert_eq!(web.matches("CraftFont {").count(), 3, "Inter Regular/Bold and BIZ only"); + for catalog in [&native, &web] { + assert!(!catalog.contains("Source Sans 3")); + assert!(!catalog.contains("Source Serif 4")); + } + } + + /// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is + /// mandatory. + #[test] + fn required_builds_need_the_latin_catalog_for_inter() { + let fixture = InputFixture::new(); + fixture.optional(CHINESE_ROW); + for wasm in [true, false] { + let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err(); + assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}"); + assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work"); + } + } + + /// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required + /// Inter contract. + #[test] + fn required_builds_reject_partial_or_unrelated_faces() { + let fixture = InputFixture::new(); + fixture.optional(CHINESE_ROW); + for latin in [ + "Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + "Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + "Inter | Italic | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + Inter | SemiBold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + "Carlito | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + "Source Sans 3 | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + Source Sans 3 | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + ] { + fixture.latin(latin); + for wasm in [true, false] { + let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err(); + assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}"); + assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work"); + } + } + } + + /// BIZ UDPGothic Regular is required on every platform; required native builds additionally + /// need Bold. + #[test] + fn required_builds_need_biz_udpgothic_regular() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + fixture.main( + "Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\ + Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n", + ); + fixture.optional(CHINESE_ROW); + for wasm in [true, false] { + let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err(); + assert!(e.contains("BIZ UDPGothic Regular"), "wasm={wasm}: {e}"); + if !wasm { + assert!(e.contains("BIZ UDPGothic Bold"), "native builds need the weight pair: {e}"); + } + assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work"); + } + } + + /// Native required builds need the real BIZ Bold face for weight metrics; wasm deliberately + /// embeds only Regular to stay within its size budget. This pins the contract the engine's + /// required-build metric pair assertion relies on. + #[test] + fn required_native_builds_need_biz_bold_but_wasm_does_not() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + fixture.optional(CHINESE_ROW); + fixture.main("BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"); + let e = input_fonts(Some(&fixture.0), false, true).unwrap_err(); + assert!(e.contains("BIZ UDPGothic Bold"), "required native build accepted a Regular-only catalog: {e}"); + let web = input_fonts(Some(&fixture.0), true, true).unwrap(); + assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only"); + } + + /// Required checks accept either static Inter Regular+Bold or a single Variable face, together + /// with BIZ. + #[test] + fn required_builds_accept_static_or_variable_inter_with_biz() { + let fixture = InputFixture::new(); + fixture.valid_latin(); + fixture.optional(CHINESE_ROW); + for wasm in [true, false] { + let static_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap(); + assert!(static_catalog.contains("Inter") && static_catalog.contains("BIZ UDPGothic")); + } + fixture.latin("Inter | Variable | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"); + for wasm in [true, false] { + let variable_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap(); + assert!(variable_catalog.contains("Inter") && variable_catalog.contains("BIZ UDPGothic")); + } + } } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a9f0c050..883ceb4e 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -409,16 +409,38 @@ mod tests { assert!(feature("liga").is_some()); } + /// When the Latin build input exists, the runtime catalogue carries the expected Latin faces; + /// required builds also assert their presence. #[test] fn craft_fonts_latin_when_present() { + let required = option_env!("CRAFT_FONTS_REQUIRED").is_some(); if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") { + assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest"); eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)"); return; } + let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style); + let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable")); + if required { + assert!( + variable.is_some() || (regular.is_some() && bold.is_some()), + "CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)" + ); + } let db = FontDb::with_font_dirs(Vec::new()); db.set_system_fallback(false); assert_eq!(db.face("Inter", "Regular").family, "Inter"); assert_eq!(db.face("Aptos", "Regular").family, "Inter"); + if let (Some(regular), Some(bold)) = (regular, bold) { + // The build input carries one entry per face; real static Bold must not be the Regular + // file relabelled in the manifest. + assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular"); + let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold")); + assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular")); + assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold")); + assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face"); + assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight); + } if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") { assert_eq!(db.face("Calibri", "Bold").family, "Carlito"); } diff --git a/crates/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 00000000..62613339 --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the build-input parser without changing process environment variables. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/fonts/tests/fixtures/latin-families.txt b/crates/fonts/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..91839018 --- /dev/null +++ b/crates/fonts/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/fixture.ttf | Latn +Inter | Italic | fonts/fixture.ttf | Latn +Inter | SemiBold | fonts/fixture.ttf | Latn +Inter | Bold | fonts/fixture.ttf | Latn +Carlito | Regular | fonts/fixture.ttf | Latn +Carlito | Bold | fonts/fixture.ttf | Latn +Carlito | Italic | fonts/fixture.ttf | Latn +Carlito | Bold Italic | fonts/fixture.ttf | Latn +Caladea | Regular | fonts/fixture.ttf | Latn +Caladea | Bold | fonts/fixture.ttf | Latn +Caladea | Italic | fonts/fixture.ttf | Latn +Caladea | Bold Italic | fonts/fixture.ttf | Latn +Liberation Sans | Regular | fonts/fixture.ttf | Latn +Liberation Sans | Bold | fonts/fixture.ttf | Latn +Liberation Sans | Italic | fonts/fixture.ttf | Latn +Liberation Sans | Bold Italic | fonts/fixture.ttf | Latn +Liberation Serif | Regular | fonts/fixture.ttf | Latn +Liberation Serif | Bold | fonts/fixture.ttf | Latn +Liberation Serif | Italic | fonts/fixture.ttf | Latn +Liberation Serif | Bold Italic | fonts/fixture.ttf | Latn +Liberation Mono | Regular | fonts/fixture.ttf | Latn +Liberation Mono | Bold | fonts/fixture.ttf | Latn +Liberation Mono | Italic | fonts/fixture.ttf | Latn +Liberation Mono | Bold Italic | fonts/fixture.ttf | Latn +Source Sans 3 | Regular | fonts/fixture.ttf | Latn +Source Sans 3 | Italic | fonts/fixture.ttf | Latn +Source Sans 3 | Semibold | fonts/fixture.ttf | Latn +Source Sans 3 | Bold | fonts/fixture.ttf | Latn +Source Serif 4 | Regular | fonts/fixture.ttf | Latn +Source Serif 4 | Italic | fonts/fixture.ttf | Latn +Source Serif 4 | Semibold | fonts/fixture.ttf | Latn +Source Serif 4 | Bold | fonts/fixture.ttf | Latn +Montserrat | Variable | fonts/fixture.ttf | Latn +Montserrat | Italic Variable | fonts/fixture.ttf | Latn +Lato | Regular | fonts/fixture.ttf | Latn +Lato | Bold | fonts/fixture.ttf | Latn +Lato | Italic | fonts/fixture.ttf | Latn +Open Sans | Variable | fonts/fixture.ttf | Latn +Open Sans | Italic Variable | fonts/fixture.ttf | Latn +Roboto | Variable | fonts/fixture.ttf | Latn +Roboto | Italic Variable | fonts/fixture.ttf | Latn +Merriweather | Regular | fonts/fixture.ttf | Latn +Merriweather | Bold | fonts/fixture.ttf | Latn +Merriweather | Italic | fonts/fixture.ttf | Latn +Playfair Display | Variable | fonts/fixture.ttf | Latn +Playfair Display | Italic Variable | fonts/fixture.ttf | Latn +Poppins | Regular | fonts/fixture.ttf | Latn +Poppins | Bold | fonts/fixture.ttf | Latn +Poppins | Italic | fonts/fixture.ttf | Latn +Nunito Sans | Variable | fonts/fixture.ttf | Latn +Nunito Sans | Italic Variable | fonts/fixture.ttf | Latn diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 3c5bf078..9d3dc738 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -1,1566 +1,1566 @@ -//! Charts: an embedded data grid plus type and element options. - -use serde::{Deserialize, Serialize}; - -use crate::style::{ColorRef, Fill, Line}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ChartType { - #[default] - Column, - StackedColumn, - PercentColumn, - Bar, - StackedBar, - PercentBar, - Line, - LineMarkers, - StackedLine, - Area, - StackedArea, - Pie, - Doughnut, - Scatter, - Bubble, - Radar, - FilledRadar, - Stock, - Surface, - Histogram, - Pareto, - BoxWhisker, - Waterfall, - Funnel, - Treemap, - Sunburst, - /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it - /// is not offered in the chart menus (PowerPoint needs an online map service to make one). - RegionMap, - Combo, -} - -impl ChartType { - /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. - pub const OFFICE_2016: [ChartType; 7] = [ - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Pareto, - ChartType::BoxWhisker, - ChartType::Treemap, - ChartType::Sunburst, - ]; - - /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). - pub fn is_chartex(self) -> bool { - Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap - } - - pub const MAIN: [ChartType; 12] = [ - ChartType::Column, - ChartType::Bar, - ChartType::Line, - ChartType::Area, - ChartType::Pie, - ChartType::Doughnut, - ChartType::Scatter, - ChartType::Radar, - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Treemap, - ]; - pub fn label(self) -> &'static str { - match self { - ChartType::Column => "Clustered Column", - ChartType::StackedColumn => "Stacked Column", - ChartType::PercentColumn => "100% Stacked Column", - ChartType::Bar => "Clustered Bar", - ChartType::StackedBar => "Stacked Bar", - ChartType::PercentBar => "100% Stacked Bar", - ChartType::Line => "Line", - ChartType::LineMarkers => "Line with Markers", - ChartType::StackedLine => "Stacked Line", - ChartType::Area => "Area", - ChartType::StackedArea => "Stacked Area", - ChartType::Pie => "Pie", - ChartType::Doughnut => "Doughnut", - ChartType::Scatter => "Scatter", - ChartType::Bubble => "Bubble", - ChartType::Radar => "Radar", - ChartType::FilledRadar => "Filled Radar", - ChartType::Stock => "Stock", - ChartType::Surface => "Surface", - ChartType::Histogram => "Histogram", - ChartType::Pareto => "Pareto", - ChartType::BoxWhisker => "Box and Whisker", - ChartType::Waterfall => "Waterfall", - ChartType::Funnel => "Funnel", - ChartType::Treemap => "Treemap", - ChartType::Sunburst => "Sunburst", - ChartType::RegionMap => "Map", - ChartType::Combo => "Combo", - } - } - pub fn is_bar_like(self) -> bool { - matches!( - self, - ChartType::Column - | ChartType::StackedColumn - | ChartType::PercentColumn - | ChartType::Bar - | ChartType::StackedBar - | ChartType::PercentBar - | ChartType::Histogram - | ChartType::Pareto - | ChartType::Waterfall - ) - } - pub fn horizontal(self) -> bool { - matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) - } - pub fn stacked(self) -> bool { - matches!( - self, - ChartType::StackedColumn - | ChartType::StackedBar - | ChartType::StackedLine - | ChartType::StackedArea - | ChartType::PercentColumn - | ChartType::PercentBar - ) - } - pub fn percent(self) -> bool { - matches!(self, ChartType::PercentColumn | ChartType::PercentBar) - } -} - -/// `c:trendlineType`: the six trendlines DrawingML charts store. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TrendlineType { - Exp, - #[default] - Linear, - Log, - MovingAvg, - Poly, - Power, -} - -impl TrendlineType { - pub fn xml(self) -> &'static str { - match self { - TrendlineType::Exp => "exp", - TrendlineType::Linear => "linear", - TrendlineType::Log => "log", - TrendlineType::MovingAvg => "movingAvg", - TrendlineType::Poly => "poly", - TrendlineType::Power => "power", - } - } - pub fn from_xml(s: &str) -> Option { - Some(match s { - "exp" => TrendlineType::Exp, - "linear" => TrendlineType::Linear, - "log" => TrendlineType::Log, - "movingAvg" => TrendlineType::MovingAvg, - "poly" => TrendlineType::Poly, - "power" => TrendlineType::Power, - _ => return None, - }) - } -} - -/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Trendline { - pub kind: TrendlineType, - /// `c:name`, when the file sets one. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Polynomial order (`c:order`). - #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, - /// Moving-average period (`c:period`). - #[serde(skip_serializing_if = "Option::is_none")] - pub period: Option, - /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). - #[serde(skip_serializing_if = "Option::is_none")] - pub forward: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub backward: Option, - /// Cross the value axis at this value (`c:intercept`). - #[serde(skip_serializing_if = "Option::is_none")] - pub intercept: Option, - /// Show the equation (`c:dispEq`). - pub disp_eq: bool, - /// Show R² (`c:dispRSqr`). - pub disp_r_sqr: bool, -} - -impl Default for Trendline { - fn default() -> Self { - Trendline { - kind: TrendlineType::Linear, - name: None, - order: None, - period: None, - forward: None, - backward: None, - intercept: None, - disp_eq: false, - disp_r_sqr: false, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Series { - pub name: String, - pub values: Vec>, - /// X values for scatter/bubble. - pub x_values: Vec>, - pub sizes: Vec>, - pub fill: Option, - pub line: Option, - /// Per-point fill overrides (pie slices). - pub point_fills: Vec<(usize, Fill)>, - pub data_labels: Option, - pub smooth: bool, - pub marker: Option, - /// Combo charts: this series' own type. - pub kind: Option, - pub secondary_axis: bool, - /// The number format of the series' data labels (Excel code): the labels' own, else the - /// source data's. - #[serde(skip_serializing_if = "Option::is_none")] - pub number_format: Option, - /// Trendlines (`c:trendline`), in file order. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub trendlines: Vec, - /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). - #[serde(skip_serializing_if = "Option::is_none")] - pub error_bars: Option, -} - -/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ErrorBars { - /// `both`, `plus` or `minus`. - pub bar_type: String, - /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. - pub val_type: String, - pub value: f64, - /// `cust`: the per-point plus and minus amounts. - pub plus: Vec>, - pub minus: Vec>, - pub end_cap: bool, -} - -impl Default for ErrorBars { - fn default() -> Self { - ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } - } -} - -/// Count, mean and sample standard deviation of a series' finite values (for error bars). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SeriesStats { - pub n: f64, - pub mean: f64, - pub sd: f64, -} - -impl SeriesStats { - pub fn of(values: &[Option]) -> Self { - let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); - let n = vals().count() as f64; - let mean = vals().sum::() / n.max(1.0); - let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; - SeriesStats { n, mean, sd } - } -} - -impl ErrorBars { - /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. - /// Standard deviation bars stand around the series' mean, as Excel draws them; the others - /// around the point. `None` for an amount that isn't a finite number. - pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { - self.range_in(&SeriesStats::of(values), i, v) - } - - /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so - /// drawing every point of a long series stays linear. - pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { - let SeriesStats { n, mean, sd } = *stats; - let (centre, minus, plus) = match self.val_type.as_str() { - "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), - "stdDev" => (mean, sd * self.value, sd * self.value), - "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), - "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), - _ => (v, self.value, self.value), - }; - let (lo, hi) = match self.bar_type.as_str() { - "plus" => (centre, centre + plus), - "minus" => (centre - minus, centre), - _ => (centre - minus, centre + plus), - }; - (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Chart { - pub kind: ChartType, - pub title: Option, - pub categories: Vec, - pub series: Vec, - pub legend: Option, - /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. - pub legend_overlay: bool, - /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); - /// `None`: the file gives none. - #[serde(skip_serializing_if = "Option::is_none")] - pub text_size: Option, - /// Title text size in points (its rich text or `c:txPr`). - #[serde(skip_serializing_if = "Option::is_none")] - pub title_size: Option, - pub data_labels: bool, - pub gridlines: bool, - /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. - pub minor_gridlines: bool, - pub axis_titles: (Option, Option), - /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). - pub style: u32, - pub palette: String, - pub plot_fill: Option, - pub chart_fill: Option, - pub text_color: Option, - pub hole_size: f64, - pub gap_width: f64, - pub overlap: f64, - pub vary_colors: bool, - pub y_min: Option, - pub y_max: Option, - /// The value axis' major unit, when the chart fixes it. - pub y_major: Option, - /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). - pub y_log_base: Option, - /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). - pub y_format: Option, - /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. - pub y_hidden: bool, - /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a - /// horizontal bar chart top to bottom (the default puts the first category at the bottom). - pub cat_reversed: bool, - /// A combo chart's secondary value axis: bounds, major unit and number format. - pub y2_min: Option, - pub y2_max: Option, - pub y2_major: Option, - pub y2_format: Option, - pub y2_hidden: bool, - /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. - pub lang: Option, - /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart - /// the running total, instead of floating. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub subtotals: Vec, - /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, - /// then theirs…), each with one label per category. A blank label continues the one above it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub category_levels: Vec>, - /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub inclusive_quartiles: bool, - /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between - /// the first and last series, hollow when the price rose. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub up_down_bars: bool, - /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are - /// automatic (Scott's rule, as Excel does). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_count: Option, - /// A histogram's bin width (`cx:binSize`). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_size: Option, - /// Original chart XML part kept for round-trip of unmodelled options. - #[serde(skip_serializing_if = "Option::is_none")] - pub raw: Option, - /// Package parts the original chart still points at (embedded workbook, styles, drawings). - /// The PPTX writer clears this before comparing modelled state. Native saves keep it too. - #[serde(skip_serializing_if = "ChartKept::is_empty")] - pub kept: ChartKept, -} - -/// One relationship of a chart part, with the target string as it was written. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChartRel { - pub id: String, - pub rel_type: String, - pub target: String, - /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` - /// up to case); empty for external ones. The writer renames kept parts and uses this to - /// point the relationship at the new name. - pub part: String, - pub external: bool, -} - -/// A part reached from a chart (the workbook, a drawing, an image it uses). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ChartPart { - /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). - pub name: String, - pub content_type: String, - /// Runtime bytes; older native files stored these inline in the manifest. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub bytes: Vec, - /// Native ZIP member while encoding/decoding. The native reader consumes this reference. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub native_entry: Option, -} - -/// Sidecar for an unchanged chart: relationships and the parts they reach. -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ChartKept { - pub rels: Vec, - pub parts: Vec, -} - -impl ChartKept { - fn is_empty(&self) -> bool { - self.rels.is_empty() && self.parts.is_empty() - } -} - -impl Default for Chart { - fn default() -> Self { - Chart { - kind: ChartType::Column, - title: Some("Chart Title".into()), - categories: vec![], - series: vec![], - legend: Some("b".into()), - legend_overlay: false, - text_size: None, - title_size: None, - data_labels: false, - gridlines: true, - minor_gridlines: false, - axis_titles: (None, None), - style: 2, - palette: "colorful1".into(), - plot_fill: None, - chart_fill: None, - text_color: None, - hole_size: 0.5, - gap_width: 2.19, - overlap: -0.27, - vary_colors: false, - y_min: None, - y_max: None, - y_major: None, - y_log_base: None, - y_format: None, - y_hidden: false, - cat_reversed: false, - y2_min: None, - y2_max: None, - y2_major: None, - y2_format: None, - y2_hidden: false, - lang: None, - subtotals: vec![], - category_levels: vec![], - inclusive_quartiles: false, - up_down_bars: false, - bin_count: None, - bin_size: None, - raw: None, - kept: ChartKept::default(), - } - } -} - -impl Chart { - fn sample_treemap_sunburst(kind: ChartType) -> Self { - let mut c = Chart { - kind, - categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(), - category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()], - vary_colors: true, - ..Default::default() - }; - let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; - c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - c - } - - fn sample_observation_chart(kind: ChartType) -> Self { - let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; - let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; - let categories = if kind == ChartType::BoxWhisker { - (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() - } else { - vec![] - }; - let mut c = - Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), - ..Default::default() - }); - } - c - } - - fn sample_stock() -> Self { - let mut c = Chart { - kind: ChartType::Stock, - categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(), - ..Default::default() - }; - let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; - for (name, v) in hlc { - c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - } - c - } - - /// The sample data a new chart starts with (our own numbers). - pub fn sample(kind: ChartType) -> Self { - if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { - return Self::sample_treemap_sunburst(kind); - } - if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - return Self::sample_observation_chart(kind); - } - if kind == ChartType::Stock { - return Self::sample_stock(); - } - let cats = ["Q1", "Q2", "Q3", "Q4"]; - let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; - let mut c = Chart { kind, categories: cats.iter().map(ToString::to_string).collect(), ..Default::default() }; - let n_series = - if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: data.iter().map(|row| row.get(s).copied()).collect(), - x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, - ..Default::default() - }); - } - c.vary_colors = n_series == 1; - if kind == ChartType::Combo { - // Columns with the last series as a line, as PowerPoint's combo starts. - for (i, s) in c.series.iter_mut().enumerate() { - s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); - } - } - c - } - /// A series' own chart type: its own in a combo chart (columns when it names none), else the - /// chart's. - pub fn series_kind(&self, s: &Series) -> ChartType { - match (self.kind, s.kind) { - (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, - (ChartType::Combo, _) => ChartType::Column, - (k, _) => k, - } - } - - /// Whether a series is plotted against the secondary value axis (combo charts only). - pub fn on_secondary(&self, s: &Series) -> bool { - self.kind == ChartType::Combo && s.secondary_axis - } - - /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo - /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series - /// uses that axis. - pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { - if self.kind != ChartType::Combo { - return (!secondary).then(|| self.value_range()); - } - let (lo, hi) = self.plotted_extent(secondary)?; - let (lo, hi) = (lo.min(0.0), hi.max(0.0)); - Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) - } - - /// The smallest and largest value plotted against one value axis: plain values as they are, - /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite - /// values; `None` when no series uses the axis. - fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { - if self.kind == ChartType::Waterfall { - return (!secondary).then(|| self.value_range()); - } - let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); - if ser.is_empty() { - return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); - } - let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); - let has_stacked_or_percent = ser.iter().any(|s| { - let k = self.series_kind(s); - k.stacked() || k.percent() - }); - - let mut percent = false; - if has_stacked_or_percent { - let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); - let budget = (ser.len() * n).min(10_000_000); - let n_clamped = if ser.is_empty() { 0 } else { n.min(budget / ser.len()) }; - - for i in 0..n_clamped { - let (mut p, mut m, mut stacked) = (0.0, 0.0, false); - for s in &ser { - let k = self.series_kind(s); - let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; - if k.percent() { - percent = true; - } else if k.stacked() { - stacked = true; - if v >= 0.0 { p += v } else { m += v } - } else { - hi = hi.max(v); - lo = lo.min(v); - } - } - if stacked { - hi = hi.max(p); - lo = lo.min(m); - } - } - } else { - for s in &ser { - for v in s.values.iter().flatten().copied().filter(|v| v.is_finite()) { - hi = hi.max(v); - lo = lo.min(v); - } - } - } - if !percent { - for s in &ser { - if self.series_kind(s).stacked() { - continue; - } - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - if percent { - lo = lo.min(0.0); - hi = hi.max(1.0); - } - Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) - } - - /// A waterfall's bars as (from, to) on the value axis: each point floats from the running - /// total, a total point stands on zero and sets it. - pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { - let Some(s) = self.series.first() else { return vec![] }; - let mut run = 0.0; - s.values - .iter() - .enumerate() - .map(|(i, v)| { - let v = v.unwrap_or(0.0); - if self.subtotals.contains(&i) { - run = v; - (0.0, v) - } else { - let from = run; - run += v; - (from, run) - } - }) - .collect() - } - - /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: - /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). - /// `None` when no series uses the axis. - pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { - let (lo, hi) = self.plotted_extent(secondary)?; - let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; - let full_percent = !secondary && self.kind.percent(); - Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) - } - - /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are - /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint - /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. - pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { - let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; - let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); - let (lo, hi) = vals - .filter(|v| v.is_finite() && *v > 0.0) - .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; - let e = |v: f64| v.log(b); - let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); - let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); - let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); - Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) - } - - /// A value axis label in the axis' number format and the chart's language. - pub fn axis_label(&self, secondary: bool, v: f64) -> String { - let code = if secondary { &self.y2_format } else { &self.y_format }; - // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. - let code = match code.as_deref() { - None | Some("General") if !secondary && self.kind.percent() => "0%", - c => c.unwrap_or("General"), - }; - crate::numfmt::format(code, v, self.lang.as_deref()) - } - - /// A data label of series `s`: its number format, else its axis', in the chart's language. - pub fn data_label(&self, s: &Series, v: f64) -> String { - let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; - let code = s.number_format.as_ref().or(axis.as_ref()); - crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) - } - - /// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an - /// explicit `false` hides them even when the chart-wide setting is on (#868). - pub fn series_data_labels(&self, s: &Series) -> bool { - s.data_labels.unwrap_or(self.data_labels) - } - - fn waterfall_value_range(&self) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; - for (a, b) in self.waterfall_bars() { - lo = lo.min(a.min(b)); - hi = hi.max(a.max(b)); - } - (lo, hi) - } - - fn stacked_value_range(&self, n: usize) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; - for i in 0..n { - let (mut p, mut m) = (0.0, 0.0); - for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); - if v >= 0.0 { p += v } else { m += v } - } - hi = hi.max(p); - lo = lo.min(m); - } - (lo, hi) - } - - /// Value range across series (stacked sums for stacked types). - pub fn value_range(&self) -> (f64, f64) { - if self.kind == ChartType::Waterfall { - return self.waterfall_value_range(); - } - let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); - let (mut lo, mut hi) = if self.kind.stacked() { - self.stacked_value_range(n) - } else { - let mut l: f64 = 0.0; - let mut h: f64 = 0.0; - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - h = h.max(*v); - l = l.min(*v); - } - } - } - (l, h) - }; - if self.kind.percent() { - return (0.0, 1.0); - } - if !self.kind.stacked() { - for s in &self.series { - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) - } -} - -/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` -/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest -/// 1-2-5 unit giving at most `max_intervals` intervals: -/// -/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. -/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise -/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts -/// near them. -/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. -/// Mixed signs get headroom at both ends. -pub fn axis_scale( - lo: f64, - hi: f64, - fmin: Option, - fmax: Option, - major: Option, - max_intervals: usize, - headroom: bool, -) -> (f64, f64, f64) { - // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. - let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); - let (fmin, fmax) = (fin(fmin), fin(fmax)); - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; - // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier - // summarises it): 5 % of the data range past the data, and zero when the values don't - // sit within the top sixth of their largest magnitude. A single value is measured from zero. - let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs()); - let pad = if !headroom { - 0.0 - } else if flat { - hi.abs() / 20.0 - } else { - (hi - lo) / 20.0 - }; - let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; - let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; - let mut lo = fmin.unwrap_or(auto_lo); - let mut hi = fmax.unwrap_or(auto_hi); - let mag = lo.abs().max(hi.abs()); - // Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1. - let unit = if mag == 0.0 { 1.0 } else { mag }; - if hi - lo <= 1e-12 * mag { - // A fixed limit on the far side of the data: the automatic end gives way. - match (fmin, fmax) { - (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) }, - (Some(_), None) if lo < 0.0 => hi = 0.0, - _ => hi = lo + unit, - } - } - let n = max_intervals.clamp(1, 50) as f64; - let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { - Some(m) => m, - None => { - let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); - // Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit. - if !(step > 0.0 && step.is_finite()) { - step = unit; - } - let mut k = 0; - // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. - while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { - step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; - k += 1; - } - step - } - }; - let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); - let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); - (min, max, step) -} - -/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. -pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { - let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; - let data_mag = lo.abs().max(hi.abs()); - let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo }; - let span = if span == 0.0 { 1.0 } else { span }; - let raw = span / 5.0; - let mag = 10f64.powf(raw.log10().floor()); - let norm = raw / mag; - let step = if norm <= 1.0 { - 1.0 - } else if norm <= 2.0 { - 2.0 - } else if norm <= 2.5 { - 2.5 - } else if norm <= 5.0 { - 5.0 - } else { - 10.0 - } * mag; - // Subnormal spans underflow to a zero or NaN step: use the span itself. - let step = if step > 0.0 && step.is_finite() { step } else { span }; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max }; - (min, max, step) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tiny_data_keeps_its_own_scale() { - let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true); - assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}"); - assert!(step > 0.0 && step < 1e-11); - let (min, max, step) = nice_scale(1e-13, 5e-13); - assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}"); - assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0); - } - - #[test] - fn subnormal_data_gives_a_finite_scale() { - let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true); - assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}"); - assert!(step > 0.0 && max >= min, "{min} {max} {step}"); - let (min, max, step) = nice_scale(0.0, f64::from_bits(1)); - assert!(min.is_finite() && max.is_finite() && step > 0.0 && step.is_finite() && max > min, "{min} {max} {step}"); - } - - #[test] - fn log_axes_run_over_whole_powers() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; - assert_eq!(c.log_axis_scale(8), None); - c.y_log_base = Some(10.0); - assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); - assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); - } - - /// #868: a series' own label setting wins; an unset series takes the chart-wide one. - #[test] - fn series_data_labels_override_the_chart_wide_setting() { - let mut c = Chart::sample(ChartType::Column); - c.data_labels = false; - let mut s = c.series[0].clone(); - assert!(!c.series_data_labels(&s), "unset series follows the chart"); - s.data_labels = Some(true); - assert!(c.series_data_labels(&s), "a series that asks for labels gets them"); - c.data_labels = true; - s.data_labels = Some(false); - assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway"); - } - - #[test] - fn error_bar_ranges() { - let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; - let mut e = ErrorBars::default(); - assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); - e.val_type = "percentage".into(); - e.value = 20.0; - assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); - e.bar_type = "plus".into(); - assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); - e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; - assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); - e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; - let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); - assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); - } - - #[test] - fn nice_scale_covers() { - let (a, b, s) = nice_scale(0.0, 5.1); - assert!(a <= 0.0 && b >= 5.1 && s > 0.0); - let (a, b, _) = nice_scale(f64::NAN, 3.0); - assert!(a.is_finite() && b.is_finite()); - let (a, b, _) = nice_scale(7.0, 7.0); - assert!(b > a); - } - - #[test] - fn automatic_axis_scales_like_powerpoint() { - // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. - assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); - // Clicks up to 6 600: 0–7 000 by 1 000. - assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); - // Fewer intervals fit on a short axis. - assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); - // Negative data gets room below; fixed bounds and unit are kept. - let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); - assert!(min <= -30.0 && max >= 105.0); - assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); - // 100 % stacked stays at 0–1 without headroom. - assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); - // Nonsense never loops or divides by zero. - for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { - let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); - assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); - } - } - - fn column(values: &[f64]) -> Chart { - let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; - Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } - } - - /// The primary axis of a chart with room for ten intervals. - fn scale(c: &Chart) -> (f64, f64, f64) { - c.axis_scale(false, 10).unwrap_or_default() - } - - /// Office's documented automatic limits (#144), with room for ten intervals. - #[test] - fn automatic_axis_matches_office() { - // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. - assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); - // PowerPoint's default chart data (max 5): 0–6 in steps of 1. - let mut c = column(&[4.3, 2.5, 3.5, 4.5]); - c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); - c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); - assert_eq!(scale(&c), (0.0, 6.0, 1.0)); - // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. - assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); - assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); - // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. - assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); - } - - #[test] - fn automatic_axis_minimum() { - // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below - // min − (max − min)/20. - assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); - assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); - assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); - // All-negative data mirrors it: the axis ends at zero, or near the data. - assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); - assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); - assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); - // Mixed signs get headroom at both ends. - assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); - // Stacked bars build up from zero, so their axis keeps it. - let mut s = column(&[85.0, 92.0]); - s.kind = ChartType::StackedColumn; - assert_eq!(scale(&s).0, 0.0); - // A combo chart's secondary axis follows the same rule. - let mut c = Chart::sample(ChartType::Combo); - c.series.truncate(2); - c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; - c.series[1].kind = Some(ChartType::Line); - c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; - c.series[1].secondary_axis = true; - assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); - assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); - } - - #[test] - fn fixed_axis_limits_are_kept() { - let mut c = column(&[42.1, 18.7, 6.3]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - c.y_min = Some(10.0); - c.y_max = None; - assert_eq!(scale(&c), (10.0, 45.0, 5.0)); - // A fixed limit beyond the data: the automatic end gives way to it. - let mut c = column(&[85.0, 92.0, 97.0]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - let mut c = column(&[-50.0, -48.0]); - c.y_min = Some(-10.0); - assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); - } - - #[test] - fn percent_axis_is_zero_to_one() { - let mut p = column(&[42.1, 18.7, 6.3]); - p.kind = ChartType::PercentColumn; - assert_eq!(scale(&p), (0.0, 1.0, 0.1)); - assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); - } - - #[test] - fn degenerate_axes_are_finite() { - for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { - let (min, max, step) = scale(&column(&[a, b])); - assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); - } - let (min, max, _) = scale(&column(&[7.0, 7.0])); - assert!(min == 0.0 && max > 7.0); - assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); - for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { - let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); - assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); - } - } - - #[test] - fn general_labels_keep_every_digit_the_data_has() { - // #324: labels were rounded to one decimal ("100000.0" for 99999.99). - for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { - let c = Chart::sample(kind); - let s = &c.series[0]; - for (v, want) in - [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] - { - assert_eq!(c.data_label(s, v), want, "{kind:?}"); - } - } - // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). - let c = Chart::sample(ChartType::Column); - let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); - assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); - } - - #[test] - fn a_forecast_grows_the_value_axis() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; - let before = c.value_range().1; - c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); - let after = c.value_range().1; - assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); - } - - #[test] - fn axis_labels_use_the_format_and_language() { - let mut c = Chart::sample(ChartType::Column); - c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); - c.lang = Some("ru-RU".into()); - assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); - assert_eq!(c.axis_label(false, 0.0), "- ₽"); - // No format: General. - assert_eq!(c.axis_label(true, 2.5), "2,5"); - c.lang = None; - assert_eq!(c.axis_label(true, 2.5), "2.5"); - // Data labels: the series' format, else the axis'. - c.lang = Some("ru-RU".into()); - c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); - assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); - assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); - } - - #[test] - fn full_percent_axes_count_in_percent() { - let mut c = Chart::sample(ChartType::PercentColumn); - assert_eq!(c.axis_label(false, 0.5), "50%"); - c.y_format = Some("0.0%".into()); - assert_eq!(c.axis_label(false, 0.5), "50.0%"); - let c = Chart::sample(ChartType::Column); - assert_eq!(c.axis_label(false, 0.5), "0.5"); - } - - #[test] - fn combo_axis_ranges() { - // Spend in columns on the primary axis, clicks as a line on the secondary one. - let mut c = Chart::sample(ChartType::Combo); - c.series.truncate(2); - c.series[0].kind = Some(ChartType::Column); - c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; - c.series[1].kind = Some(ChartType::Line); - c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; - c.series[1].secondary_axis = true; - assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); - assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); - assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); - // Without a secondary series there is no secondary axis; a non-combo chart never has one. - c.series[1].secondary_axis = false; - assert_eq!(c.axis_range(true), None); - let col = Chart::sample(ChartType::Column); - assert_eq!(col.axis_range(false), Some(col.value_range())); - assert_eq!(col.axis_range(true), None); - assert!(!col.on_secondary(&col.series[0])); - } - - #[test] - fn sample_ranges() { - let c = Chart::sample(ChartType::StackedColumn); - let (_, hi) = c.value_range(); - assert!(hi > 10.0); - assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); - } -} - -/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the -/// left, as Excel bins) and how many observations fall in it. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Bin { - pub lo: f64, - pub hi: f64, - pub count: usize, -} - -/// Bins a histogram has at most (a hostile bin size cannot make millions). -pub const MAX_BINS: usize = 1000; - -/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at -/// the furthest points within 1.5 IQR of the box, and the points beyond them. -#[derive(Clone, Debug, PartialEq)] -pub struct BoxStats { - pub low: f64, - pub q1: f64, - pub median: f64, - pub q3: f64, - pub high: f64, - pub mean: f64, - pub outliers: Vec, -} - -impl Chart { - /// The first series' finite observations. - fn observations(&self) -> Vec { - self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() - } - - /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else - /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. - pub fn histogram_bins(&self) -> Vec { - let mut v = self.observations(); - if v.is_empty() { - return vec![]; - } - v.sort_by(f64::total_cmp); - let (lo, hi) = (v[0], v[v.len() - 1]); - let span = hi - lo; - let n = v.len() as f64; - let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { - w - } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { - span / f64::from(k) - } else { - let mean = v.iter().sum::() / n; - let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); - 3.49 * sd / n.cbrt() - }; - let fixed = self.bin_count.filter(|k| *k > 0).filter(|_| self.bin_size.filter(|w| w.is_finite() && *w > 0.0).is_none()); - let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { - 1 - } else if let Some(k) = fixed { - (k as usize).clamp(1, MAX_BINS) - } else { - ((span / width).ceil() as usize).clamp(1, MAX_BINS) - }; - let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; - let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); - for x in v { - // (lo, hi] bins, the first one also holding the minimum. - let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); - if let Some(b) = bins.get_mut(i) { - b.count += 1; - } - } - bins - } - - /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with - /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the - /// cumulative percentage as a line on a 0–100 % secondary axis. - pub fn histogram_view(&self) -> Chart { - let mut bins = self.histogram_bins(); - let pareto = self.kind == ChartType::Pareto; - if pareto { - bins.sort_by_key(|b| std::cmp::Reverse(b.count)); - } - let fmt = |v: f64| { - let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } - }; - let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); - let mut v = Chart { - kind: if pareto { ChartType::Combo } else { ChartType::Column }, - categories: bins - .iter() - .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) - .collect(), - series: vec![], - gap_width: 0.0, - raw: None, - subtotals: vec![], - ..self.clone() - }; - let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); - let base = self.series.first().cloned().unwrap_or_default(); - v.series.push(Series { - name, - values: bins.iter().map(|b| Some(b.count as f64)).collect(), - kind: pareto.then_some(ChartType::Column), - number_format: None, - ..base - }); - if pareto { - let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; - let mut run = 0.0; - v.series.push(Series { - name: "Cumulative %".into(), - values: bins - .iter() - .map(|b| { - run += b.count as f64; - Some(run / total) - }) - .collect(), - kind: Some(ChartType::Line), - secondary_axis: true, - ..Default::default() - }); - v.y2_min = Some(0.0); - v.y2_max = Some(1.0); - v.y2_major = Some(0.2); - v.y2_format = Some("0%".into()); - } - v.vary_colors = false; - v - } - - /// The box chart's groups (distinct category labels in order of appearance; one unnamed - /// group without categories) and, per series, each group's summary (None when it has no - /// points). - pub fn box_groups(&self) -> (Vec, Vec>>) { - let mut groups: Vec = Vec::new(); - for c in &self.categories { - if !groups.contains(c) { - groups.push(c.clone()); - } - } - if groups.is_empty() { - groups.push(String::new()); - } - let stats = self - .series - .iter() - .map(|s| { - groups - .iter() - .map(|g| { - let pts: Vec = s - .values - .iter() - .enumerate() - .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) - .filter_map(|(_, v)| *v) - .filter(|v| v.is_finite()) - .collect(); - box_stats_by(pts, self.inclusive_quartiles) - }) - .collect() - }) - .collect(); - (groups, stats) - } -} - -/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. -fn quantile_exc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = v[i.saturating_sub(1).min(n - 1)]; - let b = v[i.min(n - 1)]; - a + (b - a) * frac -} - -/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. -fn quantile_inc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); - let i = pos.floor() as usize; - let a = v[i.min(n - 1)]; - let b = v[(i + 1).min(n - 1)]; - a + (b - a) * (pos - i as f64) -} - -/// The summary of one group's points, with exclusive quartiles. -pub fn box_stats(pts: Vec) -> Option { - box_stats_by(pts, false) -} - -/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. -pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { - if pts.is_empty() { - return None; - } - pts.sort_by(f64::total_cmp); - let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; - let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); - let iqr = q3 - q1; - let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); - let low = inside.first().copied().unwrap_or(q1).min(q1); - let high = inside.last().copied().unwrap_or(q3).max(q3); - let mean = pts.iter().sum::() / pts.len() as f64; - let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); - Some(BoxStats { low, q1, median, q3, high, mean, outliers }) -} - -/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and -/// its children (none for a leaf, which names its category index). -#[derive(Clone, Debug, PartialEq)] -pub struct TreeNode { - pub name: String, - pub value: f64, - pub leaf: Option, - pub children: Vec, -} - -/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). -pub const MAX_LEVELS: usize = 16; - -impl Chart { - /// The first series as a hierarchy: top-level nodes, each holding the consecutive - /// categories that share its label, down to the leaves. Without outer levels every - /// category is a top-level leaf. Negative and missing values count as zero. - pub fn category_tree(&self) -> Vec { - let Some(s) = self.series.first() else { return vec![] }; - let n = self.categories.len().max(s.values.len()); - let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); - // Each leaf's path from the top level down, blanks filled from the row above. - let mut last: Vec = vec![String::new(); levels.len()]; - let paths: Vec> = (0..n) - .map(|i| { - let mut path = Vec::with_capacity(levels.len() + 1); - for (d, lv) in levels.iter().enumerate().rev() { - let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); - if !l.is_empty() { - last[d] = l.to_string(); - // A new outer label starts fresh inner groups. - for inner in last.iter_mut().take(d) { - inner.clear(); - } - } - path.push(last[d].clone()); - } - path.push(self.categories.get(i).cloned().unwrap_or_default()); - path - }) - .collect(); - let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); - fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { - let mut out: Vec = Vec::new(); - let mut i = 0; - while i < rows.len() { - let (idx, path) = rows[i]; - if path.len() <= 1 { - out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); - i += 1; - continue; - } - let head = &path[0]; - let mut j = i; - while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { - j += 1; - } - let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); - let children = build(&sub, value); - out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); - i = j; - } - out - } - let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); - build(&rows, &value) - } -} - -#[cfg(test)] -mod chartex_tests { - use super::*; - - fn obs(v: &[f64]) -> Chart { - Chart { - kind: ChartType::Histogram, - series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], - ..Default::default() - } - } - - #[test] - fn histogram_bins_by_count_size_and_scott() { - let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); - c.bin_count = Some(4); - let b = c.histogram_bins(); - assert_eq!(b.len(), 4); - assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); - // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. - assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); - c.bin_count = None; - c.bin_size = Some(5.0); - assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); - c.bin_size = None; - let auto = c.histogram_bins(); - assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); - // A tiny bin size is capped; one value gives one bin. - c.bin_size = Some(1e-12); - assert!(c.histogram_bins().len() <= MAX_BINS); - assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); - assert!(obs(&[]).histogram_bins().is_empty()); - } - - #[test] - fn histogram_fixed_bin_count_is_exact_despite_float_rounding() { - let mut c = obs(&[0.0, 1.0, 2.1]); - c.bin_count = Some(7); - assert_eq!(c.histogram_bins().len(), 7); - } - - #[test] - fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { - let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); - c.kind = ChartType::Pareto; - c.bin_count = Some(3); - let v = c.histogram_view(); - assert_eq!(v.kind, ChartType::Combo); - assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); - let cum = &v.series[1]; - assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); - assert_eq!(cum.values.last(), Some(&Some(1.0))); - assert_eq!(v.categories[0], "(3.67, 6.33]"); - } - - #[test] - fn box_stats_use_exclusive_quartiles_and_fence_outliers() { - let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); - // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. - assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); - assert_eq!(b.outliers, vec![100.0]); - assert_eq!((b.low, b.high), (1.0, 7.0)); - assert!(box_stats(vec![]).is_none()); - let one = box_stats(vec![5.0]).unwrap(); - assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); - } - - #[test] - fn inclusive_quartiles_follow_quartile_inc() { - let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); - // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). - assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); - } - - #[test] - fn category_tree_groups_runs_and_fills_blank_parents() { - let mut c = Chart::sample(ChartType::Treemap); - let t = c.category_tree(); - assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); - assert_eq!(t[0].children.len(), 3); - assert_eq!(t[0].children[1].leaf, Some(1)); - assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); - // Blank parents continue the label above; two outer levels nest. - c.category_levels = vec![ - vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], - vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], - ]; - let t = c.category_tree(); - assert_eq!(t.len(), 2); - assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); - assert_eq!(t[1].children[0].children.len(), 4); - // No levels: every category is a leaf. - c.category_levels.clear(); - assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); - } - - #[test] - fn box_groups_follow_category_labels() { - let c = Chart::sample(ChartType::BoxWhisker); - let (groups, stats) = c.box_groups(); - assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); - assert_eq!(stats.len(), 2); - assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); - } -} +//! Charts: an embedded data grid plus type and element options. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + #[default] + Column, + StackedColumn, + PercentColumn, + Bar, + StackedBar, + PercentBar, + Line, + LineMarkers, + StackedLine, + Area, + StackedArea, + Pie, + Doughnut, + Scatter, + Bubble, + Radar, + FilledRadar, + Stock, + Surface, + Histogram, + Pareto, + BoxWhisker, + Waterfall, + Funnel, + Treemap, + Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, + Combo, +} + +impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap + } + + pub const MAIN: [ChartType; 12] = [ + ChartType::Column, + ChartType::Bar, + ChartType::Line, + ChartType::Area, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Radar, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Treemap, + ]; + pub fn label(self) -> &'static str { + match self { + ChartType::Column => "Clustered Column", + ChartType::StackedColumn => "Stacked Column", + ChartType::PercentColumn => "100% Stacked Column", + ChartType::Bar => "Clustered Bar", + ChartType::StackedBar => "Stacked Bar", + ChartType::PercentBar => "100% Stacked Bar", + ChartType::Line => "Line", + ChartType::LineMarkers => "Line with Markers", + ChartType::StackedLine => "Stacked Line", + ChartType::Area => "Area", + ChartType::StackedArea => "Stacked Area", + ChartType::Pie => "Pie", + ChartType::Doughnut => "Doughnut", + ChartType::Scatter => "Scatter", + ChartType::Bubble => "Bubble", + ChartType::Radar => "Radar", + ChartType::FilledRadar => "Filled Radar", + ChartType::Stock => "Stock", + ChartType::Surface => "Surface", + ChartType::Histogram => "Histogram", + ChartType::Pareto => "Pareto", + ChartType::BoxWhisker => "Box and Whisker", + ChartType::Waterfall => "Waterfall", + ChartType::Funnel => "Funnel", + ChartType::Treemap => "Treemap", + ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", + ChartType::Combo => "Combo", + } + } + pub fn is_bar_like(self) -> bool { + matches!( + self, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Histogram + | ChartType::Pareto + | ChartType::Waterfall + ) + } + pub fn horizontal(self) -> bool { + matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) + } + pub fn stacked(self) -> bool { + matches!( + self, + ChartType::StackedColumn + | ChartType::StackedBar + | ChartType::StackedLine + | ChartType::StackedArea + | ChartType::PercentColumn + | ChartType::PercentBar + ) + } + pub fn percent(self) -> bool { + matches!(self, ChartType::PercentColumn | ChartType::PercentBar) + } +} + +/// `c:trendlineType`: the six trendlines DrawingML charts store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TrendlineType { + Exp, + #[default] + Linear, + Log, + MovingAvg, + Poly, + Power, +} + +impl TrendlineType { + pub fn xml(self) -> &'static str { + match self { + TrendlineType::Exp => "exp", + TrendlineType::Linear => "linear", + TrendlineType::Log => "log", + TrendlineType::MovingAvg => "movingAvg", + TrendlineType::Poly => "poly", + TrendlineType::Power => "power", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "exp" => TrendlineType::Exp, + "linear" => TrendlineType::Linear, + "log" => TrendlineType::Log, + "movingAvg" => TrendlineType::MovingAvg, + "poly" => TrendlineType::Poly, + "power" => TrendlineType::Power, + _ => return None, + }) + } +} + +/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Trendline { + pub kind: TrendlineType, + /// `c:name`, when the file sets one. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Polynomial order (`c:order`). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Moving-average period (`c:period`). + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). + #[serde(skip_serializing_if = "Option::is_none")] + pub forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backward: Option, + /// Cross the value axis at this value (`c:intercept`). + #[serde(skip_serializing_if = "Option::is_none")] + pub intercept: Option, + /// Show the equation (`c:dispEq`). + pub disp_eq: bool, + /// Show R² (`c:dispRSqr`). + pub disp_r_sqr: bool, +} + +impl Default for Trendline { + fn default() -> Self { + Trendline { + kind: TrendlineType::Linear, + name: None, + order: None, + period: None, + forward: None, + backward: None, + intercept: None, + disp_eq: false, + disp_r_sqr: false, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Series { + pub name: String, + pub values: Vec>, + /// X values for scatter/bubble. + pub x_values: Vec>, + pub sizes: Vec>, + pub fill: Option, + pub line: Option, + /// Per-point fill overrides (pie slices). + pub point_fills: Vec<(usize, Fill)>, + pub data_labels: Option, + pub smooth: bool, + pub marker: Option, + /// Combo charts: this series' own type. + pub kind: Option, + pub secondary_axis: bool, + /// The number format of the series' data labels (Excel code): the labels' own, else the + /// source data's. + #[serde(skip_serializing_if = "Option::is_none")] + pub number_format: Option, + /// Trendlines (`c:trendline`), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Chart { + pub kind: ChartType, + pub title: Option, + pub categories: Vec, + pub series: Vec, + pub legend: Option, + /// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it. + pub legend_overlay: bool, + /// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's); + /// `None`: the file gives none. + #[serde(skip_serializing_if = "Option::is_none")] + pub text_size: Option, + /// Title text size in points (its rich text or `c:txPr`). + #[serde(skip_serializing_if = "Option::is_none")] + pub title_size: Option, + pub data_labels: bool, + pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, + pub axis_titles: (Option, Option), + /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). + pub style: u32, + pub palette: String, + pub plot_fill: Option, + pub chart_fill: Option, + pub text_color: Option, + pub hole_size: f64, + pub gap_width: f64, + pub overlap: f64, + pub vary_colors: bool, + pub y_min: Option, + pub y_max: Option, + /// The value axis' major unit, when the chart fixes it. + pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, + /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). + pub y_format: Option, + /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. + pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, + /// A combo chart's secondary value axis: bounds, major unit and number format. + pub y2_min: Option, + pub y2_max: Option, + pub y2_major: Option, + pub y2_format: Option, + pub y2_hidden: bool, + /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. + pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, + /// Original chart XML part kept for round-trip of unmodelled options. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + /// Package parts the original chart still points at (embedded workbook, styles, drawings). + /// The PPTX writer clears this before comparing modelled state. Native saves keep it too. + #[serde(skip_serializing_if = "ChartKept::is_empty")] + pub kept: ChartKept, +} + +/// One relationship of a chart part, with the target string as it was written. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartRel { + pub id: String, + pub rel_type: String, + pub target: String, + /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` + /// up to case); empty for external ones. The writer renames kept parts and uses this to + /// point the relationship at the new name. + pub part: String, + pub external: bool, +} + +/// A part reached from a chart (the workbook, a drawing, an image it uses). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartPart { + /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). + pub name: String, + pub content_type: String, + /// Runtime bytes; older native files stored these inline in the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bytes: Vec, + /// Native ZIP member while encoding/decoding. The native reader consumes this reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_entry: Option, +} + +/// Sidecar for an unchanged chart: relationships and the parts they reach. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ChartKept { + pub rels: Vec, + pub parts: Vec, +} + +impl ChartKept { + fn is_empty(&self) -> bool { + self.rels.is_empty() && self.parts.is_empty() + } +} + +impl Default for Chart { + fn default() -> Self { + Chart { + kind: ChartType::Column, + title: Some("Chart Title".into()), + categories: vec![], + series: vec![], + legend: Some("b".into()), + legend_overlay: false, + text_size: None, + title_size: None, + data_labels: false, + gridlines: true, + minor_gridlines: false, + axis_titles: (None, None), + style: 2, + palette: "colorful1".into(), + plot_fill: None, + chart_fill: None, + text_color: None, + hole_size: 0.5, + gap_width: 2.19, + overlap: -0.27, + vary_colors: false, + y_min: None, + y_max: None, + y_major: None, + y_log_base: None, + y_format: None, + y_hidden: false, + cat_reversed: false, + y2_min: None, + y2_max: None, + y2_major: None, + y2_format: None, + y2_hidden: false, + lang: None, + subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, + up_down_bars: false, + bin_count: None, + bin_size: None, + raw: None, + kept: ChartKept::default(), + } + } +} + +impl Chart { + fn sample_treemap_sunburst(kind: ChartType) -> Self { + let mut c = Chart { + kind, + categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(), + category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()], + vary_colors: true, + ..Default::default() + }; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c + } + + fn sample_observation_chart(kind: ChartType) -> Self { + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + let categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + let mut c = + Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c + } + + fn sample_stock() -> Self { + let mut c = Chart { + kind: ChartType::Stock, + categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(), + ..Default::default() + }; + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + c + } + + /// The sample data a new chart starts with (our own numbers). + pub fn sample(kind: ChartType) -> Self { + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + return Self::sample_treemap_sunburst(kind); + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + return Self::sample_observation_chart(kind); + } + if kind == ChartType::Stock { + return Self::sample_stock(); + } + let cats = ["Q1", "Q2", "Q3", "Q4"]; + let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; + let mut c = Chart { kind, categories: cats.iter().map(ToString::to_string).collect(), ..Default::default() }; + let n_series = + if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: data.iter().map(|row| row.get(s).copied()).collect(), + x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, + ..Default::default() + }); + } + c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } + c + } + /// A series' own chart type: its own in a combo chart (columns when it names none), else the + /// chart's. + pub fn series_kind(&self, s: &Series) -> ChartType { + match (self.kind, s.kind) { + (ChartType::Combo, Some(k)) if k != ChartType::Combo => k, + (ChartType::Combo, _) => ChartType::Column, + (k, _) => k, + } + } + + /// Whether a series is plotted against the secondary value axis (combo charts only). + pub fn on_secondary(&self, s: &Series) -> bool { + self.kind == ChartType::Combo && s.secondary_axis + } + + /// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo + /// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series + /// uses that axis. + pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind != ChartType::Combo { + return (!secondary).then(|| self.value_range()); + } + let (lo, hi) = self.plotted_extent(secondary)?; + let (lo, hi) = (lo.min(0.0), hi.max(0.0)); + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + + /// The smallest and largest value plotted against one value axis: plain values as they are, + /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite + /// values; `None` when no series uses the axis. + fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } + let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); + if ser.is_empty() { + return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + let has_stacked_or_percent = ser.iter().any(|s| { + let k = self.series_kind(s); + k.stacked() || k.percent() + }); + + let mut percent = false; + if has_stacked_or_percent { + let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); + let budget = (ser.len() * n).min(10_000_000); + let n_clamped = if ser.is_empty() { 0 } else { n.min(budget / ser.len()) }; + + for i in 0..n_clamped { + let (mut p, mut m, mut stacked) = (0.0, 0.0, false); + for s in &ser { + let k = self.series_kind(s); + let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + if k.percent() { + percent = true; + } else if k.stacked() { + stacked = true; + if v >= 0.0 { p += v } else { m += v } + } else { + hi = hi.max(v); + lo = lo.min(v); + } + } + if stacked { + hi = hi.max(p); + lo = lo.min(m); + } + } + } else { + for s in &ser { + for v in s.values.iter().flatten().copied().filter(|v| v.is_finite()) { + hi = hi.max(v); + lo = lo.min(v); + } + } + } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + if percent { + lo = lo.min(0.0); + hi = hi.max(1.0); + } + Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) + } + + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + + /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: + /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). + /// `None` when no series uses the axis. + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { + let (lo, hi) = self.plotted_extent(secondary)?; + let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; + let full_percent = !secondary && self.kind.percent(); + Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) + } + + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + + /// A value axis label in the axis' number format and the chart's language. + pub fn axis_label(&self, secondary: bool, v: f64) -> String { + let code = if secondary { &self.y2_format } else { &self.y_format }; + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, v, self.lang.as_deref()) + } + + /// A data label of series `s`: its number format, else its axis', in the chart's language. + pub fn data_label(&self, s: &Series, v: f64) -> String { + let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format }; + let code = s.number_format.as_ref().or(axis.as_ref()); + crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref()) + } + + /// Does series `s` show its data labels? The series' own `c:dLbls/c:showVal` wins — an + /// explicit `false` hides them even when the chart-wide setting is on (#868). + pub fn series_data_labels(&self, s: &Series) -> bool { + s.data_labels.unwrap_or(self.data_labels) + } + + fn waterfall_value_range(&self) -> (f64, f64) { + let mut lo: f64 = 0.0; + let mut hi: f64 = 0.0; + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + (lo, hi) + } + + fn stacked_value_range(&self, n: usize) -> (f64, f64) { + let mut lo: f64 = 0.0; + let mut hi: f64 = 0.0; + for i in 0..n { + let (mut p, mut m) = (0.0, 0.0); + for s in &self.series { + let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + if v >= 0.0 { p += v } else { m += v } + } + hi = hi.max(p); + lo = lo.min(m); + } + (lo, hi) + } + + /// Value range across series (stacked sums for stacked types). + pub fn value_range(&self) -> (f64, f64) { + if self.kind == ChartType::Waterfall { + return self.waterfall_value_range(); + } + let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + let (mut lo, mut hi) = if self.kind.stacked() { + self.stacked_value_range(n) + } else { + let mut l: f64 = 0.0; + let mut h: f64 = 0.0; + for s in &self.series { + for v in s.values.iter().flatten() { + if v.is_finite() { + h = h.max(*v); + l = l.min(*v); + } + } + } + (l, h) + }; + if self.kind.percent() { + return (0.0, 1.0); + } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + } +} + +/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` +/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest +/// 1-2-5 unit giving at most `max_intervals` intervals: +/// +/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. +/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise +/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts +/// near them. +/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. +/// Mixed signs get headroom at both ends. +pub fn axis_scale( + lo: f64, + hi: f64, + fmin: Option, + fmax: Option, + major: Option, + max_intervals: usize, + headroom: bool, +) -> (f64, f64, f64) { + // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. + let fin = |v: Option| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100)); + let (fmin, fmax) = (fin(fmin), fin(fmax)); + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) }; + // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier + // summarises it): 5 % of the data range past the data, and zero when the values don't + // sit within the top sixth of their largest magnitude. A single value is measured from zero. + let flat = hi - lo <= 1e-12 * lo.abs().max(hi.abs()); + let pad = if !headroom { + 0.0 + } else if flat { + hi.abs() / 20.0 + } else { + (hi - lo) / 20.0 + }; + let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; + let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; + let mut lo = fmin.unwrap_or(auto_lo); + let mut hi = fmax.unwrap_or(auto_hi); + let mag = lo.abs().max(hi.abs()); + // Relative to the data's magnitude, so tiny values don't count as flat; all-zero falls back to 1. + let unit = if mag == 0.0 { 1.0 } else { mag }; + if hi - lo <= 1e-12 * mag { + // A fixed limit on the far side of the data: the automatic end gives way. + match (fmin, fmax) { + (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(unit) }, + (Some(_), None) if lo < 0.0 => hi = 0.0, + _ => hi = lo + unit, + } + } + let n = max_intervals.clamp(1, 50) as f64; + let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) { + Some(m) => m, + None => { + let mut step = 10f64.powf(((hi - lo) / n).log10().floor()); + // Subnormal spans underflow to a zero (or non-finite) step: fall back to the unit. + if !(step > 0.0 && step.is_finite()) { + step = unit; + } + let mut k = 0; + // 1, 2, 5, 10, 20… × the magnitude until the intervals fit. + while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 { + step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 }; + k += 1; + } + step + } + }; + let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step); + let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step); + (min, max, step) +} + +/// "Nice" axis scale: (min, max, major step) covering [lo, hi]. +pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { + let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) }; + let data_mag = lo.abs().max(hi.abs()); + let span = if hi - lo <= 1e-12 * data_mag { data_mag } else { hi - lo }; + let span = if span == 0.0 { 1.0 } else { span }; + let raw = span / 5.0; + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let step = if norm <= 1.0 { + 1.0 + } else if norm <= 2.0 { + 2.0 + } else if norm <= 2.5 { + 2.5 + } else if norm <= 5.0 { + 5.0 + } else { + 10.0 + } * mag; + // Subnormal spans underflow to a zero or NaN step: use the span itself. + let step = if step > 0.0 && step.is_finite() { step } else { span }; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() <= 1e-12 * data_mag { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_data_keeps_its_own_scale() { + let (min, max, step) = axis_scale(1e-13, 5e-13, None, None, None, 8, true); + assert!(min == 0.0 && (5e-13..1e-11).contains(&max), "{min} {max} {step}"); + assert!(step > 0.0 && step < 1e-11); + let (min, max, step) = nice_scale(1e-13, 5e-13); + assert!(min <= 1e-13 && (5e-13..1e-11).contains(&max) && step < 1e-11, "{min} {max} {step}"); + assert_eq!(axis_scale(0.0, 0.0, None, None, None, 8, true).1, 1.0); + } + + #[test] + fn subnormal_data_gives_a_finite_scale() { + let (min, max, step) = axis_scale(0.0, 5e-324, None, None, None, 8, true); + assert!(min.is_finite() && max.is_finite() && step.is_finite(), "{min} {max} {step}"); + assert!(step > 0.0 && max >= min, "{min} {max} {step}"); + let (min, max, step) = nice_scale(0.0, f64::from_bits(1)); + assert!(min.is_finite() && max.is_finite() && step > 0.0 && step.is_finite() && max > min, "{min} {max} {step}"); + } + + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + + /// #868: a series' own label setting wins; an unset series takes the chart-wide one. + #[test] + fn series_data_labels_override_the_chart_wide_setting() { + let mut c = Chart::sample(ChartType::Column); + c.data_labels = false; + let mut s = c.series[0].clone(); + assert!(!c.series_data_labels(&s), "unset series follows the chart"); + s.data_labels = Some(true); + assert!(c.series_data_labels(&s), "a series that asks for labels gets them"); + c.data_labels = true; + s.data_labels = Some(false); + assert!(!c.series_data_labels(&s), "an explicit series false hides them anyway"); + } + + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + + #[test] + fn nice_scale_covers() { + let (a, b, s) = nice_scale(0.0, 5.1); + assert!(a <= 0.0 && b >= 5.1 && s > 0.0); + let (a, b, _) = nice_scale(f64::NAN, 3.0); + assert!(a.is_finite() && b.is_finite()); + let (a, b, _) = nice_scale(7.0, 7.0); + assert!(b > a); + } + + #[test] + fn automatic_axis_scales_like_powerpoint() { + // Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000. + assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0)); + // Clicks up to 6 600: 0–7 000 by 1 000. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0)); + // Fewer intervals fit on a short axis. + assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0)); + // Negative data gets room below; fixed bounds and unit are kept. + let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true); + assert!(min <= -30.0 && max >= 105.0); + assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0)); + // 100 % stacked stays at 0–1 without headroom. + assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2)); + // Nonsense never loops or divides by zero. + for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] { + let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true); + assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// The primary axis of a chart with room for ten intervals. + fn scale(c: &Chart) -> (f64, f64, f64) { + c.axis_scale(false, 10).unwrap_or_default() + } + + /// Office's documented automatic limits (#144), with room for ten intervals. + #[test] + fn automatic_axis_matches_office() { + // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. + assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(scale(&c), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. + assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); + assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); + // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. + assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below + // min − (max − min)/20. + assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); + assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); + assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); + // All-negative data mirrors it: the axis ends at zero, or near the data. + assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); + assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); + assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); + // Mixed signs get headroom at both ends. + assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); + // Stacked bars build up from zero, so their axis keeps it. + let mut s = column(&[85.0, 92.0]); + s.kind = ChartType::StackedColumn; + assert_eq!(scale(&s).0, 0.0); + // A combo chart's secondary axis follows the same rule. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(7_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); + assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(scale(&c), (10.0, 45.0, 5.0)); + // A fixed limit beyond the data: the automatic end gives way to it. + let mut c = column(&[85.0, 92.0, 97.0]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + let mut c = column(&[-50.0, -48.0]); + c.y_min = Some(-10.0); + assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); + } + + #[test] + fn percent_axis_is_zero_to_one() { + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(scale(&p), (0.0, 1.0, 0.1)); + assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); + } + + #[test] + fn degenerate_axes_are_finite() { + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { + let (min, max, step) = scale(&column(&[a, b])); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } + let (min, max, _) = scale(&column(&[7.0, 7.0])); + assert!(min == 0.0 && max > 7.0); + assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); + for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { + let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); + } + } + + #[test] + fn general_labels_keep_every_digit_the_data_has() { + // #324: labels were rounded to one decimal ("100000.0" for 99999.99). + for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { + let c = Chart::sample(kind); + let s = &c.series[0]; + for (v, want) in + [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] + { + assert_eq!(c.data_label(s, v), want, "{kind:?}"); + } + } + // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). + let c = Chart::sample(ChartType::Column); + let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); + assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); + } + + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + + #[test] + fn axis_labels_use_the_format_and_language() { + let mut c = Chart::sample(ChartType::Column); + c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into()); + c.lang = Some("ru-RU".into()); + assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽"); + assert_eq!(c.axis_label(false, 0.0), "- ₽"); + // No format: General. + assert_eq!(c.axis_label(true, 2.5), "2,5"); + c.lang = None; + assert_eq!(c.axis_label(true, 2.5), "2.5"); + // Data labels: the series' format, else the axis'. + c.lang = Some("ru-RU".into()); + c.series[0].number_format = Some(r#"#,##0\ "₽""#.into()); + assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽"); + assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽"); + } + + #[test] + fn full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + + #[test] + fn combo_axis_ranges() { + // Spend in columns on the primary axis, clicks as a line on the secondary one. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + c.series[0].kind = Some(ChartType::Column); + c.series[0].values = vec![Some(200_000.0), Some(310_000.0)]; + c.series[1].kind = Some(ChartType::Line); + c.series[1].values = vec![Some(6_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_range(false), Some((0.0, 310_000.0))); + assert_eq!(c.axis_range(true), Some((0.0, 7_400.0))); + assert_eq!(c.series_kind(&c.series[1]), ChartType::Line); + // Without a secondary series there is no secondary axis; a non-combo chart never has one. + c.series[1].secondary_axis = false; + assert_eq!(c.axis_range(true), None); + let col = Chart::sample(ChartType::Column); + assert_eq!(col.axis_range(false), Some(col.value_range())); + assert_eq!(col.axis_range(true), None); + assert!(!col.on_secondary(&col.series[0])); + } + + #[test] + fn sample_ranges() { + let c = Chart::sample(ChartType::StackedColumn); + let (_, hi) = c.value_range(); + assert!(hi > 10.0); + assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); + } +} + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let fixed = self.bin_count.filter(|k| *k > 0).filter(|_| self.bin_size.filter(|w| w.is_finite() && *w > 0.0).is_none()); + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { + 1 + } else if let Some(k) = fixed { + (k as usize).clamp(1, MAX_BINS) + } else { + ((span / width).ceil() as usize).clamp(1, MAX_BINS) + }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats_by(pts, self.inclusive_quartiles) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn histogram_fixed_bin_count_is_exact_despite_float_rounding() { + let mut c = obs(&[0.0, 1.0, 2.1]); + c.bin_count = Some(7); + assert_eq!(c.histogram_bins().len(), 7); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f08fa6c0..e111f074 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,15 +18,16 @@ deckcraft-render = { workspace = true } deckcraft-anim = { workspace = true } deckcraft-engine = { workspace = true } deckcraft-media = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -js-sys = "0.3" +web-time = { workspace = true } [dev-dependencies] +skrifa = { workspace = true } +# kittest's own dev-dependencies do not propagate; tests need both font features explicitly. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } [build-dependencies] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 3aafc1ac..fd412028 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -153,7 +153,7 @@ pub enum ExportTask { pub struct Backstage { pub page: Page, /// The open presentations (count, active) when it opened: a change closes it. - pub docs: (usize, Option), + pub docs: (usize, Option), /// Keyboard focus in the side menu (an index into [`entries`], top then bottom). pub focus: Option, /// Search text on Home, New and Open. @@ -180,7 +180,9 @@ pub struct Backstage { } impl Backstage { - pub fn new(page: Page, docs: (usize, Option)) -> Backstage { + /// Construct the Backstage view at a page with the current document identity, so a replaced deck + /// can be detected. + pub fn new(page: Page, docs: (usize, Option)) -> Backstage { Backstage { page, docs, @@ -337,7 +339,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; if has_doc { let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Back")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back")); let hovered = resp.hovered(); if hovered { ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover); @@ -437,11 +439,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { // ---------------------------------------------------------------- side menu +/// Draw one clickable Backstage navigation row and return its interaction response. fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click()); let selected = nav == Nav::Page(bs.page); let label = nav.label(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); let row = rect.shrink2(vec2(6.0, 1.0)); if resp.hovered() { @@ -495,7 +498,7 @@ fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: b let t = Tokens::get(ui.ctx()); let thumb_w = if big { 200.0 } else { 160.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.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 p = ui.painter(); if resp.hovered() { p.rect_filled(rect, CornerRadius::same(4), t.hover); @@ -599,7 +602,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { let missing = !app.recent_exists(&path); let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); let p = ui.painter(); if resp.hovered() { p.rect_filled(rect, CornerRadius::same(3), t.hover); @@ -625,7 +628,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), Sense::click(), ); - x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent")); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); if x.hovered() { ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); } @@ -643,7 +646,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let p = ui.painter(); p.rect_filled(rect, CornerRadius::same(3), if resp.hovered() && enabled { t.hover } else { t.card }); p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.0, if resp.hovered() && enabled { t.accent } else { t.border }), StrokeKind::Inside); @@ -658,7 +661,7 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::R fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); if selected { p.rect_filled(rect, CornerRadius::same(2), t.selected); @@ -936,13 +939,18 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom ui.add_space(14.0); let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("backstage_save_type").width(340.0).selected_text(current).show_ui(ui, |ui| { - for (id, label, _) in SAVE_TYPES { - if ui.selectable_label(bs.file_type == id, label).clicked() { - bs.file_type = id; + egui::ComboBox::from_id_salt("backstage_save_type") + .width(340.0) + .selected_text(current) + .show_ui(ui, |ui| { + for (id, label, _) in SAVE_TYPES { + if ui.selectable_label(bs.file_type == id, label).clicked() { + bs.file_type = id; + } } - } - }); + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); let web = app.services.pick_save.is_none(); let label = if web { "Download" } else { "Save" }; let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); @@ -1057,7 +1065,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { PrintScope::Current => "Print Current Slide", PrintScope::Custom => "Custom Range", }; - egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { for (s, label) in [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] { @@ -1067,6 +1075,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); ui.add_space(4.0); ui.horizontal(|ui| { let l = ui.label("Slides:"); @@ -1079,7 +1088,7 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { }); ui.add_space(4.0); let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); - egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { for (id, label) in LAYOUTS { if ui.selectable_label(bs.print_layout == id, label).clicked() { bs.print_layout = id; @@ -1087,27 +1096,38 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print layout")); ui.add_space(4.0); // PowerPoint prints full-page slides on slide-shaped pages; paper and orientation are // for notes pages and handouts. ui.add_enabled_ui(bs.print_layout != "slides", |ui| { let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); - egui::ComboBox::from_id_salt("backstage_print_paper").width(240.0).selected_text(paper).show_ui(ui, |ui| { - for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { - if ui.selectable_label(bs.print_paper == id, label).clicked() { - bs.print_paper = id; + egui::ComboBox::from_id_salt("backstage_print_paper") + .width(240.0) + .selected_text(paper) + .show_ui(ui, |ui| { + for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { + if ui.selectable_label(bs.print_paper == id, label).clicked() { + bs.print_paper = id; + } } - } - }); + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); ui.add_space(4.0); let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; - egui::ComboBox::from_id_salt("backstage_print_orientation").width(240.0).selected_text(orient).show_ui(ui, |ui| { - for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { - if ui.selectable_label(bs.print_landscape == land, label).clicked() { - bs.print_landscape = land; + egui::ComboBox::from_id_salt("backstage_print_orientation") + .width(240.0) + .selected_text(orient) + .show_ui(ui, |ui| { + for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + if ui.selectable_label(bs.print_landscape == land, label).clicked() { + bs.print_landscape = land; + } } - } - }); + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); }); ui.add_space(4.0); if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { @@ -1230,10 +1250,11 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ }); } +/// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); if resp.hovered() && enabled { ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); } @@ -1280,7 +1301,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ExportTask::Handouts => { ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); - egui::ComboBox::from_id_salt("backstage_handouts") + let combo = egui::ComboBox::from_id_salt("backstage_handouts") .width(240.0) .selected_text(format!("{} slides per page", bs.handouts_per_page)) .show_ui(ui, |ui| { @@ -1290,6 +1311,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Slides per page")); ui.add_space(10.0); if square_button(ui, Icon::Notes, "Create Handouts", true).clicked() { app.export_as("pdf", json!({"layout": "handouts", "perPage": bs.handouts_per_page})); @@ -1301,7 +1323,7 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { let selected = bs.file_type == *id; let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, *label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); let p = ui.painter(); if selected { p.rect_filled(rect, CornerRadius::same(2), t.selected); @@ -1351,13 +1373,18 @@ fn account(app: &mut SlideApp, ui: &mut Ui) { ui.set_width(340.0); heading(ui, "Appearance"); let dark = app.ui.brightness == theme::Brightness::Dark; - egui::ComboBox::from_id_salt("backstage_appearance").width(260.0).selected_text(if dark { "Dark" } else { "Light" }).show_ui(ui, |ui| { - for (on, label) in [(false, "Light"), (true, "Dark")] { - if ui.selectable_label(dark == on, label).clicked() { - let _ = app.run("view.dark", json!({"on": on})); + egui::ComboBox::from_id_salt("backstage_appearance") + .width(260.0) + .selected_text(if dark { "Dark" } else { "Light" }) + .show_ui(ui, |ui| { + for (on, label) in [(false, "Light"), (true, "Dark")] { + if ui.selectable_label(dark == on, label).clicked() { + let _ = app.run("view.dark", json!({"on": on})); + } } - } - }); + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); ui.add_space(16.0); heading(ui, "User Information"); dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); diff --git a/crates/ui-egui/src/backstage_tests.rs b/crates/ui-egui/src/backstage_tests.rs index 7b86b1a4..ec133129 100644 --- a/crates/ui-egui/src/backstage_tests.rs +++ b/crates/ui-egui/src/backstage_tests.rs @@ -456,6 +456,101 @@ fn the_start_screen_is_file_home() { assert!(h.state().backstage.is_none()); } +/// The start screen is not a hidden slide: typing, pasting and IME events must not create or +/// edit a presentation while the File-tab navigation keys still work. +#[test] +fn start_screen_ignores_text_paste_and_ime_while_file_keys_navigate() { + let mut h = harness(SlideApp::new(Session::new(), Services::default())); + assert_eq!(page(&h), Some(Page::Home)); + h.input_mut().events.extend([ + egui::Event::Text("x".into()), + egui::Event::Paste("pasted".into()), + egui::Event::Ime(egui::ImeEvent::Preedit { text: "あ".into(), active_range_chars: None }), + egui::Event::Ime(egui::ImeEvent::Commit("あ".into())), + egui::Event::Key { key: Key::ArrowDown, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::default() }, + ]); + h.run_steps(2); + assert!(h.state().session.active().is_none(), "no typing or IME event opened a presentation"); + assert!(h.state().session.documents().is_empty(), "the start screen has no slide to edit"); + assert!(h.state().status.is_none(), "typing, paste and IME were ignored, not run and refused"); + assert_eq!(page(&h), Some(Page::New), "the File-tab arrow still navigates"); + assert!(h.output().platform_output.ime.is_none(), "the start screen publishes no input-method descriptor"); +} + +/// The start screen keeps the shortcuts that open something in the app: the command palette and +/// Preferences. Both are in-app windows, so no native dialog is involved. +#[test] +fn start_screen_opens_the_palette_and_preferences() { + let mut h = harness(SlideApp::new(Session::new(), Services::default())); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::P); + h.run_steps(3); + assert!(h.state().palette.is_some(), "Ctrl+Shift+P opens the command palette"); + assert!(h.state().session.active().is_none(), "without creating a presentation"); + + let mut h = harness(SlideApp::new(Session::new(), Services::default())); + h.key_press_modifiers(Modifiers::COMMAND, Key::Comma); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("preferences"), "Ctrl+, opens Preferences"); + assert!(h.state().session.active().is_none(), "without creating a presentation"); +} + +/// Ctrl+O still asks the app's open dialog on the start screen, and Alt+F opens the File tab with +/// the keyboard in its side menu. The open dialog is a mock service, not the host's picker. +#[test] +fn start_screen_opens_files_and_the_file_tab_from_the_keyboard() { + let opens: Rc>> = Default::default(); + let log = opens.clone(); + let services = Services { + pick_open: Some(Box::new(move |purpose: &str| { + log.borrow_mut().push(purpose.to_string()); + None + })), + ..Default::default() + }; + let mut h = harness(SlideApp::new(Session::new(), services)); + h.key_press_modifiers(Modifiers::COMMAND, Key::O); + h.run_steps(3); + let asked = opens.borrow(); + assert_eq!(asked.len(), 1, "Ctrl+O asks the app's open dialog once"); + assert_eq!(asked[0], "open", "Ctrl+O reaches the app's open dialog"); + assert!(h.state().session.active().is_none(), "a cancelled open leaves the start screen"); + assert_eq!(page(&h), Some(Page::Home)); + + let mut h = harness(SlideApp::new(Session::new(), Services::default())); + assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), None); + h.key_press_modifiers(Modifiers::ALT, Key::F); + h.run_steps(3); + assert_eq!(page(&h), Some(Page::Home), "Alt+F opens the File tab on Home"); + assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0), "with the keyboard in the side menu"); +} + +/// Document commands stay off the empty start screen: no save picker, find palette, hyperlink +/// dialog, close prompt, status error or page change. +#[test] +fn start_screen_ignores_document_shortcuts() { + let saves: Rc>> = Default::default(); + let log = saves.clone(); + let services = Services { + pick_save: Some(Box::new(move |suggested: &str| { + log.borrow_mut().push(suggested.to_string()); + None + })), + ..Default::default() + }; + let mut h = harness(SlideApp::new(Session::new(), services)); + for (mods, key) in [(Modifiers::COMMAND, Key::S), (Modifiers::COMMAND, Key::K), (Modifiers::COMMAND, Key::F), (Modifiers::COMMAND, Key::W)] { + h.key_press_modifiers(mods, key); + h.run_steps(2); + assert!(h.state().session.active().is_none(), "{key:?} did not open a presentation"); + assert!(h.state().session.documents().is_empty(), "{key:?} created no document"); + assert_eq!(page(&h), Some(Page::Home), "{key:?} left the start screen in place"); + assert!(h.state().status.is_none(), "{key:?} was ignored rather than run and refused"); + assert!(h.state().dialog.is_none(), "{key:?} opened no document dialog"); + assert!(h.state().palette.is_none(), "{key:?} opened no find palette"); + } + assert!(saves.borrow().is_empty(), "Ctrl+S did not ask for a file"); +} + /// #346: a file picked in Open that can't be read used to drop back to the start screen without /// a word. The reason now shows over the start screen. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f5f2d3ea..09d84d6c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -41,6 +41,12 @@ fn mods(ui: &Ui) -> Mods { /// The selected shapes turn with the fingers. The whole twist is one undo step. Pinch-zoom /// and two-finger scrolling are left as they are. pub(crate) fn trackpad_twist(app: &mut SlideApp, ctx: &egui::Context) { + if ctx.will_discard() { + // Multi-pass layout runs this function again with the input already consumed. Acting on + // the discarded pass would start the twist and the replay would immediately end it, + // splitting one gesture into two undo steps. + return; + } let delta = ctx.input(|i| i.rotation_delta()); let delta = if delta.is_finite() { delta.clamp(-std::f32::consts::PI, std::f32::consts::PI) } else { 0.0 }; let turning = delta.abs() > 1e-4; @@ -137,6 +143,8 @@ fn trackpad_scroll(events: &[egui::Event]) -> egui::Vec2 { }) } +/// Paint the editing canvas, including rulers, selection handles, overlays, and current +/// zoom/scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let avail = ui.available_rect_before_wrap(); @@ -144,7 +152,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; // The leftover fraction of a wheel line belongs to one presentation. Drop it whenever another // one is drawn here, hovered or not, so switching away and back starts from zero. - let carry_id = ui.id().with("wheel_carry"); + let carry_id = ui.scope_id().with("wheel_carry"); let doc_uid = st.uid; ui.data_mut(|d| { if d.get_temp::<(u64, f32)>(carry_id).is_none_or(|(uid, _)| uid != doc_uid) { @@ -160,7 +168,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { return; } // Scroll when zoomed beyond the window. - let scroll_id = ui.id().with("canvas_scroll"); + let scroll_id = ui.scope_id().with("canvas_scroll"); let last_id = scroll_id.with("last"); let mut offset: egui::Vec2 = ui.data_mut(|d| d.get_temp(scroll_id).unwrap_or_default()); let last: Option<(Rect, f32)> = ui.data_mut(|d| d.get_temp(last_id)); @@ -241,7 +249,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } else if let Some(next) = page_flick(ui.input(|i| trackpad_scroll(&i.events))) { // Point-based trackpad gestures keep their cooldown. Raw input avoids reading // the smoothed tail of a discrete mouse notch as another page turn. - let gate = ui.id().with("wheel_gate"); + let gate = ui.scope_id().with("wheel_gate"); let last: f64 = ui.data_mut(|d| d.get_temp(gate).unwrap_or(f64::NEG_INFINITY)); let now = ui.input(|i| i.time); if now - last > 0.35 { @@ -454,17 +462,18 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option { /// Turn the input method on for the slide while it takes typing, with its candidate window at /// the insertion point (#101). Without this egui leaves it off, and a Korean keyboard types /// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own. -fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) { +/// +/// This only records the screen rect for this pass. [`crate::SlideApp`] observes the committed +/// text, owner and cause once at the end of the pass, after every IME event and editor command +/// has settled. +fn ime_output(app: &mut SlideApp, ui: &Ui, xf: Xf) { let ctx = ui.ctx(); if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() { return; } let Some(r) = ime_area(app, xf) else { return }; let to_global = ctx.layer_transform_to_global(ui.layer_id()).unwrap_or_default(); - let r = to_global * r; - ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: false }); - }); + app.ime_pending = Some(to_global * r); } /// Play/pause on an animated GIF under the pointer or selected on its own. @@ -483,7 +492,8 @@ fn gif_buttons(app: &mut SlideApp, ui: &mut Ui, xf: Xf, gifs: &[crate::media::Gi } let b = crate::media::gif_button_rect(r); // Sensing drags too keeps the press from reaching the canvas (selecting or moving the GIF). - let resp = ui.interact(b, ui.id().with(("gif_button", g.id.0)), Sense::click_and_drag()); + let resp = ui.interact(b, ui.scope_id().with(("gif_button", g.id.0)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, if g.paused { "Play animated GIF" } else { "Pause animated GIF" })); crate::media::paint_gif_button(ui.painter(), b, g.paused, resp.hovered()); if resp.clicked() { toggle = Some(g.id); @@ -649,8 +659,9 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect let area = Rect::from_min_max(pos2(sx(0.0), top.min.y + 2.0), pos2(sx(width), top.max.y - 1.0)).intersect(top); p.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, t.text_faint.gamma_multiply(0.5)), egui::StrokeKind::Inside); - let resp = ui.interact(top, ui.id().with("ruler_markers"), Sense::click_and_drag()); - let id = ui.id().with("ruler_drag"); + let resp = ui.interact(top, ui.scope_id().with("ruler_markers"), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Text ruler")); + let id = ui.scope_id().with("ruler_drag"); let mut drag: Option = ui.data(|d| d.get_temp::(id)); let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); if resp.drag_started() @@ -716,17 +727,17 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect } // The tab selector in the ruler's corner: a click cycles left, center, right, decimal; a click // on the ruler then sets that kind of stop. - let kind_id = ui.id().with("ruler_tab_kind"); + let kind_id = ui.scope_id().with("ruler_tab_kind"); let mut kind: usize = ui.data(|d| d.get_temp(kind_id)).unwrap_or(0); let corner = Rect::from_min_size(avail.min, vec2(16.0, 16.0)); - let cr = ui.interact(corner, ui.id().with("ruler_tab_selector"), Sense::click()); + let cr = ui.interact(corner, ui.scope_id().with("ruler_tab_selector"), Sense::click()); const KINDS: [(&str, &str); 4] = [("l", "Left Tab"), ("ctr", "Center Tab"), ("r", "Right Tab"), ("dec", "Decimal Tab")]; if cr.clicked() { kind = (kind + 1) % KINDS.len(); ui.data_mut(|d| d.insert_temp(kind_id, kind)); } let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; - cr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, kind_label)); + cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); p.rect_stroke( corner.shrink(2.0), @@ -1709,8 +1720,16 @@ mod tests { pos2((p.x - r.min.x) / s, (p.y - r.min.y) / s) } + /// Test helper: inject a platform-command-modified line wheel event and run thirty harness + /// steps. fn ctrl_wheel(h: &mut Harness<'_, SlideApp>, lines: f32) { - let wheel = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, lines), phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }; + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, lines), + phase: TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + modifiers: Modifiers::COMMAND, + }; h.event(wheel); // egui spreads a wheel notch over several frames. h.run_steps(30); diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 00000000..34c036cd --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1000 @@ +//! Bounded screenshot completion for the control channel. +//! +//! egui 0.37 replaced the `UserData` token and `Event::Screenshot` with a one-shot callback. +//! The callback belongs to the viewport output and may run on a different thread; it must not +//! write files or send replies. This app-owned registry is the strong owner of each admitted +//! request and its completion slot, while the callback only holds a [`std::sync::Weak`] handle. +//! +//! Rules enforced here: +//! - at most [`MAX_REQUESTS`] admitted requests; +//! - at most [`MAX_IMAGE_BYTES`] for one `ColorImage` and [`MAX_TOTAL_BYTES`] for the peak +//! memory of all in-flight/completed-but-undrained captures, including the RGBA copy, the +//! renderer's straight-alpha copy, the PNG and (for an inline reply) its base64 text; +//! - a request is issued only after the UI has settled for [`SETTLE`] and two real UI passes; +//! - the geometry (logical size, DPI/zoom, viewport and epoch) is recorded in a real UI pass, +//! never from stale `run_logic` input; +//! - a completion is correlated by token and rejected before saving when its geometry epoch, +//! image size or document generation no longer matches; +//! - a late completion after cancellation/timeout is released without a file or reply. +//! +//! The one-shot callback wakes only the viewport that issued the screenshot, after the slot lock +//! is released. While queued/settling requests are still waiting to be issued, logic asks a hidden +//! viewport for the bounded paint passes they need; an issued request relies on the screenshot +//! command and the callback wake, and a deadline wake keeps the normal timeout reachable. There is +//! no per-frame 16 ms repaint timer. +//! +//! Web qualification note: the shared eframe fork used here has no `RequestPaintWhileHidden` +//! support. A hidden browser tab may therefore not service `request_repaint_after`; hidden web +//! capture is explicitly unqualified until that fork fix lands. Native hidden/occluded windows +//! are the supported path. + +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use egui::{ColorImage, Context, ScreenshotCallback, Vec2, ViewportCommand, ViewportId}; +use serde_json::json; + +use crate::control::ControlResponse; + +/// Admitted screenshot requests at once. +pub const MAX_REQUESTS: usize = 4; +/// Bytes for one `ColorImage` (`width * height * 4`). Encoding copies are bounded by the +/// aggregate budget below. +pub const MAX_IMAGE_BYTES: usize = 128 << 20; +/// Peak bytes reserved for all in-flight and completed-but-undrained captures, including the +/// image, its RGBA/encoder copies, the PNG and an inline base64 reply. +pub const MAX_TOTAL_BYTES: usize = 256 << 20; +/// The window must have been visible and unchanged for this long before capture. +pub const SETTLE: Duration = Duration::from_millis(120); +/// Completed UI passes required after admission before capture. +pub const SETTLED_PASSES: u32 = 2; +/// A screenshot that has no completion by then fails, as before 0.37. +pub const DEADLINE: Duration = Duration::from_secs(8); + +/// The aggregate reservation after replacing an old reservation with a new one, or `None` when +/// it would exceed [`MAX_TOTAL_BYTES`]. The limit itself is inclusive. +fn budget_total(reserved: usize, replacing: usize, bytes: usize) -> Option { + let total = reserved.checked_sub(replacing)?.checked_add(bytes)?; + (total <= MAX_TOTAL_BYTES).then_some(total) +} + +/// Every allocation a screenshot can hold at its peak. +/// +/// `save_screenshot` makes one RGBA copy from the `ColorImage`, `Image::to_png` clones that copy +/// again while straightening alpha, and the PNG encoder writes its own buffer. An inline reply +/// (`path` absent) additionally base64-encodes the PNG. Reserving the sum, not just the raw +/// pixels, keeps the 256 MiB aggregate honest during encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MemoryPlan { + /// The callback's `ColorImage` (`width * height * 4`). + pub color_image: usize, + /// The RGBA `Vec` handed to `deckcraft_render::Image`. + pub rgba: usize, + /// `Image::to_png`'s straight-alpha copy. + pub encoder_copy: usize, + /// Conservative PNG output bound (raw bytes plus deflate/header slack). + pub png: usize, + /// Inline base64 text; zero when the request writes a path instead. + pub inline: usize, + /// Peak bytes held at once: all of the above. + pub peak: usize, +} + +impl MemoryPlan { + /// Checked plan for an exact pixel size. `None` for zero, non-`u32`, or overflowing images. + pub fn for_image(width: usize, height: usize, inline_response: bool) -> Option { + if u32::try_from(width).is_err() || u32::try_from(height).is_err() { + return None; + } + let color_image = width.checked_mul(height)?.checked_mul(std::mem::size_of::())?; + if color_image == 0 { + return None; + } + let rgba = color_image; + let encoder_copy = color_image; + // The image crate's PNG encoder cannot expand past the raw scanlines, one filter byte + // per row, zlib block headers and chunk framing. 1% plus that row overhead and 1 KiB is + // comfortably above the worst case, including a one-pixel-wide very tall image. + let png = color_image.checked_add(height)?.checked_add(color_image / 100)?.checked_add(1024)?; + let mut peak = color_image.checked_mul(3)?.checked_add(png)?; + let inline = if inline_response { + let base64 = png.checked_add(2)?.checked_div(3)?.checked_mul(4)?; + peak = peak.checked_add(base64)?; + base64 + } else { + 0 + }; + Some(Self { color_image, rgba, encoder_copy, png, inline, peak }) + } + + /// Checked plan for a reserved viewport geometry. + pub fn for_geometry(geometry: Geometry, inline_response: bool) -> Option { + let [width, height] = geometry.pixel_size()?; + Self::for_image(width, height, inline_response) + } +} + +/// Geometry recorded from a real UI pass. `epoch` advances on every size, DPI/zoom or viewport +/// change, including a change back, so a stale image cannot be relabelled with fresh bounds. +/// +/// eframe 0.37 exposes no surface-generation identity: a same-size surface recreation cannot be +/// distinguished from an unchanged surface. That remains an explicit native qualification gap. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Geometry { + pub size: Vec2, + pub pixels_per_point: f32, + pub viewport: ViewportId, + pub epoch: u64, +} + +impl Geometry { + /// Physical pixels the viewport should return, or `None` for a zero/NaN/hostile size. + pub fn pixel_size(self) -> Option<[usize; 2]> { + if !self.size.x.is_finite() || !self.size.y.is_finite() || !self.pixels_per_point.is_finite() { + return None; + } + let w = (self.size.x.max(0.0) as f64 * self.pixels_per_point as f64).round(); + let h = (self.size.y.max(0.0) as f64 * self.pixels_per_point as f64).round(); + if w < 1.0 || h < 1.0 || w > usize::MAX as f64 || h > usize::MAX as f64 { + return None; + } + Some([w as usize, h as usize]) + } + + /// Checked bytes for a reserved image. + pub fn image_bytes(self) -> Option { + let [w, h] = self.pixel_size()?; + w.checked_mul(h)?.checked_mul(std::mem::size_of::()) + } +} + +/// Bounds a callback image must satisfy before it is stored. `max_image_bytes` is the +/// `ColorImage` share of the request's reservation for the geometry its screenshot was issued +/// against; the aggregate reservation also covers the copies and encoding in [`MemoryPlan`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CaptureBounds { + pub max_image_bytes: usize, +} + +#[derive(Debug)] +enum SlotState { + Pending, + Ready(Arc), + /// A frame outside the reserved capture geometry. It is not stored; drain retries it until + /// the request's normal deadline, so an oversized image is never retained. + Retry(&'static str), + Rejected(&'static str), + Cancelled, +} + +/// Completion slot shared by the registry (strong) and the callback (weak). +#[derive(Debug)] +pub struct CaptureSlot { + token: u64, + state: Mutex, +} + +impl CaptureSlot { + /// Create a pending completion slot bound to one screenshot request token. + fn new(token: u64) -> Self { + Self { token, state: Mutex::new(SlotState::Pending) } + } + + /// Called only by the one-shot egui callback. Never writes files or sends replies. + /// + /// The image's byte size is checked against the reservation for the issued geometry *before* + /// the pixels are stored. A larger image is a retryable resize, not a permanent user error. + /// A smaller image is kept so [`Capture::drain`] can compare the actual size exactly and + /// retry instead of saving a frame from an older surface. Returns true when this completion + /// changed the slot, so the callback can wake the viewport after the lock is released. + pub fn complete(&self, token: u64, bounds: CaptureBounds, image: Arc) -> bool { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if token != self.token || !matches!(*state, SlotState::Pending) { + return false; + } + let [width, height] = image.size; + if width == 0 || height == 0 { + *state = SlotState::Rejected("the screenshot was empty"); + return true; + } + let bytes = width.checked_mul(height).and_then(|pixels| pixels.checked_mul(std::mem::size_of::())); + match bytes { + Some(bytes) if bytes <= bounds.max_image_bytes => *state = SlotState::Ready(image), + _ => *state = SlotState::Retry("the screenshot was larger than the reserved window"), + } + true + } + + /// Atomically replace the slot with `Pending` and return its previous state. + fn take(&self) -> SlotResult { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *state, SlotState::Pending) { + SlotState::Pending => SlotResult::Pending, + SlotState::Ready(image) => SlotResult::Ready(image), + SlotState::Retry(why) => SlotResult::Retry(why), + SlotState::Rejected(why) => SlotResult::Rejected(why), + SlotState::Cancelled => SlotResult::Cancelled, + } + } + + /// Mark the slot cancelled so a later completion or drain cannot deliver an image. + fn cancel(&self) { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *state = SlotState::Cancelled; + } +} + +#[derive(Debug)] +enum SlotResult { + Pending, + Ready(Arc), + Retry(&'static str), + Rejected(&'static str), + Cancelled, +} + +/// The one-shot callback wakes only the viewport that issued this screenshot. `complete` has +/// already released the slot lock when this runs, so no lock is held across the wake. +fn completion_callback(ctx: Context, viewport: ViewportId, weak: Weak, token: u64, bounds: CaptureBounds) -> ScreenshotCallback { + ScreenshotCallback::new(move |image| { + let Some(slot) = weak.upgrade() else { return }; + if slot.complete(token, bounds, image) { + ctx.request_repaint_of(viewport); + } + }) +} + +#[derive(Clone, Copy, Debug)] +struct Issued { + geometry: Geometry, +} + +struct Request { + token: u64, + path: Option, + reply: std::sync::mpsc::Sender, + slot: Arc, + admitted: web_time::Instant, + deadline: web_time::Instant, + ui_passes_at_admission: u64, + doc_generation: Option, + issued: Option, + /// Peak bytes reserved for this request. A queued request is reserved from the geometry + /// known at admission and rebooked when the window resizes; an issued request keeps the + /// reservation of the geometry whose callback is in flight. + reservation: usize, +} + +/// One completed screenshot handed to the app thread for saving and replying. +pub struct Completed { + pub token: u64, + pub path: Option, + pub reply: std::sync::mpsc::Sender, + pub image: Arc, + pub geometry: Geometry, + /// Still reserved while the app encodes/writes it; release after saving. + pub reserved: usize, +} + +#[derive(Default)] +pub struct Capture { + next_token: u64, + requests: Vec, + geometry: Option, + settled_passes: u32, + ui_passes: u64, + reserved: usize, +} + +impl Capture { + /// Report whether the maximum number of screenshot requests is already admitted. + pub fn is_full(&self) -> bool { + self.requests.len() >= MAX_REQUESTS + } + + /// Return the number of currently admitted screenshot requests. + pub fn pending(&self) -> usize { + self.requests.len() + } + + /// Peak bytes reserved for in-flight and completed-but-undrained screenshots. + pub fn reserved_bytes(&self) -> usize { + self.reserved + } + + /// Admit one control request, reserving its peak encode memory when the UI geometry is + /// already known. The caller replies with the returned error when admission is refused. + pub fn admit( + &mut self, + path: Option, + reply: std::sync::mpsc::Sender, + doc_generation: Option, + now: web_time::Instant, + ) -> Result<(), &'static str> { + if self.is_full() { + return Err("busy: at most 4 screenshots may be pending"); + } + let reservation = match self.geometry { + Some(geometry) => { + let plan = MemoryPlan::for_geometry(geometry, path.is_none()).ok_or("the window has no usable surface size")?; + if plan.color_image > MAX_IMAGE_BYTES { + return Err("the screenshot would exceed 128 MiB; use ui.render"); + } + let total = budget_total(self.reserved, 0, plan.peak) + .ok_or("screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish")?; + self.reserved = total; + plan.peak + } + // No UI pass yet: the first settled pass books the real geometry in `issue`. + None => 0, + }; + self.next_token = self.next_token.wrapping_add(1).max(1); + let token = self.next_token; + self.requests.push(Request { + token, + path, + reply, + slot: Arc::new(CaptureSlot::new(token)), + admitted: now, + deadline: now + DEADLINE, + ui_passes_at_admission: self.ui_passes, + doc_generation, + issued: None, + reservation, + }); + Ok(()) + } + + /// Record the geometry of the pass that just laid out the real UI. A change resets the + /// settle counter, advances the epoch, and rebooks queued (not in-flight) captures at the + /// new peak size so the aggregate remains accurate during a resize. + pub fn observe_ui(&mut self, ctx: &Context) { + // The screenshot callback returns the whole framebuffer, not the safe-area content + // rect: on web/iOS `content_rect` excludes status-bar/notch insets and would make every + // completion look like a resize. + let size = ctx.viewport_rect().size(); + let ppp = ctx.pixels_per_point(); + let viewport = ctx.viewport_id(); + let changed = self.geometry.is_none_or(|old| old.size != size || old.pixels_per_point != ppp || old.viewport != viewport); + if changed { + let epoch = self.geometry.map_or(1, |g| g.epoch.wrapping_add(1)); + self.geometry = Some(Geometry { size, pixels_per_point: ppp, viewport, epoch }); + self.settled_passes = 1; + self.rebook_unissued(); + } else { + self.settled_passes = self.settled_passes.saturating_add(1); + } + self.ui_passes = self.ui_passes.saturating_add(1); + } + + /// Update reservations of requests whose screenshot has not been requested yet. + fn rebook_unissued(&mut self) { + let geometry = self.geometry; + let mut i = 0; + while i < self.requests.len() { + if self.requests[i].issued.is_some() { + i += 1; + continue; + } + let desired = match geometry { + Some(geometry) => match MemoryPlan::for_geometry(geometry, self.requests[i].path.is_none()) { + Some(plan) if plan.color_image <= MAX_IMAGE_BYTES => plan.peak, + _ => { + self.fail(i, "the window has no usable surface size"); + continue; + } + }, + None => 0, + }; + let old = self.requests[i].reservation; + match budget_total(self.reserved, old, desired) { + Some(total) => { + self.reserved = total; + self.requests[i].reservation = desired; + i += 1; + } + None => self.fail(i, "screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish"), + } + } + } + + /// Issue due requests from the end of a real, settled UI pass. + /// + /// A request is failed here (with a single JSON reply) when the document changed, the + /// window has no usable surface, or the bounded memory budget would be exceeded. + pub fn issue(&mut self, ctx: &Context, doc_generation: Option, now: web_time::Instant) { + let Some(geometry) = self.geometry else { return }; + if self.settled_passes < SETTLED_PASSES { + return; + } + let mut failures: Vec<(usize, String)> = vec![]; + for (i, req) in self.requests.iter_mut().enumerate() { + if req.issued.is_some() + || now.saturating_duration_since(req.admitted) < SETTLE + || self.ui_passes.saturating_sub(req.ui_passes_at_admission) < u64::from(SETTLED_PASSES) + || self.settled_passes < SETTLED_PASSES + { + continue; + } + if req.doc_generation != doc_generation { + failures.push((i, "the presentation changed while the screenshot was waiting".into())); + continue; + } + let Some(plan) = MemoryPlan::for_geometry(geometry, req.path.is_none()) else { + failures.push((i, "the window has no usable surface size".into())); + continue; + }; + if plan.color_image > MAX_IMAGE_BYTES { + failures.push((i, "the screenshot would exceed 128 MiB; use ui.render".into())); + continue; + } + let Some(new_total) = budget_total(self.reserved, req.reservation, plan.peak) else { + failures.push((i, "screenshot budget exhausted (4 requests / 256 MiB including encode); wait for one to finish".into())); + continue; + }; + let weak: Weak = Arc::downgrade(&req.slot); + let callback = completion_callback(ctx.clone(), geometry.viewport, weak, req.token, CaptureBounds { max_image_bytes: plan.color_image }); + ctx.send_viewport_cmd_to(geometry.viewport, ViewportCommand::Screenshot(callback)); + self.reserved = new_total; + req.reservation = plan.peak; + req.issued = Some(Issued { geometry }); + } + for (i, why) in failures.into_iter().rev() { + self.fail(i, &why); + } + } + + /// Take completed results and expired/cancelled requests. A completed image keeps its + /// reservation until [`Self::release`] is called after the app has encoded or written it. + pub fn drain(&mut self, now: web_time::Instant, doc_generation: Option) -> Vec { + let mut done = vec![]; + let current_epoch = self.geometry.map(|g| g.epoch); + let mut i = 0; + while i < self.requests.len() { + // A completion that arrived just before the deadline is still valid: check the slot + // first and only fail on the deadline when nothing is ready. Otherwise the deadline + // branch below would drop a frame that is already in hand. + let result = if self.requests[i].issued.is_some() { self.requests[i].slot.take() } else { SlotResult::Pending }; + match result { + SlotResult::Pending => { + if now >= self.requests[i].deadline { + self.fail(i, "no frame was presented (screen locked or window hidden); use ui.render"); + } else { + i += 1; + } + } + SlotResult::Retry(why) => { + // The callback refused to retain an image outside the reservation. Retry it + // against the current geometry until the normal deadline, exactly like a + // size mismatch; an expired request fails with the same reason. + let Some(_issued) = self.requests[i].issued.take() else { + i += 1; + continue; + }; + self.release_request(i); + if now >= self.requests[i].deadline { + self.fail(i, why); + } else { + self.requests[i].slot = Arc::new(CaptureSlot::new(self.requests[i].token)); + self.requests[i].ui_passes_at_admission = self.ui_passes; + self.requests[i].admitted = now; + i += 1; + } + } + SlotResult::Cancelled => self.fail(i, "screenshot was cancelled"), + SlotResult::Rejected(why) => self.fail(i, why), + SlotResult::Ready(image) => { + let Some(issued) = self.requests[i].issued.take() else { + i += 1; + continue; + }; + let same_geometry = current_epoch == Some(issued.geometry.epoch) + && self.geometry.is_some_and(|g| { + g.size == issued.geometry.size + && g.pixels_per_point == issued.geometry.pixels_per_point + && g.viewport == issued.geometry.viewport + }); + let exact_size = issued.geometry.pixel_size() == Some(image.size); + if !same_geometry || !exact_size { + // The window changed (or the image came from an earlier size) while + // capturing: retry with fresh geometry and the same deadline. This is not + // a document change and writes no file. + self.release_request(i); + if now >= self.requests[i].deadline { + self.fail(i, "the window kept resizing while the screenshot was being captured"); + } else { + self.requests[i].slot = Arc::new(CaptureSlot::new(self.requests[i].token)); + self.requests[i].ui_passes_at_admission = self.ui_passes; + self.requests[i].admitted = now; + i += 1; + } + } else if self.requests[i].doc_generation != doc_generation { + self.fail(i, "the presentation changed while the screenshot was being captured"); + } else { + let reservation = self.requests[i].reservation; + self.requests[i].reservation = 0; + let req = self.requests.remove(i); + done.push(Completed { + token: req.token, + path: req.path, + reply: req.reply, + image, + geometry: issued.geometry, + reserved: reservation, + }); + } + } + } + } + done + } + + /// Release the reservation for a completed screenshot after the app has encoded or written + /// it and replied. The aggregate stays charged through the encode to keep its peak honest. + pub fn release(&mut self, bytes: usize) { + self.reserved = self.reserved.saturating_sub(bytes); + } + + /// Drop one request's reservation but keep it queued (used by a retry). + fn release_request(&mut self, i: usize) { + let bytes = self.requests[i].reservation; + self.requests[i].reservation = 0; + self.release(bytes); + } + + /// Remove a request at `i`, release its reservation and reply exactly once. + fn fail(&mut self, i: usize, why: &str) { + self.release_request(i); + let req = self.requests.remove(i); + req.slot.cancel(); + let _ = req.reply.send(json!({"ok": false, "error": why})); + } + + /// Test-only: make the settle deadline already pass without sleeping. + #[cfg(test)] + pub fn force_settle(&mut self) { + let now = web_time::Instant::now(); + for req in &mut self.requests { + req.admitted = now - SETTLE - Duration::from_millis(1); + req.ui_passes_at_admission = 0; + } + } + + /// Ask the issuing viewport for the bounded work a pending screenshot still needs. + /// + /// Queued/settling requests need real hidden paints (`RequestPaintWhileHidden`) until they + /// are issued. Once issued, the screenshot command itself requests the capture paint and the + /// completion callback wakes the viewport; only a deadline timer is scheduled here, so a + /// missed hidden frame still fails through the normal request deadline instead of a 16 ms + /// repaint loop. + pub fn request_paint_if_needed(&self, ctx: &Context, now: web_time::Instant) { + if self.requests.is_empty() { + return; + } + let viewport = self.geometry.map_or_else(|| ctx.viewport_id(), |g| g.viewport); + if self.requests.iter().any(|req| req.issued.is_none()) { + ctx.send_viewport_cmd_to(viewport, ViewportCommand::RequestPaintWhileHidden); + } + if let Some(deadline) = self.requests.iter().map(|req| req.deadline).min() { + let wait = deadline.saturating_duration_since(now).max(Duration::from_millis(1)); + ctx.request_repaint_after_for(wait, viewport); + } + } +} + +impl Drop for Capture { + /// Cancel every outstanding completion when the capture registry is dropped. + fn drop(&mut self) { + for req in &self.requests { + req.slot.cancel(); + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + /// Test helper: build a solid red `ColorImage` of the requested size. + fn image(w: usize, h: usize) -> Arc { + Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h])) + } + + /// Test helper: admit one `ui.screenshot` request and return its token and reply receiver. + fn setup(cap: &mut Capture, now: web_time::Instant, path: Option<&str>) -> (u64, std::sync::mpsc::Receiver) { + let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); + cap.admit(path.map(str::to_string), req.reply.clone(), Some(7), now).unwrap(); + (cap.requests.last().unwrap().token, rx) + } + + /// Filling the registry to `MAX_REQUESTS` reports full; cancelling after a late completion + /// leaves no image. + #[test] + fn admission_is_bounded_and_late_cancel_keeps_no_image() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + for _ in 0..MAX_REQUESTS { + setup(&mut cap, now, None); + } + assert!(cap.is_full(), "four admitted requests fill the registry"); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + slot.cancel(); + assert!(matches!(slot.take(), SlotResult::Cancelled), "a completion after cancel retains nothing"); + } + + /// A completion carrying a different request token cannot complete the tracked slot. + #[test] + fn one_shot_token_correlation_ignores_a_foreign_completion() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let (token, _rx) = setup(&mut cap, now, Some("first.png")); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(token + 1, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + assert!(matches!(slot.take(), SlotResult::Pending), "another token cannot complete this slot"); + slot.complete(token, CaptureBounds { max_image_bytes: 16 }, image(2, 2)); + assert!(matches!(slot.take(), SlotResult::Ready(_))); + } + + /// Empty images are rejected; nonzero images are retained with their size. + #[test] + fn empty_images_are_rejected_and_other_sizes_are_kept_for_the_drain() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let (token, _rx) = setup(&mut cap, now, None); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(0, 2)); + assert!(matches!(slot.take(), SlotResult::Rejected(_)), "zero width"); + slot.complete(token, CaptureBounds { max_image_bytes: 36 }, image(3, 3)); + let taken = slot.take(); + assert!(matches!(&taken, SlotResult::Ready(image) if image.size == [3, 3]), "the actual size reaches drain"); + } + + /// A smaller image from a resize that has not been observed yet must not be saved just + /// because it fits the byte bound; drain compares the exact pixel size and retries. + #[test] + fn an_image_of_a_different_size_retries_instead_of_saving() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(4.0, 4.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, rx) = setup(&mut cap, now, Some("resized.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 64; + cap.reserved = 64; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "a stale-size image is not written"); + assert_eq!(cap.pending(), 1, "the request is retried"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(rx.try_recv().is_err(), "no premature error reply"); + } + + /// A completion that is ready when the deadline has just passed is still a valid frame. + #[test] + fn a_ready_image_wins_over_the_deadline() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, _rx) = setup(&mut cap, now, Some("late.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now + DEADLINE + Duration::from_millis(1), Some(7)); + assert_eq!(done.len(), 1, "the completed frame is delivered"); + assert_eq!(done[0].path.as_deref(), Some("late.png")); + assert_eq!(done[0].reserved, 16); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 16, "the encode reservation is still held"); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), 0); + } + + /// Fractional DPI rounds the framebuffer size once, on the reserved geometry only. + #[test] + fn fractional_dpi_rounds_to_the_reserved_pixel_size() { + let g = Geometry { size: Vec2::new(101.0, 51.0), pixels_per_point: 1.5, viewport: ViewportId::ROOT, epoch: 1 }; + assert_eq!(g.pixel_size(), Some([152, 77])); + let accepted = |image: [usize; 2]| g.pixel_size() == Some(image); + assert!(accepted([152, 77])); + assert!(!accepted([151, 76]), "a different framebuffer size is a resize, not a fit"); + } + + /// Pixel-memory budgets use checked geometry and reject zero, NaN, or overflowing sizes. + #[test] + fn budgets_use_checked_geometry_and_reject_zero_or_nan_sizes() { + let g = Geometry { size: Vec2::new(100.0, 50.0), pixels_per_point: 2.0, viewport: ViewportId::ROOT, epoch: 1 }; + assert_eq!(g.pixel_size(), Some([200, 100])); + assert_eq!(g.image_bytes(), Some(200 * 100 * 4)); + assert_eq!(Geometry { size: Vec2::new(0.0, 50.0), ..g }.image_bytes(), None); + assert_eq!(Geometry { size: Vec2::new(f32::NAN, 50.0), ..g }.image_bytes(), None); + assert_eq!(Geometry { size: Vec2::new(f32::MAX, f32::MAX), pixels_per_point: f32::MAX, ..g }.image_bytes(), None); + } + + /// A changed geometry epoch retries instead of saving a stale image. + #[test] + fn a_geometry_epoch_change_retries_instead_of_saving() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + // Fake a settled UI pass and issue the request against it. + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 4 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, _rx) = setup(&mut cap, now, Some("retry.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + // The geometry changed before the completion arrived. + cap.geometry = Some(Geometry { epoch: 5, ..geometry }); + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "stale geometry is not written"); + assert_eq!(cap.pending(), 1, "the request is retried"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(cap.requests[0].issued.is_none()); + } + + /// A document change after admission fails the request before any file is written. + #[test] + fn a_document_change_fails_before_writing() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, rx) = setup(&mut cap, now, Some("doc.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 64 }, image(2, 2)); + let done = cap.drain(now, Some(8)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + let reply = rx.recv().unwrap(); + assert_eq!(reply["ok"], false); + } + + /// A timed-out capture replies with failure and releases its reservation. + #[test] + fn timeout_replies_once_and_releases_the_reservation() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + let (_token, rx) = setup(&mut cap, now, None); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let done = cap.drain(now + DEADLINE, Some(7)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 0); + assert_eq!(rx.recv().unwrap()["ok"], false); + } + + /// An out-of-order completion is written to the path belonging to its own token. + #[test] + fn distinct_tokens_keep_distinct_paths_even_when_completed_out_of_order() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + setup(&mut cap, now, Some("a.png")); + setup(&mut cap, now, Some("b.png")); + let plan = MemoryPlan::for_image(2, 2, false).expect("path capture plan"); + for req in &mut cap.requests { + req.issued = Some(Issued { geometry }); + req.reservation = plan.peak; + } + cap.reserved = 2 * plan.peak; + let bounds = CaptureBounds { max_image_bytes: plan.color_image }; + let second = Arc::clone(&cap.requests[1].slot); + second.complete(cap.requests[1].token, bounds, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert_eq!(done.len(), 1); + assert_eq!(done[0].path.as_deref(), Some("b.png")); + assert_eq!(cap.pending(), 1, "the unfinished first request continues"); + assert_eq!(cap.reserved_bytes(), 2 * plan.peak, "completed images stay reserved through encoding"); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), plan.peak, "only the saved second capture is released"); + let first = Arc::clone(&cap.requests[0].slot); + first.complete(cap.requests[0].token, bounds, image(2, 2)); + let done = cap.drain(now, Some(7)); + assert_eq!(done.len(), 1); + assert_eq!(done[0].path.as_deref(), Some("a.png")); + cap.release(done[0].reserved); + assert_eq!(cap.reserved_bytes(), 0); + } + + /// The reservation counts the callback image, the RGBA copy, the encoder's straight-alpha + /// copy, the PNG bound (raw + row filters) and (only for an inline reply) its base64 text. + #[test] + fn memory_plan_counts_image_rgba_png_and_inline_base64() { + let path = MemoryPlan::for_image(2, 2, false).expect("path plan"); + assert_eq!(path.color_image, 16); + assert_eq!(path.rgba, 16); + assert_eq!(path.encoder_copy, 16); + assert_eq!(path.png, 16 + 2 + 1024, "raw, one filter byte per row and encoder slack"); + assert_eq!(path.inline, 0, "a path reply does not hold base64"); + assert_eq!(path.peak, 3 * 16 + path.png); + + let inline = MemoryPlan::for_image(2, 2, true).expect("inline plan"); + assert_eq!(inline.png, path.png); + assert_eq!(inline.inline, path.png.div_ceil(3) * 4); + assert_eq!(inline.peak, path.peak + inline.inline); + + assert_eq!(MemoryPlan::for_image(0, 2, false), None, "zero width"); + assert_eq!(MemoryPlan::for_image(usize::MAX, 2, false), None, "overflowing width"); + assert_eq!(MemoryPlan::for_image(usize::MAX / 4, 4, false), None, "pixel-byte overflow"); + } + + /// Admission reserves the peak before the first UI pass can issue the screenshot, and a + /// second request that would cross the aggregate is refused without changing the total. + #[test] + fn admission_reserves_the_peak_and_refuses_the_one_that_would_exceed_it() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + let plan = MemoryPlan::for_geometry(geometry, true).expect("plan"); + cap.reserved = MAX_TOTAL_BYTES - plan.peak; + setup(&mut cap, now, None); + assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "the exact limit is admitted"); + let (req, rx) = crate::ControlRequest::new("ui.screenshot", json!({})); + assert!(cap.admit(None, req.reply, Some(7), now).is_err(), "one more peak does not fit"); + assert_eq!(cap.reserved_bytes(), MAX_TOTAL_BYTES, "a refused admission changes nothing"); + assert!(rx.try_recv().is_err(), "the caller sends the refusal reply"); + } + + /// A resize rebooks a queued (not in-flight) request at the new peak; the old reservation + /// is released first, so replacement never double-counts it. + #[test] + fn rebooking_at_a_new_geometry_replaces_the_old_reservation() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(100.0, 100.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + setup(&mut cap, now, Some("resize.png")); + let small = MemoryPlan::for_geometry(geometry, false).expect("small plan"); + assert_eq!(cap.reserved_bytes(), small.peak); + let bigger = Geometry { size: Vec2::new(200.0, 200.0), ..geometry }; + cap.geometry = Some(bigger); + cap.rebook_unissued(); + let large = MemoryPlan::for_geometry(bigger, false).expect("large plan"); + assert_eq!(cap.reserved_bytes(), large.peak, "the new peak replaces the old one"); + assert_eq!(cap.requests[0].reservation, large.peak); + } + + /// The aggregate limit is inclusive: exactly 256 MiB is admitted; one byte more is refused. + /// Replacing a request's own reservation does not count it twice. + #[test] + fn aggregate_budget_accepts_the_limit_and_refuses_one_byte_over() { + assert_eq!(budget_total(MAX_TOTAL_BYTES - 16, 0, 16), Some(MAX_TOTAL_BYTES), "equality is allowed"); + assert_eq!(budget_total(MAX_TOTAL_BYTES - 15, 0, 16), None, "one byte over is refused"); + assert_eq!(budget_total(MAX_TOTAL_BYTES, 16, 16), Some(MAX_TOTAL_BYTES), "a replaced reservation is released first"); + } + + /// The callback checks the image against the reservation for the issued geometry before it + /// stores anything. An oversized frame is not retained and drain retries it until the + /// normal deadline, where it fails once. + #[test] + fn a_callback_image_over_the_reserved_geometry_is_not_retained() { + let now = web_time::Instant::now(); + let mut cap = Capture::default(); + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.settled_passes = SETTLED_PASSES; + cap.ui_passes = 10; + let (_token, rx) = setup(&mut cap, now, Some("huge.png")); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + cap.reserved = 16; + let slot = Arc::clone(&cap.requests[0].slot); + let oversized = image(3, 3); + let weak = Arc::downgrade(&oversized); + assert!(slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, oversized.clone())); + drop(oversized); + assert!(weak.upgrade().is_none(), "the rejected pixels are released before the next pass"); + assert!(matches!(slot.take(), SlotResult::Retry(_)), "the oversized frame is retryable, not stored"); + + // Put the retry result back into the request and retry once without writing a file. + let slot = Arc::clone(&cap.requests[0].slot); + slot.complete(cap.requests[0].token, CaptureBounds { max_image_bytes: 16 }, image(3, 3)); + let done = cap.drain(now, Some(7)); + assert!(done.is_empty(), "an oversized image is never written"); + assert_eq!(cap.pending(), 1, "the request retries against fresh geometry"); + assert_eq!(cap.reserved_bytes(), 0, "the old reservation is released"); + assert!(rx.try_recv().is_err(), "no premature error reply"); + + let done = cap.drain(now + DEADLINE, Some(7)); + assert!(done.is_empty()); + assert_eq!(cap.pending(), 0); + assert_eq!(cap.reserved_bytes(), 0); + assert_eq!(rx.recv().unwrap()["ok"], false, "the normal deadline still fails the request once"); + } + + /// A completion wakes the viewport that issued it, once, and only after the slot lock has + /// been released. A second clone of the one-shot callback cannot wake it again. + #[test] + fn a_completion_wakes_only_its_own_viewport_once() { + let ctx = Context::default(); + let viewport = ViewportId::from_hash_of("capture-wake"); + let slot = Arc::new(CaptureSlot::new(1)); + let weak = Arc::downgrade(&slot); + let (tx, rx) = std::sync::mpsc::channel(); + // A new Context starts with pending root paints. Establish the idle state first. + for _ in 0..3 { + ctx.run_ui(Default::default(), |_| {}).drop_without_applying_deltas(); + } + assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT)); + ctx.set_repaint_observer(move |info| { + let _ = tx.send(info.viewport_id); + }); + let callback = completion_callback(ctx.clone(), viewport, weak, 1, CaptureBounds { max_image_bytes: 16 }); + let second = callback.clone(); + callback.complete(image(2, 2)); + second.complete(image(2, 2)); + assert_eq!(rx.try_recv().ok(), Some(viewport), "the issuing viewport is woken"); + assert!(rx.try_recv().is_err(), "a one-shot completion wakes once"); + assert!(ctx.has_requested_repaint_for(&viewport)); + assert!(!ctx.has_requested_repaint_for(&ViewportId::ROOT), "another viewport is not woken"); + } + + /// A queued request needs a bounded hidden paint to reach issue. Once issued, the screenshot + /// command/callback own the wake and only the deadline timer remains, so a hidden window does + /// not repaint every 16 ms. + #[test] + fn hidden_paints_are_bounded_to_queued_requests_and_the_deadline() { + let now = web_time::Instant::now(); + let ctx = Context::default(); + let mut cap = Capture::default(); + setup(&mut cap, now, None); + + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + let queued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); + assert!( + queued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), + "a queued hidden request asks for a real bounded paint: {:?}", + queued.commands + ); + output.drop_without_applying_deltas(); + + let geometry = Geometry { size: Vec2::new(2.0, 2.0), pixels_per_point: 1.0, viewport: ViewportId::ROOT, epoch: 1 }; + cap.geometry = Some(geometry); + cap.requests[0].issued = Some(Issued { geometry }); + cap.requests[0].reservation = 16; + // Drain egui's initial/previous repaint passes before inspecting the future deadline. + for _ in 0..3 { + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + assert!(!output.viewport_output[&ViewportId::ROOT].commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden))); + output.drop_without_applying_deltas(); + } + let output = ctx.run_ui(Default::default(), |ui| cap.request_paint_if_needed(ui.ctx(), now)); + let issued = output.viewport_output.get(&ViewportId::ROOT).expect("root output"); + assert!( + !issued.commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), + "an issued screenshot does not force hidden paints: {:?}", + issued.commands + ); + assert!( + issued.repaint_delay > Duration::from_secs(1) && issued.repaint_delay <= DEADLINE, + "only the normal deadline wake remains: {:?}", + issued.repaint_delay + ); + output.drop_without_applying_deltas(); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 50393af8..8dc21d91 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -21,6 +21,7 @@ use deckcraft_engine::{Mods, PointerEvent}; use serde_json::{Value, json}; use crate::SlideApp; +use crate::capture::{MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MemoryPlan}; pub type ControlResponse = Value; @@ -132,6 +133,7 @@ fn f(p: &Value, k: &str) -> Option { p.get(k).and_then(Value::as_f64).map(|v| v as f32) } +/// Dispatch one decoded control request and return an outcome for its caller to reply with. pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -173,8 +175,8 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> && !show.ended && let Some(key) = key_from(k) { - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + app.queue_input(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + app.queue_input(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); return ok(json!({"show": true})); } wrap(app.session.key(k, mods_from(p)).map_err(|e| e.to_string())) @@ -197,7 +199,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> } else { return err("missing `preedit` or `commit`"); }; - app.synthetic.push(egui::Event::Ime(ev)); + app.queue_input(egui::Event::Ime(ev)); ok(json!({"queued": true})) } "ui.tool.select" => { @@ -213,7 +215,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> "ui.move" | "ui.click" | "ui.drag" => { let (Some(x), Some(y)) = (f(p, "x"), f(p, "y")) else { return err("missing x/y") }; let pos = egui::pos2(x, y); - app.synthetic.push(egui::Event::PointerMoved(pos)); + app.queue_input(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { Some("right" | "secondary") => egui::PointerButton::Secondary, @@ -221,13 +223,13 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }; let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3); for _ in 0..count { - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE }); - app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); + app.queue_input(egui::Event::PointerButton { pos, button, pressed: true, modifiers: egui::Modifiers::NONE }); + app.queue_input(egui::Event::PointerButton { pos, button, pressed: false, modifiers: egui::Modifiers::NONE }); } } else if req.method == "ui.drag" { let (Some(tx), Some(ty)) = (f(p, "toX"), f(p, "toY")) else { return err("missing toX/toY") }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 120); - app.synthetic.push(egui::Event::PointerButton { + app.queue_input(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, @@ -235,9 +237,9 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }); for k in 1..=steps { let a = k as f32 / steps as f32; - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a))); + app.queue_input(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * a, y + (ty - y) * a))); } - app.synthetic.push(egui::Event::PointerButton { + app.queue_input(egui::Event::PointerButton { pos: egui::pos2(tx, ty), button: egui::PointerButton::Primary, pressed: false, @@ -245,7 +247,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> }); } ctx.request_repaint(); - ok(json!({"queued": app.synthetic.len()})) + ok(json!({"queued": app.synthetic_queued_len()})) } "ui.set" => { if let Some(t) = s("tab") { @@ -346,7 +348,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> } "show.end" => { if app.show.is_some() { - app.synthetic.push(egui::Event::Key { + app.queue_input(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, @@ -409,11 +411,38 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option) -> Value { let [w, h] = image.size; + let inline = path.is_none(); + // The request was reserved from the same geometry; re-check the exact image before making + // the RGBA/PNG/base64 copies so a mismatched or oversized completion cannot allocate past + // the capture budget. A path reply deliberately never builds the base64 text. + let Some(plan) = MemoryPlan::for_image(w, h, inline) else { + return json!({"ok": false, "error": "the screenshot has no usable image size"}); + }; + if plan.color_image > MAX_IMAGE_BYTES { + return json!({"ok": false, "error": "the screenshot would exceed 128 MiB; use ui.render"}); + } + if plan.peak > MAX_TOTAL_BYTES { + return json!({"ok": false, "error": "the screenshot would exceed the 256 MiB capture budget; use ui.render"}); + } + if image.pixels.len().checked_mul(4) != Some(plan.color_image) { + return json!({"ok": false, "error": "the screenshot pixel data does not match its size"}); + } + let Some((width, height)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()) else { + return json!({"ok": false, "error": "the screenshot has no usable image size"}); + }; let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); - let img = deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }; + let img = deckcraft_render::Image { width, height, pixels: rgba }; let png = img.to_png(); + if png.is_empty() { + return json!({"ok": false, "error": "the screenshot could not be encoded"}); + } + if png.len() > plan.png { + return json!({"ok": false, "error": "the screenshot PNG exceeded its reserved budget"}); + } let Some(path) = path else { return json!({"ok": true, "result": {"width": w, "height": h, "png": deckcraft_engine::cmd::base64_encode(&png)}}); }; @@ -432,6 +461,34 @@ mod tests { use super::*; + /// A path reply writes the PNG and returns no base64 text; an inline reply returns PNG + /// bytes base64-encoded once. Both go through the same checked memory plan. + #[test] + fn screenshot_reply_is_path_only_or_explicitly_inline() { + use std::sync::{Arc, Mutex}; + + let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default()); + let written = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&written); + app.services.write = Some(Box::new(move |_path, bytes| { + *sink.lock().unwrap() = bytes.to_vec(); + Ok(()) + })); + let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); + + let path_reply = save_screenshot(&mut app, &image, Some("/tmp/shot.png")); + assert_eq!(path_reply["ok"], true, "{path_reply}"); + assert!(path_reply["result"].get("png").is_none(), "a path reply never builds base64"); + let png = written.lock().unwrap().clone(); + assert_eq!(png.get(..8), Some(&b"\x89PNG\r\n\x1a\n"[..]), "the writer gets a PNG"); + + let inline = save_screenshot(&mut app, &image, None); + assert_eq!(inline["ok"], true, "{inline}"); + let text = inline["result"]["png"].as_str().expect("inline base64"); + let decoded = deckcraft_engine::cmd::base64_decode(text).expect("valid base64"); + assert_eq!(decoded, png, "the inline reply is the same PNG"); + } + /// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719). #[test] fn take_and_abandon_are_exclusive() { diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 765dec19..0cef384b 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,7 +212,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 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("Show"); @@ -222,8 +222,8 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } } ui.separator(); - ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let field = ui.label("Sort"); + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; @@ -231,6 +231,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } } }); + let _ = combo.response.labelled_by(field.id); if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { v.ascending = !v.ascending; } diff --git a/crates/ui-egui/src/datetime_tests.rs b/crates/ui-egui/src/datetime_tests.rs index c955aa2e..8a92c637 100644 --- a/crates/ui-egui/src/datetime_tests.rs +++ b/crates/ui-egui/src/datetime_tests.rs @@ -39,6 +39,7 @@ fn date_run(h: &Harness<'_, SlideApp>) -> Option { .cloned() } +/// Insert opens date/time settings, and OK inserts exactly one field. #[test] fn insert_opens_the_dialog_and_ok_inserts_one_field() { let mut h = harness(); @@ -46,7 +47,7 @@ fn insert_opens_the_dialog_and_ok_inserts_one_field() { h.run_steps(2); assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("dateTime")); let _ = h.get_by_label("Update automatically"); - let _ = h.get_by_label("English (United States)"); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Language").value().as_deref(), Some("English (United States)")); let now = deckcraft_text::datetime::DateTime::now_local().unwrap(); let sample = deckcraft_text::datetime::field_text("datetime2", Some("en-US"), now).unwrap(); h.get_by_label(&sample).click(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7c0f8ab1..acbfc97b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -77,12 +77,14 @@ fn title(id: &str) -> &'static str { } } +/// Render the active dialog, clearing it when dismissed and preserving or restoring application +/// state as its close path requires. pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false; let mut window = egui::Window::new(title(&d.id)) - .id(egui::Id::new(("dialog", d.id.clone()))) + .id(egui::Id::unique(("dialog", d.id.clone()))) .collapsible(false) .resizable(false) .anchor(Align2::CENTER_CENTER, vec2(0.0, -40.0)) @@ -182,11 +184,13 @@ fn restore_preferences(app: &mut SlideApp, d: &Dialog) { } } +/// Draw a labelled single-line field bound to `key`, falling back to `default`, and store the +/// edited value in the dialog. fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { let mut v = d.get(key, default); ui.horizontal(|ui| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); - ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)); + let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); } @@ -278,7 +282,9 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { Some(json!({"name": name, "data": deckcraft_engine::cmd::base64_encode(&loaded)})) } -fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, choices: &[(&str, &str)]) { +/// Draw an accessible album combo box, preserving its current/default selection and storing a +/// newly chosen key; the caller supplies the visible label. +fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { let cur = d.get(key, default); let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { @@ -288,7 +294,13 @@ fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, key: &str, default: &str, } } }); - opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label)); + let access = name.to_string(); + let shown = label.to_string(); + opened.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); + info.current_text_value = Some(shown.clone()); + info + }); } const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]; @@ -458,6 +470,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui, d, "album_layout", + "Picture layout", "layout", "1", &[ @@ -475,6 +488,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui, d, "album_frame", + "Frame shape", "frame", "rect", &[("none", "None"), ("rect", "Rectangle"), ("rounded", "Rounded"), ("simple", "Simple, white")], @@ -482,7 +496,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { check(ui, d, "Captions under pictures", "captions", false); check(ui, d, "ALL pictures black and white", "grayscale", false); ui.label("Album theme"); - album_choice(ui, d, "album_theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); + album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); }); let captions = d.fields.get("captions").is_some_and(|v| v == "1"); let grayscale = d.fields.get("grayscale").is_some_and(|v| v == "1"); @@ -556,6 +570,7 @@ fn seed_theme_colors(d: &mut Dialog) { d.fields.insert("seeded".into(), "1".into()); } +/// Draw one theme-colour row with its hex editor and store the value for the scheme slot. fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { let key = slot.xml_name(); let mut v = d.get(key, "000000"); @@ -567,7 +582,7 @@ fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { let resp = ui.add(egui::TextEdit::singleline(&mut v).desired_width(84.0).id_salt(("theme-hex", key))); let shown = v.clone(); resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, format!("{} hex", slot.label())); + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, format!("{} hex", slot.label())); info.current_text_value = Some(shown.clone()); info }); @@ -615,7 +630,7 @@ fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name"); + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); info.current_text_value = Some(shown.clone()); info }); @@ -662,6 +677,8 @@ fn theme_font_choices(app: &SlideApp, selected: &str) -> Vec { v } +/// Draw an accessible font combo box and update the selected family; the caller supplies the +/// visible label. fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut String) { let resp = egui::ComboBox::from_id_salt(label).selected_text(selected.as_str()).width(240.0).show_ui(ui, |ui| { for face in choices { @@ -673,7 +690,7 @@ fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut Strin let shown = selected.clone(); let access = label.to_string(); resp.response.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, access.clone()); + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, access.clone()); info.current_text_value = Some(shown.clone()); info }); @@ -726,7 +743,7 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { - let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Scheme name"); + let mut info = egui::WidgetInfo::labeled(egui::Role::TextInput, true, "Scheme name"); info.current_text_value = Some(shown.clone()); info }); @@ -961,8 +978,8 @@ fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.horizontal(|ui| { - ui.label("Percent:"); - let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)); + let l = ui.label("Percent:"); + let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); if edit.changed() { mode = "percent".into(); } @@ -1056,9 +1073,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "slideSize" => { let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - ui.label("Slides sized for:"); + let label = ui.label("Slides sized for:"); let preset = d.get("preset", ""); - egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { if ui.selectable_label(preset == *label, *label).clicked() { d.fields.insert("preset".into(), label.to_string()); @@ -1067,6 +1084,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + let _ = combo.response.labelled_by(label.id); field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0)); field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0)); let (w, h) = (num(d, "w", cur.width / 72.0), num(d, "h", cur.height / 72.0)); @@ -1213,9 +1231,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - ui.label("Slides"); + let field = ui.label("Slides"); let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; - egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { d.fields.insert("custom".into(), String::new()); } @@ -1225,6 +1243,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + let _ = combo.response.labelled_by(field.id); let (ok, cancel) = buttons(ui, "OK"); if ok { let ty = d.get("type", "speaker"); @@ -1303,7 +1322,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "zoom" => zoom_dialog(app, ui, d), "comment" => { let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Post"); if ok && !v.trim().is_empty() { @@ -1372,8 +1391,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let color = d.get("color", ""); ui.horizontal(|ui| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); - egui::ComboBox::from_id_salt("bullet-color") + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let combo = egui::ComboBox::from_id_salt("bullet-color") .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) .show_ui(ui, |ui| { for (k, l) in colors { @@ -1382,6 +1401,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + let _ = combo.response.labelled_by(field.id); }); ui.horizontal(|ui| { if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { @@ -1461,7 +1481,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }; pick(d, ui, "none", "None"); ui.horizontal(|ui| { - if ui.radio(hyperlink, "Hyperlink to:").clicked() && !hyperlink { + let field = ui.radio(hyperlink, "Hyperlink to:"); + if field.clicked() && !hyperlink { d.fields.insert("kind".into(), "next".into()); } let targets = [ @@ -1477,13 +1498,14 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); ui.add_enabled_ui(hyperlink, |ui| { - egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { for (k, l) in targets { if ui.selectable_label(kind == k, l).clicked() { d.fields.insert("kind".into(), k.into()); } } }); + let _ = combo.response.labelled_by(field.id); }); }); let kind = d.get("kind", cur_kind); @@ -1547,7 +1569,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .unwrap_or_default(); ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); let mut v = d.get("text", &cur); - ui.add(egui::TextEdit::multiline(&mut v).desired_rows(4).desired_width(340.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); let deco = check(ui, d, "Mark as decorative", "deco", deco_now); let (ok, cancel) = buttons(ui, "OK"); @@ -1559,7 +1581,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "outline" => { ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).desired_rows(10).desired_width(380.0).code_editor()); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok { @@ -1665,8 +1687,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if fmt == "pdf" { ui.add_space(4.0); ui.horizontal(|ui| { - ui.label("Print layout:"); - egui::ComboBox::from_id_salt("pdf_layout") + let field = ui.label("Print layout:"); + let combo = egui::ComboBox::from_id_salt("pdf_layout") .selected_text(match pdf_layout.as_str() { "notes" => "Notes Pages", "h1" => "Handouts (1 slide per page)", @@ -1693,6 +1715,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + let _ = combo.response.labelled_by(field.id); }); let mut hidden = d.get("pdfHidden", "false") == "true"; if ui.checkbox(&mut hidden, "Include hidden slides").changed() { @@ -1749,12 +1772,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); let mut to = d.get("to", ""); egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label("Replace:"); - egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { + let field = ui.label("Replace:"); + let combo = egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { for f in &used { ui.selectable_value(&mut from, f.clone(), f); } }); + let _ = combo.response.labelled_by(field.id); ui.end_row(); ui.label("With:"); ui.vertical(|ui| { @@ -1818,7 +1842,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "equation" => { ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); let mut v = d.get("eq", ""); - ui.add(egui::TextEdit::singleline(&mut v).desired_width(340.0).font(theme::font(15.0))); + ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); d.fields.insert("eq".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok && !v.is_empty() { @@ -2037,7 +2061,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { // The selection's own language first when the list doesn't have it. let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); - for (name, tag) in all { + for (_native, tag) in all { + // Native names are shown when the interface fonts can draw them; otherwise + // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). + let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); let label = if installed { format!("{name} (dictionary)") } else { name }; if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { @@ -2111,7 +2138,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { egui::Grid::new("chartdata").striped(true).show(ui, |ui| { for row in grid.iter_mut() { for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).desired_width(70.0)); + ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); } ui.end_row(); } @@ -2176,7 +2203,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); let mut v = d.get("text", "Plan\nBuild\nLaunch"); - ui.add(egui::TextEdit::multiline(&mut v).desired_rows(5).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok { @@ -2218,10 +2245,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut from = d.get("from", ""); let mut to = d.get("to", ""); ui.horizontal(|ui| { - ui.label("Replace"); - ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)); - ui.label("With"); - ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)); + let l = ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); + let l = ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); from.clear(); @@ -2266,7 +2293,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { // on release, and at once from the keyboard. let pending = d.fields.get("uiScale").and_then(|v| v.parse::().ok()); let mut scale = pending.unwrap_or(app.ui.ui_scale); - let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")); + let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); + // No visible label here (the row is laid out to fit); the accessible name only. + let enabled = r.enabled(); + r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); if r.is_pointer_button_down_on() { if r.changed() || pending.is_some() { d.fields.insert("uiScale".into(), scale.to_string()); @@ -2291,8 +2321,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { return false; } ui.horizontal(|ui| { - ui.label("Your name (comments):"); - ui.text_edit_singleline(&mut app.session.prefs.author); + let label = ui.label("Your name (comments):"); + // Name the field for assistive technology; a bare text edit next to a label is an + // unnamed input in egui 0.37's accessibility tree. + let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); }); let (ok, cancel) = buttons(ui, "Done"); if cancel { @@ -2514,11 +2546,12 @@ fn size_position_value(d: &Dialog) -> Result { Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"})) } +/// Draw a labelled single-line text row bound to a dialog key and store the edited value. fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { let mut v = field_of(d, key); ui.horizontal(|ui| { - ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label)); - ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)); + let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label)); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); } @@ -2579,6 +2612,8 @@ fn date_rows(lang: &str) -> Vec<(&'static str, String)> { rows } +/// Render language and format controls; OK applies `insert.dateTime`, and the return value says +/// whether the dialog should close. fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(440.0); ui.label("Available formats:"); @@ -2607,7 +2642,12 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); - opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, lang_label)); + let shown = lang_label.to_string(); + opened.response.widget_info(move || { + let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language"); + info.current_text_value = Some(shown.clone()); + info + }); }); ui.label( egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") @@ -2876,16 +2916,19 @@ fn seed_font_fields(d: &mut Dialog) { seed_once(d, "colorSeed", shown); } -fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) { +/// Draw a uniquely identified accessible choice combo box and store the selected key; +/// the caller supplies the visible label. +fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { let cur = d.get(key, ""); let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); - egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { for (v, l) in choices { if ui.selectable_label(cur == *v, *l).clicked() { d.fields.insert((*key).into(), (*v).into()); } } }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, name)); } fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { @@ -2910,10 +2953,11 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { fams.insert(0, family.clone()); } ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); - egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui( - ui, - |ui| { + let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + let combo = egui::ComboBox::from_id_salt("font_dialog_family") + .selected_text(if family.is_empty() { " " } else { family.as_str() }) + .width(300.0) + .show_ui(ui, |ui| { egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { for f in &fams { if ui.selectable_label(family == *f, f).clicked() { @@ -2921,8 +2965,8 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); - }, - ); + }); + let _ = combo.response.labelled_by(field.id); }); ui.horizontal(|ui| { let _ = labeled_text(ui, d, "Size", "size", 64.0); @@ -2936,6 +2980,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui, d, "font_dialog_underline", + "Underline", "underline", &[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")], ); @@ -2944,21 +2989,21 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.horizontal(|ui| { ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); - choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); + choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); }); ui.horizontal(|ui| { ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); - choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); + choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); ui.add_space(8.0); ui.label("Caps"); - choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); + choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); }); ui.horizontal(|ui| { ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); let mut spacing = d.get("spacing", "0"); let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); let mut picked = None; - egui::ComboBox::from_id_salt("font_dialog_spacing") + let combo = egui::ComboBox::from_id_salt("font_dialog_spacing") .selected_text(match spacing.as_str() { "-3" => "Very tight", "-1.5" => "Tight", @@ -2975,6 +3020,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Spacing")); let _ = edited; if let Some(v) = picked { d.fields.insert("spacing".into(), v); @@ -3117,6 +3163,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui, d, "paragraph_align", + "Alignment", "align", &[ ("left", "Left"), @@ -3138,7 +3185,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); ui.horizontal(|ui| { ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); - choice(ui, d, "paragraph_special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); + choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); ui.add_space(8.0); let _ = labeled_text(ui, d, "By", "by", 64.0); ui.label("in"); @@ -3153,10 +3200,10 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); let mut picked: Option<(&'static str, Option<&'static str>)> = None; ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); let kind = d.get("lineKind", "multiple"); let value = d.get("lineValue", "1"); - egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { for (label, k, v) in [ ("Single", "multiple", Some("1")), ("1.5 lines", "multiple", Some("1.5")), @@ -3169,6 +3216,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); + let _ = combo.response.labelled_by(field.id); ui.add_space(8.0); let _ = labeled_text(ui, d, "At", "lineValue", 64.0); }); @@ -3295,9 +3343,9 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Tab stop position (in):"); + let l = ui.label("Tab stop position (in):"); let mut v = d.get("newpos", ""); - ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)); + ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id); d.fields.insert("newpos".into(), v); }); let cur = d.get("newalign", "l"); @@ -3459,14 +3507,15 @@ fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result Id { - Id::new(name) + Id::unique(name) } /// The colour menu button whose menu is open (noted by `color_popup` every frame it is). diff --git a/crates/ui-egui/src/eyedropper_tests.rs b/crates/ui-egui/src/eyedropper_tests.rs index cc4687b3..9c47e395 100644 --- a/crates/ui-egui/src/eyedropper_tests.rs +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -26,8 +26,9 @@ fn harness() -> Harness<'static, SlideApp> { h } +/// Test helper: stable unique id for the eyedropper test menu. fn menu() -> Id { - Id::new("test-menu") + Id::unique("test-menu") } /// A colour menu asked for the Eyedropper. diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index cb539cf3..2410dc0a 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -107,7 +107,7 @@ const DIRECTIONS_PATH: [(&str, [f64; 4]); 5] = [ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> Option { let t = Tokens::get(ui.ctx()); let mut out: Option = None; - let sel_id = ui.id().with((id, "stop")); + let sel_id = ui.scope_id().with((id, "stop")); let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1)); // Preset variations of the first stop's colour. @@ -168,8 +168,8 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> GradientShape::Path { .. } => "Radial", }; ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); - egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); + let combo = egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { for k in ["Linear", "Radial", "Rectangular", "Path"] { if ui.selectable_label(kind == k, k).clicked() && kind != k { let focus = match &g.shape { @@ -186,6 +186,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } } }); + let _ = combo.response.labelled_by(field.id); }); ui.horizontal_wrapped(|ui| { ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); @@ -211,7 +212,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> if let GradientShape::Linear { angle, .. } = &g.shape { ui.horizontal(|ui| { ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle")); - if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), *angle, 15.0, "°", 70.0) { + if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), "Angle", *angle, 15.0, "°", 70.0) { out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); } }); @@ -221,15 +222,17 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> ui.add_space(4.0); ui.label("Gradient stops"); let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); + bar_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Gradient bar")); paint_bar(ui, bar, g, sc); let (marks, _) = ui.allocate_exact_size(vec2(bar.width(), 14.0), Sense::hover()); - let drag_id = ui.id().with((id, "drag")); + let drag_id = ui.scope_id().with((id, "drag")); let mut dragging: Option<(usize, f64)> = ui.data_mut(|d| d.get_temp(drag_id)); for (i, s) in g.stops.iter().enumerate() { let pos = dragging.filter(|d| d.0 == i).map_or(s.pos, |d| d.1); let x = bar.min.x + pos as f32 * bar.width(); let r = Rect::from_center_size(pos2(x, marks.center().y), vec2(10.0, 12.0)); - let resp = ui.interact(r, ui.id().with((id, "mark", i)), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with((id, "mark", i)), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Gradient stop {}", i + 1))); let fill = c32(&s.color, sc); let stroke = if i == sel { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.handle_stroke) }; let tri = vec![pos2(x, r.min.y), pos2(r.max.x, r.min.y + 4.0), pos2(r.max.x, r.max.y), pos2(r.min.x, r.max.y), pos2(r.min.x, r.min.y + 4.0)]; @@ -296,7 +299,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }); ui.horizontal(|ui| { ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position")); - if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), (s.pos * 100.0).round(), 5.0, "%", 70.0) { + if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), "Position", (s.pos * 100.0).round(), 5.0, "%", 70.0) { let mut ng = g.clone(); ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); ng.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); @@ -304,10 +307,10 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); - let key = ui.id().with((id, "tr", sel)); + let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + let key = ui.scope_id().with((id, "tr", sel)); let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); - let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")); + let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")).labelled_by(label.id); if r.changed() { ui.data_mut(|d| d.insert_temp(key, tr)); } @@ -481,7 +484,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option let stroke = if on || resp.hovered() || resp.has_focus() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) }; ui.painter().rect_stroke(r.shrink(1.0), CornerRadius::same(2), stroke, egui::StrokeKind::Inside); let label = pattern_label(name); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, label)); if resp.on_hover_text(label).clicked() && !on { out = Some(PatternFill { preset: name.to_string(), ..pt.clone() }); } @@ -495,7 +498,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option ui.painter().rect(sw, CornerRadius::same(3), c32(cur, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); let name = format!("{label} color"); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); widgets::color_popup(&r).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, sc, None) { out = Some(if foreground { PatternFill { fg: c, ..pt.clone() } } else { PatternFill { bg: c, ..pt.clone() } }); diff --git a/crates/ui-egui/src/huge_deck_tests.rs b/crates/ui-egui/src/huge_deck_tests.rs index 6652de77..7e2ebb16 100644 --- a/crates/ui-egui/src/huge_deck_tests.rs +++ b/crates/ui-egui/src/huge_deck_tests.rs @@ -57,6 +57,7 @@ fn the_thumbnail_pane_of_a_huge_deck_renders_only_the_slides_in_view() { delta: egui::vec2(0.0, -40_000.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, }); h.run_steps(60); let scrolled = h.state().textures.thumb_count(); @@ -107,6 +108,7 @@ fn a_settled_thumbnail_pane_renders_nothing_more() { delta: egui::vec2(0.0, -40_000.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, }); h.run_steps(60); let after = h.state().textures.thumb_renders(); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index c9bc7dab..2c13e886 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -9,6 +9,7 @@ use serde_json::json; use crate::{Services, SlideApp}; use deckcraft_engine::Session; +/// Build the standard 1280×800 UI harness around a supplied session and run three frames. fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> { let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { @@ -17,6 +18,7 @@ fn harness(session: Session) -> egui_kittest::Harness<'static, SlideApp> { }, SlideApp::new(session, Services::default()), ); + crate::install_ime_pass_hooks(&h.ctx); // The app keeps requesting repaints, so step frames instead of run(). h.run_steps(3); h @@ -45,7 +47,7 @@ fn korean_composes_in_a_slide_text_box() { let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; h.state_mut().run("text.edit", json!({"id": title})).unwrap(); h.run_steps(2); - let ime = h.output().platform_output.ime.expect("the input method is on while editing"); + let ime = h.output().platform_output.ime.as_ref().expect("the input method is on while editing"); let slide = h.state().slide_rect.unwrap(); assert!(slide.contains(ime.rect.center()), "candidate window at the insertion point {:?}, on the slide {slide:?}", ime.rect); assert!(ime.rect.width() < 8.0 && ime.rect.height() > 4.0, "a caret, not the box: {:?}", ime.rect); @@ -61,7 +63,7 @@ fn korean_composes_in_a_slide_text_box() { assert!(!deckcraft_engine::cmd::text::composition_underlines(st).is_empty(), "underlined while composing"); } h.run_steps(1); - let ime = h.output().platform_output.ime.unwrap(); + let ime = h.output().platform_output.ime.as_ref().unwrap(); assert!(ime.rect.center().x > caret0.center().x, "the candidate window follows the insertion point"); h.event(Event::Ime(ImeEvent::Commit("한글".into()))); h.run_steps(2); @@ -94,11 +96,216 @@ fn japanese_conversion_shows_the_active_part() { let u = deckcraft_engine::cmd::text::composition_underlines(h.state().session.active().unwrap()); assert_eq!(u.iter().filter(|x| x.2).count(), 1, "{u:?}"); let undo0 = undo_len(h.state()); - h.key_press(egui::Key::End); + // kittest runs one frame per queued event. Assert the key-down frame itself: the platform + // composition must be cancelled there, not one frame later after the key-up releases it. + h.key_down(egui::Key::End); h.run_steps(1); + let interrupted = h.output().platform_output.ime.clone().expect("still editing"); + assert!(interrupted.should_interrupt_composition, "the platform composition is interrupted"); assert_eq!(title_text(h.state()), "日本ご"); assert!(h.state().session.active().unwrap().composition.is_none()); assert_eq!(undo_len(h.state()), undo0 + 1); + h.key_up(egui::Key::End); + h.run_steps(1); + // The published interrupt is the platform reset boundary; the tombstone for the settled + // generation ends there, so a later commit-only event is treated as a new platform + // generation rather than being dropped for the rest of the session. + assert!(h.state().ime_cancelled.is_none(), "the published interrupt retired the settled tombstone"); +} + +/// A composition the application settled (here an End key) drops the platform pre-edit and commit +/// already queued in the same input batch, so the conversion is typed once. The published +/// interrupt is the explicit boundary: after it the next pre-edit is a new generation. A commit +/// the platform queues after that reset is indistinguishable from a legitimate direct commit +/// (egui's events carry no generation), so the bounded tombstone does not pretend otherwise. +#[test] +fn settled_composition_drops_queued_preedit_and_same_batch_commit() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.event(preedit("日本")); + h.run_steps(1); + let undo0 = undo_len(h.state()); + + // One RawInput: the key settles the composition and the platform's queued pre-edit/commit + // arrive after it in the same pass. None of them may be applied. Force an extra discarded + // pass too: the interrupt may only be consumed by the settled pass the platform sees. + h.ctx.request_discard("ime interrupt through a discarded pass"); + h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.input_mut().events.push(preedit("日本")); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into()))); + h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本", "the stale pre-edit and commit do not restart the cancelled composition"); + assert!(h.state().session.active().unwrap().composition.is_none()); + assert_eq!(undo_len(h.state()), undo0 + 1, "the settled composition is one undo step"); + let interrupted = h.output().platform_output.ime.clone().expect("still editing"); + assert!(interrupted.should_interrupt_composition, "the settled platform composition is interrupted"); + assert!(h.state().ime_cancelled.is_none(), "the settled pass published the interrupt and retired the tombstone"); + let reset = interrupted.reset_request.expect("the settled pass carries the opaque reset request"); + + // A real new composition is admitted only by the exact ordered completion. + h.input_mut().events.extend([reset_completed(reset), preedit("新")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本新", "a legitimate new pre-edit starts a new generation"); + assert!(h.state().session.active().unwrap().composition.is_some()); + h.event(Event::Ime(ImeEvent::Commit("新".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本新"); +} + +/// A commit-only event in the settling batch is stale (it belongs to the composition the key +/// ended). After the published interrupt, a later commit-only event is a legitimate direct input +/// method commit and must not be swallowed by a permanent tombstone. +#[test] +fn stale_commit_in_the_settling_batch_is_rejected_and_a_later_direct_commit_is_accepted() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.event(preedit("日本")); + h.run_steps(1); + let undo0 = undo_len(h.state()); + + // One RawInput: the key settles the composition and the platform's queued commit follows. + h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into()))); + h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本", "the same-batch commit does not type the conversion twice"); + assert_eq!(undo_len(h.state()), undo0 + 1); + assert!(h.state().ime_cancelled.is_none(), "the published interrupt retired the tombstone"); + let reset = h.output().platform_output.ime.as_ref().and_then(|ime| ime.reset_request).expect("the settled pass carries the opaque reset request"); + + // A direct commit with no pre-edit (a real IME's punctuation/full-width character path) + // arrives after the exact ordered completion and is accepted. + h.input_mut().events.extend([reset_completed(reset), Event::Ime(ImeEvent::Commit("。".into()))]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日本。", "a later commit-only is not lost"); +} + +/// The interrupt is only a boundary when it reaches the platform. A discarded layout/font pass +/// keeps both the pending interrupt and the tombstone; the next settled pass retires the tombstone +/// and publishes the reset. Assert the per-pass state, not just the final output carrier. +#[test] +fn a_discarded_pass_keeps_the_tombstone_until_a_settled_pass_publishes_the_interrupt() { + use std::sync::{Arc, Mutex}; + let traces = Arc::new(Mutex::new(Vec::new())); + let seen = traces.clone(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1280.0, 800.0)).build_ui_state( + move |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + seen.lock().unwrap().push(( + app.ime_cancelled.is_some(), + app.ime_interrupt_pending, + app.ime_composition.is_none(), + ui.ctx().will_discard(), + )); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + h.run_steps(3); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(2); + h.event(preedit("日本")); + h.run_steps(1); + traces.lock().unwrap().clear(); + + h.ctx.request_discard("discarded IME interrupt boundary"); + h.input_mut().events.push(Event::Key { key: egui::Key::End, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.input_mut().events.push(preedit("日本")); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("日本".into()))); + h.run_steps(1); + + let t = traces.lock().unwrap().clone(); + assert_eq!(t.len(), 2, "one discarded pass followed by the settled pass: {t:?}"); + assert!(t[0].0, "the discarded pass keeps the settled tombstone"); + assert!(t[0].1, "the interrupt stays pending through the discarded pass"); + assert!(t[0].2, "the stale pre-edit/commit cleared the tracked composition"); + assert!(t[0].3, "the first pass is discarded"); + assert!(!t[1].0, "the settled pass publishes the interrupt and retires the tombstone"); + assert!(!t[1].1); + assert!(!t[1].3); + assert_eq!(title_text(h.state()), "日本"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.should_interrupt_composition)); +} + +/// A settled composition whose descriptor was not painted leaves a tombstone. A *discarded* +/// pass keeps it (the platform has not been told to reset), but an accepted no-descriptor pass is +/// the backend's reset boundary: the tombstone must be retired there, or the first legitimate +/// commit into the next text target is lost. +#[test] +fn accepted_no_descriptor_pass_retires_the_settled_tombstone() { + use std::sync::{Arc, Mutex}; + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let title = app.session.active().unwrap().doc.slides[0].shapes[0].id.0; + app.run("text.edit", json!({"id": title})).unwrap(); + let owner = app.ime_target().expect("the edited title is an IME target").owner; + let generation = 7; + app.ime_generation = generation; + app.ime_composition = Some(crate::ImeComposition { owner, generation, settled: true }); + app.ime_cancelled = Some(crate::ImeCancelled { owner, generation }); + app.ime_interrupt_pending = true; + app.ime_pending = None; + + let traces = Arc::new(Mutex::new(Vec::new())); + let seen = traces.clone(); + ctx.request_discard("no-descriptor IME boundary"); + ctx.run_ui(egui::RawInput::default(), |ui| { + let will_discard = ui.ctx().will_discard(); + app.ime_pending = None; + let _ = app.observe_ime(ui.ctx()); + seen.lock().unwrap().push((will_discard, app.ime_cancelled.is_some(), app.ime_interrupt_pending)); + }) + .drop_without_applying_deltas(); + + let t = traces.lock().unwrap().clone(); + assert_eq!(t.len(), 2, "one discarded pass then the accepted pass: {t:?}"); + assert!(t[0].0, "the first pass is discarded"); + assert!(t[0].1, "a discarded pass keeps the tombstone"); + assert!(t[0].2, "a discarded pass keeps the interrupt pending"); + assert!(!t[1].0, "the second pass is accepted"); + assert!(!t[1].1, "the accepted no-descriptor pass retires the tombstone"); + assert!(!t[1].2, "there is no descriptor to publish the interrupt in"); + assert!(app.ime_composition.is_none(), "the settled composition is retired with its tombstone"); + + // The next text target's first event is a legitimate direct commit, not the old generation. + app.ime_event(&ctx, &egui::ImeEvent::Commit("。".into())); + assert_eq!(title_text(&app), "。", "a first commit in the new target is not swallowed"); +} + +/// A focused stock `TextEdit` can publish its own `o.ime` while the canvas has no pending rect. +/// That descriptor is not a backend reset: the canvas tombstone and settled composition must +/// survive until an accepted pass really publishes no descriptor at all. +#[test] +fn focused_text_field_descriptor_keeps_the_settled_tombstone() { + let mut h = harness(Session::with_new()); + let notes = egui::Id::unique("notes_edit"); + h.ctx.memory_mut(|m| m.request_focus(notes)); + h.run_steps(2); + assert!(h.ctx.memory(|m| m.has_focus(notes)), "the notes TextEdit has focus"); + let owner = egui::Id::unique("old-canvas-ime"); + h.state_mut().ime_composition = Some(crate::ImeComposition { owner, generation: 7, settled: true }); + h.state_mut().ime_cancelled = Some(crate::ImeCancelled { owner, generation: 7 }); + h.state_mut().ime_interrupt_pending = true; + + h.run_steps(1); + assert!(h.output().platform_output.ime.is_some(), "the focused stock TextEdit publishes its own descriptor"); + assert!(h.state().ime_cancelled.is_some(), "a descriptor-publishing pass is not a reset"); + assert!(h.state().ime_interrupt_pending, "the canvas interrupt is still waiting for a reset pass"); + assert!(h.state().ime_composition.is_some_and(|c| c.settled), "the settled composition is not retired"); + + // With focus gone there is no canvas target and no TextEdit descriptor: this accepted pass + // is the backend reset boundary, so the tombstone and settled composition retire. + h.ctx.memory_mut(|m| m.surrender_focus(notes)); + h.run_steps(1); + assert!(h.output().platform_output.ime.is_none(), "nothing publishes an IME descriptor"); + assert!(h.state().ime_cancelled.is_none(), "the accepted no-descriptor pass retires the tombstone"); + assert!(!h.state().ime_interrupt_pending); + assert!(h.state().ime_composition.is_none(), "the settled composition retires with its tombstone"); } /// Typing on a selected (not edited) shape with an input method replaces its text, as typing does; @@ -124,7 +331,7 @@ fn composing_on_a_selected_shape_starts_editing_it() { #[test] fn korean_composes_in_the_notes_pane() { let mut h = harness(Session::with_new()); - let id = egui::Id::new("notes_edit"); + let id = egui::Id::unique("notes_edit"); h.ctx.memory_mut(|m| m.request_focus(id)); h.run_steps(2); assert!(h.ctx.memory(|m| m.has_focus(id))); @@ -134,7 +341,7 @@ fn korean_composes_in_the_notes_pane() { h.run_steps(1); assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one"); } - let ime = h.output().platform_output.ime.expect("the notes field turns the input method on"); + let ime = h.output().platform_output.ime.as_ref().expect("the notes field turns the input method on"); let slide = h.state().slide_rect.unwrap(); assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide"); h.event(Event::Ime(ImeEvent::Commit("노트".into()))); @@ -171,6 +378,15 @@ fn system_has(s: &str) -> bool { s.chars().filter(|c| !c.is_whitespace()).all(|c| deckcraft_fonts::FontDb::global().fallback_for(c, u32::MAX).is_some_and(|f| !f.is_color())) } +/// Assert that both interface faces draw every non-space character of `text`; `painted` describes +/// the sample in a failure message. +fn assert_faces_draw(h: &egui_kittest::Harness<'_, SlideApp>, text: &str, painted: &str) { + for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] { + let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect(); + assert!(missing.is_empty(), "{family:?} draws {missing:?} of {painted} as boxes"); + } +} + /// #101: a Korean file name, title, notes and comments, a Japanese slide and a Chinese section are /// drawn with glyphs in the interface (title bar, outline, notes pane, comments), not boxes: faces /// that draw them are borrowed from the font database. A Latin presentation borrows nothing. @@ -183,35 +399,1581 @@ fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() { let korean = "한국어 발표 자료"; let japanese = "日本語のスライド"; let chinese = "简体中文演示文稿"; - let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; - s.execute("text.set", &json!({"id": title, "text": japanese})).unwrap(); - s.execute("slide.notes", &json!({"text": "발표자 노트입니다"})).unwrap(); - s.execute("section.add", &json!({"name": chinese})).unwrap(); - s.doc_mut().unwrap().path = Some(format!("{korean}.pptx")); + let notes = "발표자 노트입니다"; + let require_all = option_env!("CRAFT_FONTS_REQUIRED").is_some() || deckcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "LXGW WenKai Lite"); + // The title slide's title and subtitle are painted by the initial Normal view, under + // kittest's strict missing-glyph policy. Pre-paint CJK only when this build is required to + // carry it or this machine can draw every initial sample; an optional build without those + // fonts must skip before that first paint instead of panicking and only then checking. + let initial_cjk = require_all || [chinese, japanese, korean].iter().all(|t| system_has(t)); + let slide = s.doc().unwrap().current_slide().unwrap(); + let title = slide.shapes[0].id.0; + let body = slide.shapes[1].id.0; + if initial_cjk { + s.execute("text.set", &json!({"id": title, "text": chinese})).unwrap(); + s.execute("text.set", &json!({"id": body, "text": japanese})).unwrap(); + s.execute("slide.notes", &json!({"text": notes})).unwrap(); + s.execute("section.add", &json!({"name": chinese})).unwrap(); + s.doc_mut().unwrap().path = Some(format!("{korean}.pptx")); + } let mut h = harness(s); + if initial_cjk { + // `harness` already completed its first passes. Both initially painted samples must have + // real glyph coverage before any command (and the strict policy would have panicked + // otherwise). + let initial: Vec = h.state().session.active().unwrap().doc.slides[0] + .shapes + .iter() + .take(2) + .map(|sh| sh.text.as_ref().map(|t| t.paragraphs.iter().map(|p| p.text()).collect::>().join("\n")).unwrap_or_default()) + .collect(); + assert_eq!(initial, [chinese.to_owned(), japanese.to_owned()]); + for text in [chinese, japanese] { + assert_faces_draw(&h, text, &format!("initially painted {text:?}")); + } + } else { + eprintln!("optional font input: initial CJK paint skipped before layout (no complete local coverage)"); + // Each sample this machine can draw is still painted for real after the check; the + // strict policy then checks it. Unsupported samples stay out of the document entirely. + if system_has(chinese) { + h.state_mut().run("text.set", json!({"id": title, "text": chinese})).unwrap(); + h.state_mut().run("section.add", json!({"name": chinese})).unwrap(); + } + if system_has(japanese) { + h.state_mut().run("text.set", json!({"id": body, "text": japanese})).unwrap(); + } + if system_has(korean) { + h.state_mut().run("slide.notes", json!({"text": notes})).unwrap(); + h.state_mut().session.doc_mut().unwrap().path = Some(format!("{korean}.pptx")); + } + if system_has(chinese) || system_has(japanese) || system_has(korean) { + h.run_steps(3); + } + } + // Outline and file-name text is painted only after this command; the Chinese title above has + // already covered the initial-paint case. h.state_mut().run("view.outline", json!({})).unwrap(); h.run_steps(3); - assert!(h.state().session.active().unwrap().title().contains(korean)); + assert!(!initial_cjk || h.state().session.active().unwrap().title().contains(korean)); let mut checked = 0; - for text in [korean, japanese, chinese, "발표자 노트입니다"] { + for text in [korean, japanese, chinese, notes] { if !system_has(text) { + assert!(!require_all, "required font input does not draw {text:?}"); eprintln!("skipped {text:?}: no font on this machine draws it"); continue; } - for family in [FontId::proportional(13.0), crate::theme::bold(13.0)] { - let missing: String = text.chars().filter(|c| !c.is_whitespace() && !h.ctx.fonts_mut(|f| f.has_glyph(&family, *c))).collect(); - assert!(missing.is_empty(), "{family:?} draws {missing:?} of {text:?} as boxes"); - } + assert_faces_draw(&h, text, &format!("{text:?}")); checked += 1; } - if cfg!(windows) || cfg!(target_os = "macos") { - assert_eq!(checked, 4, "Windows and macOS have Korean and Chinese fonts"); + if require_all || cfg!(windows) || cfg!(target_os = "macos") { + assert_eq!(checked, 4, "all CJK samples must be exercised with required/bundled fonts"); } - // Text typed later (a dialog field, a new comment) is looked at too. + // Text typed later (a dialog field, a new comment) is looked at too. Never type text this + // machine cannot draw: the strict missing-glyph policy would panic before the optional check. let typed = "ไทย"; - h.event(Event::Text(typed.into())); - h.run_steps(2); if system_has(typed) { + h.event(Event::Text(typed.into())); + h.run_steps(2); assert!(h.ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(13.0), typed))); + } else if require_all { + panic!("required font input does not draw {typed:?}"); + } else { + eprintln!("skipped {typed:?}: no font on this machine draws it"); } } + +/// An input method can ask for characters (stock `DeleteSurrounding`) or UTF-8 bytes (the fork's +/// `DeleteSurroundingBytes`) around the caret to be removed. The byte range never splits a +/// character, and a deletion made while composing stays in the composition's one undo step. +#[test] +fn ime_delete_surrounding_handles_chars_bytes_and_composition_undo() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "abc"})).unwrap(); + h.run_steps(2); + h.event(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 2, after_chars: 0 })); + h.run_steps(1); + assert_eq!(title_text(h.state()), "a"); + let deleted = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(deleted.change_cause, egui::output::ImeChangeCause::InputMethod, "an accepted deletion is an IME mutation"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abc", "a non-composing deletion is one undo step"); + + // UTF-8 bytes: the four bytes of an emoji are one character and must delete whole. + h.state_mut().session.execute("text.set", &json!({"id": title, "text": "aé😀"})).unwrap(); + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.move", &json!({"to": "end"})).unwrap(); + h.run_steps(2); + h.event(Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 4, after_bytes: 0 })); + h.run_steps(2); + assert_eq!(title_text(h.state()), "aé", "the emoji's bytes delete exactly one scalar"); + + // Deleting before a composition, then committing, is a single undo step. + h.state_mut().session.execute("text.set", &json!({"id": title, "text": "abc"})).unwrap(); + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(2); + let undo0 = undo_len(h.state()); + h.event(preedit("日本")); + h.run_steps(1); + h.event(Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 })); + h.run_steps(1); + assert_eq!(title_text(h.state()), "ab日本"); + h.event(Event::Ime(ImeEvent::Commit("日本".into()))); + h.run_steps(2); + assert_eq!(title_text(h.state()), "ab日本"); + assert_eq!(undo_len(h.state()), undo0 + 1, "delete + commit is one composition step"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abc"); +} + +/// The canvas producer publishes bounded logical committed text with UTF-8 byte offsets and a +/// stable owner/generation across ordinary typing, caret movement and reversed selection. +#[test] +fn ime_output_is_logical_bounded_and_owner_stable() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "aé😀b"})).unwrap(); + h.run_steps(2); + let first = h.output().platform_output.ime.clone().expect("editing text owns the input method"); + assert_eq!(first.purpose, egui::IMEPurpose::Normal); + assert_eq!(first.change_cause, egui::output::ImeChangeCause::Other); + let payload = first.surrounding_text.available().expect("model text is available"); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aé😀b", 8, 8)); + let (owner, generation) = (first.owner, first.context_generation); + + // A caret move is not a new context and still reports logical UTF-8 offsets. + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); + h.run_steps(1); + let moved = h.output().platform_output.ime.clone().unwrap(); + assert_eq!((moved.owner, moved.context_generation), (owner, generation)); + let payload = moved.surrounding_text.available().unwrap(); + assert_eq!((payload.cursor, payload.anchor), (3, 3)); + + // A reversed selection keeps caret < anchor in the outgoing bytes. + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap(); + h.run_steps(1); + let reversed = h.output().platform_output.ime.clone().unwrap(); + assert_eq!((reversed.owner, reversed.context_generation), (owner, generation)); + let payload = reversed.surrounding_text.available().unwrap(); + assert_eq!((payload.cursor, payload.anchor), (1, 7), "caret and anchor keep their direction"); + + // A forced extra pass in the same frame keeps the same context. + h.ctx.request_discard("ime multipass regression"); + h.run_steps(1); + let multipass = h.output().platform_output.ime.clone().unwrap(); + assert_eq!((multipass.owner, multipass.context_generation), (owner, generation)); + + // The extra window renders the slide and owns no text: whether the backend draws it as its + // own native viewport or (as in kittest, which installs no immediate-viewport renderer) + // embedded in this pass, it must neither own nor disturb the editor's context. + h.state_mut().run("view.newWindow", json!({"on": true})).unwrap(); + h.run_steps(2); + let second_window = h.output().platform_output.ime.clone().unwrap(); + assert_eq!((second_window.owner, second_window.context_generation), (owner, generation)); + h.state_mut().run("view.newWindow", json!({"on": false})).unwrap(); +} + +/// A model-stored composition is pre-edit, not committed text. An accepted commit changes the +/// committed snapshot and is the only path that earns `InputMethod`. +#[test] +fn preedit_is_excluded_and_a_commit_is_input_method() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "ab"})).unwrap(); + h.run_steps(2); + h.event(preedit("日本")); + h.run_steps(1); + let composing = h.output().platform_output.ime.clone().unwrap(); + let payload = composing.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "pre-edit is not committed text"); + assert_eq!(composing.change_cause, egui::output::ImeChangeCause::Other); + + h.event(Event::Ime(ImeEvent::Commit("日本".into()))); + h.run_steps(1); + let committed = h.output().platform_output.ime.clone().unwrap(); + let payload = committed.surrounding_text.available().unwrap(); + assert_eq!(payload.text, "ab日本"); + assert_eq!((payload.cursor, payload.anchor), (8, 8)); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); + + // A pre-edit and its commit in one frame is still one accepted IME mutation. `Harness::event` + // queues one frame per event, so put both in a single RawInput. + h.state_mut().session.execute("text.insert", &json!({"text": "X"})).unwrap(); + h.run_steps(1); + h.input_mut().events.push(preedit("Y")); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + let same_frame = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(title_text(h.state()), "ab日本XY"); + assert_eq!(same_frame.change_cause, egui::output::ImeChangeCause::InputMethod); +} + +/// An application edit anywhere in the frame dominates an IME commit in that same frame. +#[test] +fn mixed_application_and_ime_edits_report_other() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "a"})).unwrap(); + h.run_steps(2); + // Both events belong to one pass. `Harness::event` queues one frame per event, so put them + // in a single RawInput instead. + h.input_mut().events.push(Event::Text("x".into())); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("y".into()))); + h.run_steps(1); + let ime = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(title_text(h.state()), "axy"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!(payload.text, "axy"); +} + +/// An empty commit is a no-op: it must not claim an IME mutation. An empty body is still an +/// available surrounding context, not `Unavailable`. +#[test] +fn no_op_commit_stays_other_and_empty_text_is_available() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + let empty = h.output().platform_output.ime.clone().unwrap(); + let payload = empty.surrounding_text.available().expect("an empty editable body is available"); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + + h.event(Event::Ime(ImeEvent::Commit(String::new()))); + h.run_steps(1); + let no_op = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(no_op.change_cause, egui::output::ImeChangeCause::Other); + assert!(no_op.surrounding_text.available().is_some_and(|p| p.text.is_empty())); +} + +/// A no-op delete is not a mutation: a real commit in the same `RawInput` is still an accepted +/// IME edit, not `Other` caused by a poisoned frame. +#[test] +fn no_op_delete_does_not_poison_a_commit_in_the_same_frame() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "abc"})).unwrap(); + h.run_steps(2); + + h.input_mut().events.push(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 0, after_chars: 0 })); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("X".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcX"); + let ime = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the no-op did not poison the real commit"); +} + +/// An empty commit is likewise a no-op; a real commit later in the same frame remains the +/// accepted input-method mutation. +#[test] +fn empty_commit_does_not_poison_a_commit_in_the_same_frame() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.insert", &json!({"text": "ab"})).unwrap(); + h.run_steps(2); + + h.input_mut().events.push(Event::Ime(ImeEvent::Commit(String::new()))); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abY"); + let ime = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the empty commit did not poison the real commit"); +} + +/// A selection wider than the surrounding-text budget is published as `Unavailable`. A real +/// deletion from that state must still be certified through the unavailable revision, including +/// when the selection stays unavailable afterwards. +#[test] +fn wide_selection_delete_is_certified_with_the_unavailable_revision() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.set", &json!({"id": title, "text": "a".repeat(4500)})).unwrap(); + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 4500]})).unwrap(); + h.run_steps(2); + let before = h.output().platform_output.ime.clone().unwrap(); + assert!(before.surrounding_text.available().is_none(), "the wide selection is unavailable"); + + h.input_mut().events.push(Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 })); + h.run_steps(1); + assert_eq!(title_text(h.state()).len(), 4499, "one character before the selection was deleted"); + let after = h.output().platform_output.ime.clone().unwrap(); + assert!(after.surrounding_text.available().is_none(), "the selection is still wider than the budget"); + assert_eq!(after.change_cause, egui::output::ImeChangeCause::InputMethod, "the unavailable revision certifies the deletion"); +} + +/// A commit that replaces a wide (unavailable) selection produces available text; the mixed +/// unavailable -> available transition needs the before revision to be certified. +#[test] +fn wide_selection_commit_is_certified_with_the_unavailable_revision() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.state_mut().session.execute("text.set", &json!({"id": title, "text": "a".repeat(4500)})).unwrap(); + h.state_mut().session.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 4500]})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.as_ref().unwrap().surrounding_text.available().is_none()); + + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aY", "the selected tail is replaced"); + let ime = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(ime.surrounding_text.available().unwrap().text, "aY"); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod, "the unavailable revision certifies the commit"); +} + +/// The surrounding window is bounded even in a large body, and switching the edited target +/// starts a new owner/generation once. +#[test] +fn surrounding_text_is_bounded_and_target_replacement_resets_generation() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + let long = "a".repeat(4500); + h.state_mut().session.execute("text.set", &json!({"id": title, "text": long})).unwrap(); + h.state_mut().session.execute("text.move", &json!({"to": "end"})).unwrap(); + h.run_steps(2); + let ime = h.output().platform_output.ime.clone().unwrap(); + let payload = ime.surrounding_text.available().unwrap(); + assert!(payload.text.len() <= 4000, "{}", payload.text.len()); + assert_eq!(payload.cursor, payload.text.len()); + let first = (ime.owner, ime.context_generation); + + // A selection wider than the protocol bound is explicitly unavailable, not truncated. + h.state_mut().session.execute("text.selectAll", &json!({})).unwrap(); + h.run_steps(1); + let wide = h.output().platform_output.ime.clone().unwrap(); + assert!(wide.surrounding_text.available().is_none(), "oversized selection is Unavailable"); + + h.state_mut().run("text.exit", json!({})).unwrap(); + let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [20, 20, 200, 80], "text": "other"})).unwrap(); + let other = r["id"].as_u64().unwrap(); + h.state_mut().run("text.edit", json!({"id": other})).unwrap(); + h.run_steps(2); + let ime = h.output().platform_output.ime.clone().unwrap(); + assert_ne!((ime.owner, ime.context_generation), first, "a new target is a new context"); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("other", 5, 5)); +} + +/// With no target, the custom owner is hidden: it observes `None` and publishes no descriptor. +#[test] +fn hidden_canvas_owner_observes_none() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + let first = h.output().platform_output.ime.clone().unwrap(); + h.state_mut().run("text.exit", json!({})).unwrap(); + h.state_mut().run("edit.select", json!({"ids": []})).unwrap(); + h.run_steps(1); + assert!(h.output().platform_output.ime.is_none(), "nothing to type into: no custom owner"); + + // Re-focusing the same shape starts one new generation after the hidden pass. + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + let again = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(again.owner, first.owner); + assert_ne!(again.context_generation, first.context_generation); +} + +/// A platform pre-edit or commit queued in the same pass as the application settling the +/// composition (a plain click on another shape) must not be typed into the new target: the +/// settled text stays in the body it was composed in, and the platform's copy is interrupted. +/// Once that interrupt is published, an event the OS still queues is indistinguishable from a +/// legitimate new composition (egui's event payload has no generation), so only the settling +/// batch can be rejected deterministically. +/// +/// The pre-edit is plain ASCII on purpose: borrowing a CJK face installs fonts and discards the +/// pass, which would hide the one pass the interruption is published in. +#[test] +fn a_settling_batch_cannot_land_an_ime_event_in_the_next_target() { + let mut h = harness(Session::with_new()); + let a = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + let r = h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [400, 400, 240, 120], "text": "Confidential draft"})).unwrap(); + let b = r["id"].as_u64().unwrap() as u32; + h.state_mut().run("text.exit", json!({})).unwrap(); + h.state_mut().session.execute("text.set", &json!({"id": a, "text": "Quarterly results"})).unwrap(); + h.state_mut().run("text.edit", json!({"id": a})).unwrap(); + h.run_steps(2); + + // An input-method pre-edit in A. + h.event(preedit("nihon")); + h.run_steps(2); + assert!(h.state().session.active().unwrap().composition.is_some()); + + // A plain press on B settles the composition (PowerPoint does the same) and selects B. Its + // queued pre-edit and commit travel in the same input batch as the settling pass. + let press = deckcraft_engine::tools::PointerEvent { kind: deckcraft_engine::tools::PointerKind::Down, x: 500.0, y: 450.0, ..Default::default() }; + h.state_mut().session.pointer(press).unwrap(); + h.input_mut().events.push(preedit("nihon")); + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("nihon".into()))); + h.run_steps(1); + let st = h.state().session.active().unwrap(); + assert!(st.composition.is_none(), "the click settles the composition"); + assert_eq!(st.selection.shapes, vec![deckcraft_model::ShapeId(b)], "B is the new target"); + assert!(st.selection.text.is_none(), "the queued IME events did not start editing B"); + assert_eq!(text_of(&h, a), "Quarterly resultsnihon", "the settled pre-edit stays in A"); + assert_eq!(text_of(&h, b), "Confidential draft", "B's whole text is not replaced"); + let ime = h.output().platform_output.ime.clone().expect("B is a typing target"); + assert!(ime.should_interrupt_composition, "the platform's copy is cancelled, not committed back"); + assert!(h.state().ime_cancelled.is_none(), "the settled pass published the interrupt boundary"); +} + +/// The extra document window is a rendered image, not a second text target: the canvas owner +/// stays in the editing viewport, and closing the last presentation really releases it. +#[test] +fn a_second_window_keeps_the_canvas_owner_and_closing_the_last_document_tears_it_down() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + let first = h.output().platform_output.ime.clone().unwrap(); + assert_eq!(h.state().ime_viewport, Some(egui::ViewportId::ROOT), "the canvas owner lives in the editing viewport"); + + h.state_mut().run("view.newWindow", json!({"on": true})).unwrap(); + h.run_steps(2); + let second = h.output().platform_output.ime.clone().expect("the canvas still owns the input method"); + assert_eq!( + (second.owner, second.context_generation), + (first.owner, first.context_generation), + "the render-only window neither owns nor disturbs the input method" + ); + assert_eq!(h.state().ime_viewport, Some(egui::ViewportId::ROOT)); + h.state_mut().run("view.newWindow", json!({"on": false})).unwrap(); + h.run_steps(1); + + h.state_mut().run("file.close", json!({})).unwrap(); + h.run_steps(1); + assert!(h.state().session.active().is_none(), "the last presentation closed"); + assert_eq!(h.state().ime_viewport, None, "its viewport's IME state is released"); + assert!(h.output().platform_output.ime.is_none()); +} + +/// The text of the shape `id` in the active presentation. +fn text_of(h: &egui_kittest::Harness<'static, SlideApp>, id: u32) -> String { + h.state() + .session + .active() + .and_then(|d| d.shape(deckcraft_model::ShapeId(id))) + .and_then(|sh| sh.text.as_ref()) + .map(deckcraft_model::text::TextBody::text) + .unwrap_or_default() +} + +/// Open the title editor on `abcd`, select the text, and return the caret point used for +/// pointer interruption. +fn pending_terminal_editor() -> (egui_kittest::Harness<'static, SlideApp>, egui::Pos2) { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.event(Event::Text("abcd".into())); + h.run_steps(1); + h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + h.run_steps(1); + let point = h.output().platform_output.ime.as_ref().expect("the actual canvas caret1").cursor_rect.center(); + assert!(h.state().canvas_rect.unwrap().contains(point)); + h.state_mut().run("text.select", json!({"anchor": [0, 4], "caret": [0, 4]})).unwrap(); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcd"); + (h, point) +} + +/// Clear a pending pre-edit and assert that the visible text and undo length are unchanged and +/// no platform interrupt was requested. +fn clear_pending_preedit(h: &mut egui_kittest::Harness<'_, SlideApp>) { + let undo = undo_len(h.state()); + h.event(preedit("Q")); + h.run_steps(1); + assert!(h.state().session.active().unwrap().composition.is_some()); + h.event(preedit("")); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcd", "the visible preedit was restored, not committed"); + assert!(h.state().session.active().unwrap().composition.is_none()); + assert_eq!(undo_len(h.state()), undo); + assert!(!h.output().platform_output.ime.as_ref().unwrap().should_interrupt_composition, "empty preedit is not a platform reset"); +} + +/// Build the terminal `DeleteSurrounding` event in character or byte units. +fn pending_terminal_delete(bytes: bool) -> Event { + Event::Ime(if bytes { + ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 } + } else { + ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 } + }) +} + +/// A pointer interruption after clearing pre-edit rejects both later `DeleteSurrounding` and +/// commit forms. +#[test] +fn cleared_preedit_pointer_interruption_rejects_later_delete_and_terminal_commit() { + for bytes in [false, true] { + for split_terminal in [false, true] { + let (mut h, point) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let undo = undo_len(h.state()); + let doc = h.state().session.active().unwrap().doc.clone(); + h.input_mut().events.extend([ + Event::PointerMoved(point), + Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + ]); + h.run_steps(1); + let reset = published_reset(&h); + let t = h.state().session.active().unwrap().selection.text.as_ref().expect("the real pointer kept text editing"); + assert_eq!((t.anchor, t.caret), ((0, 1), (0, 1)), "the actual pointer moved the caret"); + h.input_mut().events.push(Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); // Publishing an interrupt and idle passes do not deliver the terminal. + h.input_mut().events.push(pending_terminal_delete(bytes)); + if split_terminal { + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcd", "the separately delivered old deletion is refused"); + } + h.input_mut().events.extend([Event::Ime(ImeEvent::Commit("X".into())), reset_completed(reset), preedit("Y")]); + h.run_steps(1); + // Old terminal is refused, the exact completion admits the suffix in that same batch. + assert_eq!(title_text(h.state()), "aYbcd"); + assert!( + !h.output().platform_output.ime.as_ref().unwrap().should_interrupt_composition, + "the observed old terminal must not reset the new composition" + ); + assert_eq!(undo_len(h.state()), undo, "neither stale input nor new preedit creates undo"); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(title_text(h.state()), "abcd", "only the fresh commit needs undo"); + // Before the new preedit the stale terminal must not have changed the presentation; + // the fresh composition's own undo snapshot is exactly the original document. + assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc)); + } + } +} + +/// A key interruption after clearing pre-edit rejects a terminal event delivered in a different +/// batch. +#[test] +fn cleared_preedit_key_interruption_rejects_a_terminal_in_a_later_batch() { + for bytes in [false, true] { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let undo = undo_len(h.state()); + h.key_down(egui::Key::ArrowLeft); + h.run_steps(1); + let reset = published_reset(&h); + h.key_up(egui::Key::ArrowLeft); + h.run_steps(2); + h.input_mut().events.extend([pending_terminal_delete(bytes), Event::Ime(ImeEvent::Commit("X".into()))]); + h.run_steps(1); + let t = h.state().session.active().unwrap().selection.text.as_ref().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 3), (0, 3))); + assert_eq!(title_text(h.state()), "abcd"); + assert_eq!(undo_len(h.state()), undo); + h.input_mut().events.extend([reset_completed(reset), Event::Ime(ImeEvent::Commit("Y".into()))]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcYd", "the exact completion admits the later direct commit"); + } +} + +/// Replacing the editing target rejects stale terminal mutations from the old composition. +#[test] +fn cleared_preedit_target_replacement_rejects_later_terminal_mutations() { + for bytes in [false, true] { + let (mut h, _) = pending_terminal_editor(); + let first = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + let second = h.state().session.active().unwrap().doc.slides[0].shapes[1].id.0; + h.state_mut().run("text.edit", json!({"id": second})).unwrap(); + h.event(Event::Text("WXYZ".into())); + h.run_steps(1); + h.state_mut().run("text.edit", json!({"id": first, "at": [0, 4]})).unwrap(); + h.run_steps(1); + clear_pending_preedit(&mut h); + let undo = undo_len(h.state()); + let doc = h.state().session.active().unwrap().doc.clone(); + // Actual production target-selection command, in a separate pass before the terminal. + h.state_mut().run("text.edit", json!({"id": second, "at": [0, 1]})).unwrap(); + h.run_steps(1); + let reset = published_reset(&h); + h.run_steps(2); + h.event(pending_terminal_delete(bytes)); + h.run_steps(1); + h.event(Event::Ime(ImeEvent::Commit("X".into()))); + h.run_steps(1); + assert!(std::sync::Arc::ptr_eq(&doc, &h.state().session.active().unwrap().doc), "neither old mutation can edit the replacement target"); + assert_eq!(title_text(h.state()), "abcd"); + assert_eq!(undo_len(h.state()), undo); + let t = h.state().session.active().unwrap().selection.text.as_ref().unwrap(); + assert_eq!((t.shape.0, t.anchor, t.caret), (second, (0, 1), (0, 1))); + h.input_mut().events.extend([reset_completed(reset), preedit("Y")]); + h.run_steps(1); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + let body = h.state().session.active().unwrap().doc.slides[0].shapes[1].text.as_ref().unwrap(); + assert_eq!(body.paragraphs[0].text(), "WYXYZ", "a fresh composition uses only the new target"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.active().unwrap().doc.slides[0].shapes[1].text.as_ref().unwrap().paragraphs[0].text(), "WXYZ"); + } +} + +/// Closing the last document clears pending terminal state, and a new document needs no +/// stale-composition fence. +#[test] +fn closing_the_last_document_clears_a_pending_preedit_terminal() { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + assert!(h.state().ime_terminal_pending.is_some()); + + h.state_mut().run("file.close", json!({})).unwrap(); + assert!(h.state().session.active().is_none(), "the last presentation closed"); + // An interruption observed after the document disappeared has no destination at all. It must + // stay distinct from "not interrupted yet", which would compare against the retired origin + // forever and request avoidable resets after a new document returns. + let ctx = h.ctx.clone(); + h.state_mut().note_settled_composition(&ctx); + let pending = h.state().ime_terminal_pending.as_ref().expect("the cleared-preedit terminal is still guarded"); + assert!(pending.cancelled && pending.interrupted); + assert!(pending.interrupted_at.is_none(), "the interruption has no active document destination"); + + h.run_steps(1); + assert!(h.state().ime_terminal_pending.is_none(), "document teardown releases the terminal guard"); + assert!(h.state().ime_composition.is_none()); + assert!(h.state().ime_cancelled.is_none()); + assert!(!h.state().ime_interrupt_pending); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); + + // Its dead-document terminal must not force a reset in a new presentation's editor. + h.state_mut().run("file.new", json!({})).unwrap(); + h.run_steps(1); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear), "the new document's first target needs no stale-composition fence"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); +} + +/// After a plain clear, both character- and byte-count terminal deletes are accepted. +#[test] +fn cleared_preedit_without_interruption_accepts_both_terminal_delete_forms() { + for bytes in [false, true] { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + h.event(pending_terminal_delete(bytes)); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abc"); + h.event(Event::Ime(ImeEvent::Commit("X".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcX", "the accepted deletion's caret shift does not stale its commit"); + assert!(h.state().ime_terminal_pending.is_none()); + } +} + +/// A clear accepts its own commit, then permits a direct commit and a fresh composition without +/// stale state. +#[test] +fn cleared_preedit_accepts_its_commit_then_direct_and_fresh_compositions() { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + h.event(Event::Ime(ImeEvent::Commit("X".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcdX"); + assert!(h.state().ime_terminal_pending.is_none()); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcdXY", "a direct commit has no old owner"); + h.event(preedit("Z")); + h.run_steps(1); + h.event(Event::Ime(ImeEvent::Commit("Z".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcdXYZ"); +} + +// --------------------------------------------------------------------------- +// Token-bound IME reset adoption (revision02): opaque id + ordered completion. +// --------------------------------------------------------------------------- + +/// Build the ordered reset-completion event for a request id. +fn reset_completed(id: egui::ImeResetId) -> Event { + Event::ImeResetCompleted { request_id: id } +} + +/// Return the custom descriptor's currently published reset id, asserting that a token-bound +/// request exists. +fn published_reset(h: &egui_kittest::Harness<'static, SlideApp>) -> egui::ImeResetId { + h.output().platform_output.ime.as_ref().and_then(|ime| ime.reset_request).expect("the custom descriptor carries a token-bound reset request") +} + +/// Read the reset phase recorded for the root viewport. +fn root_phase(h: &egui_kittest::Harness<'static, SlideApp>) -> crate::ImeResetPhase { + h.state().ime_reset_for(egui::ViewportId::ROOT) +} + +/// Match the backend contract: the active viewport must have an entry in RawInput. +fn viewport_input(viewport: egui::ViewportId) -> egui::RawInput { + let mut input = egui::RawInput { viewport_id: viewport, ..Default::default() }; + input.viewports.insert(viewport, egui::ViewportInfo { parent: Some(egui::ViewportId::ROOT), ..Default::default() }); + input +} + +/// Read the reset phase for an arbitrary viewport, defaulting to `Clear` when none is stored. +fn phase_at(h: &egui_kittest::Harness<'static, SlideApp>, viewport: egui::ViewportId) -> crate::ImeResetPhase { + h.state().ime_reset_for(viewport) +} + +#[derive(Debug, PartialEq, Eq)] +struct ImeStateSnapshot { + composition: Option<(egui::Id, u64, bool)>, + terminal: Option<(egui::Id, u64, (usize, usize), (usize, usize), bool, bool, Option)>, + cancelled: Option<(egui::Id, u64)>, + generation: u64, + settles_seen: u64, + interrupt_pending: bool, + viewport: Option, + frame_viewport: Option, + reset_entries: usize, +} + +/// Every piece of the root composition lifetime that the per-viewport reset map does not own. +fn ime_state(h: &egui_kittest::Harness<'static, SlideApp>) -> ImeStateSnapshot { + let app = h.state(); + ImeStateSnapshot { + composition: app.ime_composition.map(|c| (c.owner, c.generation, c.settled)), + terminal: app.ime_terminal_pending.as_ref().map(|p| (p.owner, p.revision, p.anchor, p.caret, p.cancelled, p.interrupted, p.interrupted_at)), + cancelled: app.ime_cancelled.map(|c| (c.owner, c.generation)), + generation: app.ime_generation, + settles_seen: app.ime_settles_seen, + interrupt_pending: app.ime_interrupt_pending, + viewport: app.ime_viewport, + frame_viewport: app.ime_frame_viewport, + reset_entries: app.ime_reset.len(), + } +} + +/// Put the title editor into a settled composition and return the actual opaque request id. +fn settled_title_reset(h: &mut egui_kittest::Harness<'static, SlideApp>) -> egui::ImeResetId { + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + if h.state().session.active().unwrap().selection.text.is_none() { + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(2); + } + h.event(preedit("日")); + h.run_steps(1); + h.key_down(egui::Key::End); + h.run_steps(1); + let id = published_reset(h); + h.key_up(egui::Key::End); + h.run_steps(1); + id +} + +/// Only the exact ordered completion releases the fence; a fresh suffix survives while a stale +/// prefix is discarded. +#[test] +fn ordered_reset_completion_is_exact_and_fresh_suffix_survives_stale_prefix() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + // Foreign completion and a fresh preedit in the same raw batch: inert, blocked. + let foreign = egui::Context::default().request_ime_reset(egui::ViewportId::ROOT).unwrap(); + h.input_mut().events.extend([reset_completed(foreign), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "foreign completion cannot release the guard"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + // A stale old commit is rejected while the fence is still Awaiting. + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("old".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "the stale prefix is rejected"); + + // The exact completion in one batch, then a fresh suffix in the NEXT batch. + h.input_mut().events.push(reset_completed(r1)); + h.run_steps(1); + h.input_mut().events.push(preedit("Y")); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y", "the exact completion admits the next-batch suffix"); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); + + // A second interruption gets a newer id; completion plus the suffix may share one batch. + h.event(preedit("Z")); + h.run_steps(1); + h.key_down(egui::Key::End); + h.run_steps(1); + let r2 = published_reset(&h); + h.key_up(egui::Key::End); + h.run_steps(1); + assert_ne!(r1, r2, "another interruption supersedes the previous fence"); + h.input_mut().events.extend([reset_completed(r2), preedit("W")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日YZW", "the exact new completion admits the same-batch suffix"); +} + +/// Waiting frames without the exact completion emit no replacement token and keep input blocked. +#[test] +fn no_completion_fails_closed_and_unchanged_waiting_frames_do_not_emit_new_requests() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + for _ in 0..3 { + h.input_mut().events.extend([Event::Ime(ImeEvent::Commit("X".into())), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "without a completion no IME mutation is admitted"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); + } + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + h.input_mut().events.extend([reset_completed(r1), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y", "the exact completion is the only release"); +} + +/// A discarded final `None` suspends the guard; returning to the same editor publishes a new id. +#[test] +fn discarded_final_none_suspends_and_the_same_editor_rearms_with_a_new_id() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("about")); + h.ctx.request_discard("final IME descriptor is absent on this discarded pass"); + h.run_steps(1); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), + "the discarded final-none pass retires R1 locally: {:?}", + root_phase(&h) + ); + assert!(h.output().platform_output.ime.is_none()); + + h.state_mut().dialog = None; + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2, "returning to the same caret publishes one replacement"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r2)); + + // A late completion for the retired request is inert. + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "the retired R1 cannot release R2"); + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); +} + +/// A real modal without a discarded pass suspends the guard and publishes no speculative token. +#[test] +fn actual_modal_without_discard_suspends_and_queues_no_token() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + + // A normal About pass, no request_discard: the modal is an explicitly ineligible route even + // though the canvas is painted behind it. + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("about")); + h.run_steps(2); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), + "the modal retires R1 without allocating: {:?}", + root_phase(&h) + ); + assert!(h.output().platform_output.ime.is_none(), "the modal publishes no custom descriptor"); + + // Force a stock descriptor while the modal stays open. A token queued on the modal pass would + // be attached to this descriptor by egui; there must be none. + let notes = egui::Id::unique("notes_edit"); + h.ctx.memory_mut(|m| m.request_focus(notes)); + h.run_steps(1); + let stock = h.output().platform_output.ime.as_ref().expect("the notes field publishes its own descriptor"); + assert!(stock.reset_request.is_none(), "the modal pass did not queue a custom token for stock output"); + h.ctx.memory_mut(|m| m.surrender_focus(notes)); + + // Returning to the custom editor publishes exactly one replacement, and only its exact + // completion admits the suffix. + h.state_mut().dialog = None; + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2); + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "the retired modal-pass R1 stays inert"); + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); +} + +/// A modal-owned observe/finalizer pass rejects a matching custom descriptor before allocating a +/// token. +#[test] +fn modal_observe_and_finalizer_reject_a_matching_custom_descriptor_before_allocation() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("about")); + h.state_mut().set_ime_reset( + egui::ViewportId::ROOT, + crate::ImeResetPhase::Suspended { viewport: egui::ViewportId::ROOT, needs_rearm: true, request: None }, + ); + + let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(1.0, 18.0)); + h.state_mut().ime_pending = Some(rect); + let ctx = h.ctx.clone(); + ctx.output_mut(|o| o.ime = None); + assert!(h.state_mut().observe_ime(&ctx).is_none(), "a pending canvas rect is hidden by the modal"); + assert!(ctx.output(|o| o.ime.is_none()), "the hidden custom owner is not republished"); + + let owner = h.state().ime_target().expect("the title is still a custom target").owner; + let key = crate::ImeDescriptorKey::new(owner, 1, egui::IMEPurpose::Normal); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner, + context_generation: 1, + purpose: egui::IMEPurpose::Normal, + rect, + cursor_rect: rect, + should_interrupt_composition: false, + reset_request: None, + change_cause: egui::output::ImeChangeCause::Other, + surrounding_text: egui::output::IMESurroundingText::Unavailable, + }); + }); + h.state_mut().finalize_ime_reset(&ctx, Some(key)); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), + "the finalizer rejects a matching custom descriptor while the modal owns the route: {:?}", + root_phase(&h) + ); + assert!(ctx.output(|o| o.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())), "no token was added to the modal output"); +} + +/// Opening, Backstage, palette, and modal routes all report that the custom canvas cannot own +/// input. +#[test] +fn opening_backstage_palette_and_modal_routes_are_explicitly_ineligible() { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + assert!(h.state().ime_custom_canvas_route(&h.ctx), "the plain canvas editor is eligible"); + + h.state_mut().opening = Some(crate::opening::Opening::for_test("deck.pptx")); + assert!(!h.state().ime_custom_canvas_route(&h.ctx), "the opening overlay hides the canvas"); + h.state_mut().opening = None; + + h.state_mut().open_backstage(crate::backstage::Page::Home); + assert!(!h.state().ime_custom_canvas_route(&h.ctx), "Backstage hides the canvas"); + h.state_mut().close_backstage(); + + h.state_mut().palette = Some((String::new(), 0)); + assert!(!h.state().ime_custom_canvas_route(&h.ctx), "the palette owns input"); + h.state_mut().palette = None; + + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("about")); + assert!(!h.state().ime_custom_canvas_route(&h.ctx), "a modal owns input"); +} + +/// Stock focused controls keep their own descriptor; Backstage/no-editor routes suspend and then +/// re-arm once on return. +#[test] +fn stock_focus_backstage_and_no_editor_routes_preserve_stock_output_and_rearm() { + // A focused stock TextEdit keeps its own descriptor; the custom fence is suspended. + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + let custom_owner = h.state().ime_target().unwrap().owner; + let notes = egui::Id::unique("notes_edit"); + h.ctx.memory_mut(|m| m.request_focus(notes)); + h.run_steps(2); + let stock = h.output().platform_output.ime.as_ref().expect("the notes field publishes its own IME descriptor"); + assert_ne!(stock.owner, custom_owner, "the stock field's descriptor was not overwritten"); + assert!(stock.reset_request.is_none(), "the finalizer did not attach a custom token to stock output"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { .. })); + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "late R1 is inert after stock focus"); + + h.ctx.memory_mut(|m| m.surrender_focus(notes)); + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2); + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y", "returning to the custom editor re-arms once"); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + + // Backstage hides the canvas and must not edit the hidden slide. + let r3 = settled_title_reset(&mut h); + h.state_mut().open_backstage(crate::backstage::Page::Home); + h.run_steps(1); + assert!(h.output().platform_output.ime.is_none(), "Backstage publishes no canvas descriptor"); + assert_eq!(title_text(h.state()), "日Y日", "hidden slide is untouched"); + h.input_mut().events.extend([reset_completed(r3), preedit("Z")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y日", "Backstage leaves the old fence suspended"); + h.state_mut().close_backstage(); + h.run_steps(1); + let r4 = published_reset(&h); + assert_ne!(r3, r4); + h.input_mut().events.extend([reset_completed(r4), preedit("Z")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y日Z", "returning from Backstage re-arms the same editor"); + h.event(Event::Ime(ImeEvent::Commit("Z".into()))); + h.run_steps(1); + + // A no-editor/multi-selection route also suspends and returns with one fresh request. + let r5 = settled_title_reset(&mut h); + h.state_mut().run("text.exit", json!({})).unwrap(); + h.state_mut().session.active_mut().unwrap().selection.shapes.clear(); + h.run_steps(1); + assert!(h.output().platform_output.ime.is_none(), "no editor means no custom descriptor"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { .. })); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title, "at": [0, 5]})).unwrap(); + h.run_steps(1); + let r6 = published_reset(&h); + assert_ne!(r5, r6); + h.input_mut().events.extend([reset_completed(r6), preedit("K")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y日Z日K"); +} + +/// Full logic+UI passes do not re-arm, while standalone logic suspends exactly once without +/// replaying input. +#[test] +fn full_logic_ui_passes_do_not_rearm_while_standalone_logic_suspends_once() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + + // The full pass runs App::logic before the UI. The mode hook must prevent the logic half + // from treating the not-yet-painted UI as absence. + h.run_steps(2); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); + + let ctx = h.ctx.clone(); + // A child viewport frame supplies the actual viewport stack for its logic callback. + let other = egui::ViewportId::from_hash_of("deckcraft test other viewport"); + ctx.run_ui(viewport_input(other), |ui| h.state_mut().logic(ui.ctx())).drop_without_applying_deltas(); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + // Standalone root logic paints no geometry: suspend exactly once, without replaying input. + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + + // The next full UI pass reacts to the actual custom descriptor and publishes one replacement. + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r2)); + h.run_steps(1); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); +} + +/// A terminal change observed only in standalone logic never queues a token for a later stock +/// route. +#[test] +fn standalone_logic_changed_terminal_never_queues_a_token_for_the_next_stock_route() { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let before = title_text(h.state()); + h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + + // A changed pending terminal reaches the interruption branch in a standalone run_logic tick. + // Logic paints no geometry, so it must suspend locally instead of putting a request into + // egui Memory; otherwise the next stock descriptor consumes the abandoned token. + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), "standalone logic allocates no token"); + assert!(h.state().ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled), "the interruption is still recorded locally"); + assert!(h.state().ime_interrupt_pending, "the settled composition still needs its platform interrupt"); + + h.state_mut().palette = Some((String::new(), 0)); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + assert!(h.output().platform_output.ime.as_ref().is_none_or(|ime| ime.reset_request.is_none())); + // TextEdit requests focus after painting; its stock descriptor is on the next frame. + h.run_steps(1); + let stock = h.output().platform_output.ime.as_ref().expect("the palette field publishes its own descriptor"); + assert!(stock.reset_request.is_none(), "the abandoned logic tick did not attach a token to stock output"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + h.input_mut().events.push(preedit("X")); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "stock input cannot edit the hidden slide"); + + // Returning to the custom editor publishes exactly one replacement for the real new caret. + h.state_mut().palette = None; + if let Some(id) = h.ctx.memory(|m| m.focused()) { + h.ctx.memory_mut(|m| m.surrender_focus(id)); + } + h.run_steps(1); + let r = published_reset(&h); + h.input_mut().events.extend([reset_completed(r), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd", "the exact replacement admits the fresh suffix at the real caret"); +} + +/// The exact completion admits a fresh suffix without admitting the old terminal commit. +#[test] +fn cleared_preedit_exact_completion_admits_fresh_suffix_without_the_old_terminal_commit() { + // Same batch: the exact completion is the platform-reset boundary; no old Commit, + // Enabled/Disabled or idle pass is needed before a new composition may start. The caret is + // the real one the fixture selected, so the oracle is `aYbcd` (not the review's illustrative + // `abcdY`, which assumed a different caret). + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let undo = undo_len(h.state()); + h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + h.run_steps(1); + let r1 = published_reset(&h); + let pending = h.state().ime_terminal_pending.as_ref().expect("the retired cleared-preedit terminal is guarded"); + assert!(pending.cancelled); + assert!(pending.interrupted_at.is_some(), "the interruption destination is stored separately from the origin"); + + h.input_mut().events.extend([reset_completed(r1), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd", "the exact completion admits the same-batch suffix"); + let t = h.state().session.active().unwrap().selection.text.as_ref().unwrap(); + assert_eq!((t.anchor, t.caret), ((0, 2), (0, 2)), "the fresh preedit advances the real caret"); + assert_eq!(undo_len(h.state()), undo, "a preedit creates no undo step"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); + assert!(h.state().ime_terminal_pending.is_none(), "the exact fence completion released the old terminal guard"); + for _ in 0..3 { + h.run_steps(1); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear), "unchanged idle passes do not request R churn"); + } + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd", "the committed suffix replaces its preedit"); + assert_eq!(undo_len(h.state()), undo + 1, "the fresh composition is exactly one undo step"); + + // Next batch: completion alone releases, then the fresh suffix arrives in a later raw batch. + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let undo = undo_len(h.state()); + h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + h.run_steps(1); + let r1 = published_reset(&h); + h.input_mut().events.push(reset_completed(r1)); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); + assert!(h.state().ime_terminal_pending.is_none()); + for _ in 0..3 { + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear), "an idle pass after the exact completion keeps it clear"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|ime| ime.reset_request.is_none())); + } + h.input_mut().events.push(preedit("Y")); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd", "the next-batch suffix is admitted"); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "aYbcd"); + assert_eq!(undo_len(h.state()), undo + 1); +} + +/// Repeating the exact completion after `Clear` changes no composition or undo state. +#[test] +fn duplicate_exact_completion_after_clear_is_inert() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + let undo = undo_len(h.state()); + + h.input_mut().events.push(reset_completed(r1)); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); + + // A duplicate after Clear does nothing, and the same batch may carry a fresh preedit. + h.input_mut().events.extend([reset_completed(r1), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); + assert_eq!(undo_len(h.state()), undo); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); + + // A duplicate old completion never clears the latest replacement either. + h.event(preedit("Z")); + h.run_steps(1); + h.key_down(egui::Key::End); + h.run_steps(1); + let r2 = published_reset(&h); + h.key_up(egui::Key::End); + h.run_steps(1); + assert_ne!(r1, r2); + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日YZ", "the duplicate old completion cannot release R2"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r2)); + h.input_mut().events.extend([reset_completed(r2), preedit("W")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日YZW"); +} + +/// The font-install early return suspends without a token, and returning to the editor re-arms +/// once. +#[test] +fn font_install_early_return_suspends_without_a_speculative_token_and_rearms_once() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + let before = title_text(h.state()); + + // Force the real ui_setup font-install early return. The outer all-exit wrapper must still + // observe the absent custom descriptor and retire R1. + h.state_mut().styled = false; + // A discarded pass is invisible to `run_ui`: it retries immediately, up to `max_passes`. + // Drive exactly one root UI pass here so the discarded pass itself can be observed, while + // keeping the normal two-pass budget so `Context::will_discard` is genuinely true + // (`num_completed_passes + 1 < max_passes`; with `max_passes == 1` it would be false and the + // finalizer would take the accepted-pass branch). This calls only `SlideApp::ui`, not + // `run_ui` or its plugin pass hooks, and not `SlideApp::logic`; the font-install early return + // under test does not depend on them. + let input = h.input_mut().take(); + let ctx = h.ctx.clone(); + ctx.begin_pass(input); + ctx.root_ui(|ui| h.state_mut().ui(ui)); + assert!(ctx.will_discard(), "the font-install pass is a discarded pass under the normal budget"); + let pass = ctx.end_pass(); + let published = pass.platform_output.ime.clone(); + pass.drop_without_applying_deltas(); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + assert!(published.is_none(), "the discarded font pass publishes no custom descriptor"); + + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2, "the late R1 is inert; returning custom paints one replacement"); + assert_eq!(title_text(h.state()), before, "the late preedit stays blocked"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r2)); + + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}Y")); +} + +/// The palette route retires the guard without a token; returning to the canvas publishes one +/// replacement. +#[test] +fn palette_route_retires_the_guard_without_a_speculative_token_and_rearms_once() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + let before = title_text(h.state()); + + h.state_mut().palette = Some((String::new(), 0)); + h.run_steps(2); + let output = h.output().platform_output.ime.as_ref(); + assert!(output.is_none_or(|output| output.reset_request.is_none()), "the finalizer never attaches a custom token to stock output"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "the palette leaves the hidden slide untouched"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { .. })); + + h.state_mut().palette = None; + if let Some(id) = h.ctx.memory(|m| m.focused()) { + h.ctx.memory_mut(|m| m.surrender_focus(id)); + } + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2); + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}Y")); +} + +/// A render-only viewport cannot mutate the root composition or bind the root reset guard. +#[test] +fn render_only_viewports_cannot_mutate_root_composition_or_bind_the_root_guard() { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + h.state_mut().run("text.select", json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap(); + h.run_steps(1); + let r1 = published_reset(&h); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + // Move the live selection without a root pass. A second window/presenter viewport renders + // through its own immediate viewport and never calls `SlideApp::logic`/`ui`; admitting such a + // callback would mutate root composition state while only the reset map is per-viewport. + h.state_mut().run("text.select", json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap(); + let before = ime_state(&h); + // `run_ui` for a child viewport runs that viewport's whole pass and returns its output; the + // harness keeps the last ROOT output. Publish nothing for the render-only viewport and leave + // the root's already-published token untouched. + let root_ime_before = h.output().platform_output.ime.clone(); + assert_eq!( + root_ime_before.as_ref().and_then(|ime| ime.reset_request), + Some(r1), + "the root descriptor legitimately still carries the published R1" + ); + let child = egui::ViewportId::from_hash_of("deckcraft test child viewport"); + let ctx = h.ctx.clone(); + let child_logic = ctx.run_ui(viewport_input(child), |ui| h.state_mut().logic(ui.ctx())); + assert!(child_logic.platform_output.ime.is_none(), "rejected child logic publishes no IME descriptor"); + child_logic.drop_without_applying_deltas(); + assert_eq!(ime_state(&h), before, "rejected child logic left every root composition field unchanged"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1), "root R1 survives the child tick"); + assert!(matches!(phase_at(&h, child), crate::ImeResetPhase::Clear), "no child guard was admitted"); + assert_eq!(h.output().platform_output.ime, root_ime_before, "the child tick does not clear or rebind the root's published token"); + + let child_ui = ctx.run_ui(viewport_input(child), |ui| h.state_mut().ui(ui)); + assert!(child_ui.platform_output.ime.is_none(), "rejected child UI publishes no IME descriptor"); + child_ui.drop_without_applying_deltas(); + assert_eq!(ime_state(&h), before, "rejected child UI left every root composition field unchanged"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + assert_eq!(h.output().platform_output.ime, root_ime_before, "the child UI does not clear or rebind the root's published token"); + + // The root guard, not a child request, is what admits the real next-batch input. + h.input_mut().events.extend([reset_completed(r1), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abYcd", "the moved real caret receives the fresh suffix"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); +} + +/// A suspended guard or request stamped for another viewport is never rebound to root. +#[test] +fn a_suspended_guard_or_request_stamped_for_another_viewport_is_never_rebound() { + let mut h = harness(Session::with_new()); + let _ = settled_title_reset(&mut h); + let child = egui::ViewportId::from_hash_of("deckcraft foreign suspended viewport"); + let child_request = h.ctx.request_ime_reset(child).expect("a real child request token"); + h.state_mut() + .ime_reset + .insert(egui::ViewportId::ROOT, crate::ImeResetPhase::Suspended { viewport: child, needs_rearm: true, request: Some(child_request) }); + h.run_steps(1); + let root_request = published_reset(&h); + assert_ne!(root_request, child_request, "a request issued for the child viewport is not rebound to root"); + assert_eq!(root_request.viewport_id(), egui::ViewportId::ROOT); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, viewport, .. } if id == root_request && viewport == egui::ViewportId::ROOT) + ); + + // A child completion delivered on the root stream is foreign and inert. + h.input_mut().events.extend([reset_completed(child_request), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "the foreign request completion cannot clear the root fence"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == root_request)); + h.input_mut().events.extend([reset_completed(root_request), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); +} + +/// Sorter and Notes Page stock routes allocate no custom reset token when a composition settles. +#[test] +fn sorter_and_notes_page_settles_never_allocate_for_a_stock_route() { + for (view, command) in [(crate::ViewMode::Sorter, "view.sorter"), (crate::ViewMode::NotesPage, "view.notesPage")] { + let mut h = harness(Session::with_new()); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(2); + assert!(h.state().ime_custom_canvas_route(&h.ctx), "the plain canvas editor is eligible"); + h.event(preedit("日")); + h.run_steps(1); + let before = title_text(h.state()); + + // Switch to a production view that paints no canvas producer. The retained text + // selection still yields an IME owner, so a negative-only admission check would let the + // settle below allocate a token for a route that never publishes a custom descriptor. + h.state_mut().run(command, json!({})).unwrap(); + assert_eq!(h.state().ui.view, view); + assert!(h.state().ime_target().is_some(), "the retained text selection still yields an owner"); + assert!(!h.state().ime_custom_canvas_route(&h.ctx), "{command} does not call canvas::show"); + + h.key_down(egui::Key::End); + h.run_steps(1); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), + "the settle retires the old guard before the finalizer; no request is allocated in input: {:?}", + root_phase(&h) + ); + h.key_up(egui::Key::End); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + + // The palette's focused TextEdit is a stock descriptor. A token allocated on the sorter + // settle would be attached here by egui. + h.state_mut().palette = Some((String::new(), 0)); + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + assert!(h.output().platform_output.ime.as_ref().is_none_or(|ime| ime.reset_request.is_none())); + // TextEdit requests focus after painting; its stock descriptor is on the next frame. + h.run_steps(1); + let stock = h.output().platform_output.ime.as_ref().expect("the palette TextEdit publishes its own descriptor"); + assert!(stock.reset_request.is_none(), "{command} did not attach a speculative token to stock output"); + h.input_mut().events.push(preedit("X")); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "stock input cannot edit the hidden canvas"); + + // Returning to the custom canvas publishes exactly one replacement, and only its exact + // completion admits the fresh suffix. + h.state_mut().palette = None; + if let Some(id) = h.ctx.memory(|m| m.focused()) { + h.ctx.memory_mut(|m| m.surrender_focus(id)); + } + h.state_mut().run("view.normal", json!({})).unwrap(); + h.run_steps(1); + let reset = published_reset(&h); + h.input_mut().events.extend([reset_completed(reset), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}Y"), "returning to the canvas re-arms once"); + h.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}Y")); + } +} + +/// A same-pass switch from custom canvas to stock palette retires the guard without publishing a +/// token. +#[test] +fn same_pass_custom_to_stock_palette_retires_the_guard_without_a_new_token() { + let mut h = harness(Session::with_new()); + let r1 = settled_title_reset(&mut h); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == r1)); + + let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(1.0, 18.0)); + let ctx = h.ctx.clone(); + let mut custom = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let ctx = ui.ctx(); + h.state_mut().ime_frame_viewport = Some(egui::ViewportId::ROOT); + // A pre-UI interruption in a full pass must retire the guard locally, not allocate. + // The old implementation allocated here because the canvas route looked available. + h.state_mut().note_ime_interruption(ctx, true); + assert!(matches!(h.state().ime_reset_for(egui::ViewportId::ROOT), crate::ImeResetPhase::Suspended { request: None, .. })); + + h.state_mut().ime_pending = Some(rect); + // The canvas producer ran first and published a matching custom descriptor this pass. + custom = h.state_mut().observe_ime(ctx); + assert!(custom.is_some(), "the canvas's descriptor is eligible"); + + // Later in the same pass the command palette opens and its TextEdit owns IME output. + // Replace the pass output with that stock descriptor before the finalizer runs. + h.state_mut().palette = Some((String::new(), 0)); + let stock_owner = egui::Id::unique("same-pass-stock-palette"); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: stock_owner, + context_generation: 1, + purpose: egui::IMEPurpose::Normal, + rect, + cursor_rect: rect, + should_interrupt_composition: false, + reset_request: None, + change_cause: egui::output::ImeChangeCause::Other, + surrounding_text: egui::output::IMESurroundingText::Unavailable, + }); + }); + h.state_mut().finalize_ime_reset(ctx, custom); + }); + // Read after `end_pass`: this is where egui would attach a request left in Memory to the + // pass's stock descriptor. It must be absent. + let stock_reset = output.platform_output.ime.as_ref().and_then(|ime| ime.reset_request); + output.drop_without_applying_deltas(); + assert!(stock_reset.is_none(), "the pre-UI interruption did not leave a token for the stock descriptor"); + assert!( + matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. }), + "the custom->stock pass retires R1 without allocating: {:?}", + root_phase(&h) + ); + + // A full palette pass must not publish R1 or any replacement into the stock field. + h.run_steps(1); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Suspended { request: None, .. })); + assert!(h.output().platform_output.ime.as_ref().is_none_or(|ime| ime.reset_request.is_none())); + // TextEdit requests focus after painting; its stock descriptor is on the next frame. + h.run_steps(1); + let stock = h.output().platform_output.ime.as_ref().expect("the palette TextEdit publishes its own descriptor"); + assert!(stock.reset_request.is_none()); + assert!(!h.state().ime_custom_canvas_route(&h.ctx)); + + h.state_mut().palette = None; + if let Some(id) = h.ctx.memory(|m| m.focused()) { + h.ctx.memory_mut(|m| m.surrender_focus(id)); + } + h.run_steps(1); + let r2 = published_reset(&h); + assert_ne!(r1, r2, "returning to the canvas requests one replacement"); + h.input_mut().events.extend([reset_completed(r1), preedit("X")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日", "the retired custom R1 stays inert"); + h.input_mut().events.extend([reset_completed(r2), preedit("Y")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), "日Y"); +} + +/// Closing and creating a deck before a pass keeps the old terminal fence until its exact reset +/// completion. +#[test] +fn close_new_then_edit_before_a_pass_keeps_the_old_terminal_fence_until_exact_reset() { + let (mut h, _) = pending_terminal_editor(); + clear_pending_preedit(&mut h); + let old_owner = h.state().ime_target().unwrap().owner; + let old_uid = h.state().session.active().unwrap().uid; + let pending = h.state().ime_terminal_pending.as_ref().expect("the cleared-preedit terminal is guarded"); + assert_eq!(pending.owner, old_owner); + assert!(!pending.cancelled); + + // Replace the active document and enter its first editor without an intervening pass + // observing `None`. Blindly clearing on the uid change would let the old platform suffix + // land in the new document, so the old terminal must stay bound to its old owner. + h.state_mut().run("file.close", json!({})).unwrap(); + h.state_mut().run("file.new", json!({})).unwrap(); + let new_uid = h.state().session.active().unwrap().uid; + assert_ne!(old_uid, new_uid); + let title = h.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + h.state_mut().run("text.edit", json!({"id": title})).unwrap(); + h.run_steps(1); + + let pending = h.state().ime_terminal_pending.as_ref().expect("the old terminal survives the document replacement"); + assert_eq!(pending.owner, old_owner, "the terminal is still bound to the retired composition's owner"); + assert!(pending.cancelled && pending.interrupted); + let reset = published_reset(&h); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == reset)); + let before = title_text(h.state()); + let undo = undo_len(h.state()); + + // The old composition's suffix arrives before its terminal commit: no preedit may enter the + // new document's editor, and the old terminal guard must still be live. + h.input_mut().events.push(preedit("F")); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "a stale old preedit is rejected"); + assert_eq!(undo_len(h.state()), undo); + assert!(h.state().ime_terminal_pending.as_ref().is_some_and(|p| p.owner == old_owner && p.cancelled)); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == reset)); + + // The old terminal commit is rejected and consumed as a terminal event. The new document's + // reset fence remains Awaiting: terminal delivery is not the platform-reset boundary. + h.input_mut().events.push(Event::Ime(ImeEvent::Commit("F".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "the old terminal commit is rejected"); + assert_eq!(undo_len(h.state()), undo); + assert!(h.state().ime_terminal_pending.is_none(), "the delivered old terminal no longer needs a guard"); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Awaiting { id, .. } if id == reset)); + + // Another stale suffix still cannot cross the new document's reset fence. + h.input_mut().events.push(preedit("F")); + h.run_steps(1); + assert_eq!(title_text(h.state()), before, "the reset fence, not just the terminal, blocks the old suffix"); + + // Only the exact completion of the new document's fence releases custom input. The latest + // new composition then gets its own preedit and commit. + h.input_mut().events.extend([reset_completed(reset), preedit("F")]); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}F"), "the fresh new-document composition is admitted"); + assert!(h.state().ime_terminal_pending.is_none()); + assert!(matches!(root_phase(&h), crate::ImeResetPhase::Clear)); + h.event(Event::Ime(ImeEvent::Commit("F".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), format!("{before}F")); + assert_eq!(undo_len(h.state()), undo + 1, "only the latest new composition is one undo step"); +} diff --git a/crates/ui-egui/src/last_color_tests.rs b/crates/ui-egui/src/last_color_tests.rs index 511a52c4..b43d3a85 100644 --- a/crates/ui-egui/src/last_color_tests.rs +++ b/crates/ui-egui/src/last_color_tests.rs @@ -44,8 +44,8 @@ fn select(h: &mut Harness<'static, SlideApp>, id: ShapeId) { /// Open the drop-down arrow of a split button, type a hex colour into "More Colors…" and press Enter. fn pick_hex(h: &mut Harness<'static, SlideApp>, button: &str, hex: &str) { - let r = h.get_by_label(button).rect(); - let arrow = egui::pos2(r.max.x - 5.0, r.center().y); + // The split button exposes its main and arrow targets separately, named for this command. + let arrow = h.get_by_role_and_label(egui::accesskit::Role::Button, &format!("{button} options")).rect().center(); h.input_mut().events.push(Event::PointerMoved(arrow)); h.run_steps(2); for pressed in [true, false] { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3f4acbb..094d7aba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod animtimeline; pub mod backstage; pub mod canvas; +pub mod capture; pub mod control; pub mod credits; pub mod dialogs; @@ -38,7 +39,7 @@ pub mod uifonts; pub mod widgets; use std::collections::HashMap; -use std::sync::mpsc::{Receiver, Sender}; +use std::sync::mpsc::Receiver; use deckcraft_engine::{Mods, Session, Target, UiRequest}; use deckcraft_model::{LayoutId, MasterId, SlideId}; @@ -56,6 +57,46 @@ pub type PickFn = Box Option>; pub type OpenAsyncFn = Box; pub type DownloadFn = Box; +/// Temp key holding whether the current viewport is inside an egui UI pass. +fn ime_in_ui_pass_key(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique(("deckcraft-ime-in-ui-pass", viewport)) +} + +/// Stable key marking that the IME pass hooks are installed. +fn ime_pass_hooks_key() -> egui::Id { + egui::Id::unique(("deckcraft-ime-pass-hooks", "installed")) +} + +/// Install the per-viewport UI-pass mode hooks exactly once for this [`egui::Context`]. +/// +/// Call this before the context's first `run_ui` (desktop/web shells and the test harness). +/// The callbacks receive `&mut Ui`; they obtain the context through `ui.ctx()` and capture no +/// context or app reference, so they cannot form a reference cycle. The flag distinguishes a +/// full `run_ui` pass (where `App::logic` runs before the UI that owns the final output) from a +/// standalone `run_logic` callback. It is only a mode marker, not an IME publication receipt. +pub fn install_ime_pass_hooks(ctx: &egui::Context) { + if ctx.data(|d| d.get_temp::(ime_pass_hooks_key()).is_some_and(|v| v)) { + return; + } + ctx.on_begin_pass( + "deckcraft-ime-ui-pass-begin", + std::sync::Arc::new(|ui: &mut egui::Ui| { + let ctx = ui.ctx(); + let key = ime_in_ui_pass_key(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(key, true)); + }), + ); + ctx.on_end_pass( + "deckcraft-ime-ui-pass-end", + std::sync::Arc::new(|ui: &mut egui::Ui| { + let ctx = ui.ctx(); + let key = ime_in_ui_pass_key(ctx.viewport_id()); + ctx.data_mut(|d| d.insert_temp(key, false)); + }), + ); + ctx.data_mut(|d| d.insert_temp(ime_pass_hooks_key(), true)); +} + /// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`]. #[derive(Clone, Debug)] pub struct InboxFile { @@ -439,6 +480,40 @@ struct AnimPreview { timeline: deckcraft_anim::Timeline, } +/// The app state a queued synthetic control-channel input is meant for. +/// +/// A batch is injected only while this identity is unchanged. eframe carries a raw-input batch +/// from a logic-only (hidden) pass into the next full pass, so the hook also keeps the exact event +/// list it appended and retracts it when the identity no longer matches: the stale click never +/// reaches the new document, slide or canvas layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InputTarget { + documents: usize, + active: Option, + slide: Option, + view: ViewMode, + zoom: Option, + canvas: Option<[u32; 4]>, + canvas_scale: u32, + /// Dialog, palette or Backstage view over the canvas. A modal transition changes what a + /// queued window-point click means even when the document and canvas layout are unchanged. + dialog: Option, + /// Command palette query *and* highlighted entry: the keyboard target moves with the + /// selection even when the query does not. + palette: Option, + backstage: Option, + /// Active ribbon tab, whether the ribbon is collapsed, and its horizontal scroll offset. + /// A window-point click means a different button when any of them changes. + ribbon_tab: egui::Id, + ribbon_collapsed: bool, + ribbon_scroll: u32, + /// Full-screen slide show: the show instance, slide, step and the layout/mode state that + /// changes what a click at the same window point does. + show: Option, + /// Background-open progress window, identified by the file *and* the attempt. + opening: Option<(egui::Id, u64)>, +} + pub struct SlideApp { /// Shape Fill's last colour (a command colour parameter): the button applies it again. Accent 1 until one is picked. pub last_fill: serde_json::Value, @@ -470,7 +545,37 @@ pub struct SlideApp { fonts_noted: std::collections::HashSet, pub perf: Perf, /// Synthetic input events (control channel `ui.click` etc.), injected one per frame. - pub synthetic: Vec, + pub(crate) synthetic: Vec, + /// A synthetic batch has been injected but the UI pass has not acknowledged it. While this + /// is set no second batch is injected, so a hidden/discarded frame cannot replay an event. + synthetic_pending: bool, + /// Events queued while an older batch is still pending. They belong to the target at the time + /// they were queued, so a later document change cannot make them fall in behind the old batch + /// and get cleared with it. + synthetic_next: Vec, + synthetic_next_target: Option, + /// The exact events appended to the pending `RawInput`; retracted on a target change. + synthetic_injected: Vec, + /// Where `synthetic_injected` was appended in `RawInput::events`. Retraction uses this + /// index so an identical host event earlier in the batch is never removed instead. + synthetic_injected_at: Option, + /// Identity of the last injected synthetic batch (for diagnostics). + synthetic_generation: u64, + /// Document/slide/canvas identity the queued/injected batch belongs to. + synthetic_target: Option, + /// Focus identity the pending batch was injected with; a focus change retracts it. + synthetic_focus: Option, + /// `RawInput::time` of the pass the batch was injected into. A logic-only callback leaves + /// `InputState` from the previous pass; comparing this value keeps retraction from draining + /// an unrelated batch out of that stale input. + synthetic_injected_time: Option, + /// Horizontal scroll offset (as `f32` bits) of each ribbon tab, recorded for + /// [`InputTarget`]. Scroll state is per tab in egui, so the identity is too. + ribbon_scroll: HashMap, + /// Monotonic identity for a new slide show instance (`start_show`). + show_instance: u64, + /// Monotonic identity for a new background-open attempt. + opening_generation: u64, /// Canvas screen rect and points-per-slide-point scale (for control & tests). pub canvas_rect: Option, /// The Eyedropper is waiting for a click on the slide. @@ -489,14 +594,13 @@ pub struct SlideApp { pending_urls: Vec, /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame. pending_copy: Option, - shot_token: u64, - queued_shots: Vec<(u64, f64, u32)>, - pending_shots: Vec<(u64, Option, Sender, f64)>, + /// Bounded screenshot completion slots for the control channel (egui 0.37 callbacks). + capture: capture::Capture, styled: bool, restyle: bool, fonts_ready: bool, last_time: f64, - /// When AutoRecover data was last written (seconds, egui time). + /// When AutoRecover data was last written (monotonic seconds, not wall time). pub last_recovery: f64, /// Quit was confirmed (changes saved or discarded): let the window close. pub quit_confirmed: bool, @@ -526,6 +630,149 @@ pub struct SlideApp { next_file_id: u64, /// Picks and drops whose bytes have not arrived yet. pending_files: Vec, + /// Owner/generation/cause bookkeeping for the canvas text IME producer. + ime_tracker: egui::ime_context::ImeContextTracker, + /// Canvas IME geometry for this pass: the caret while editing, or the selected shape. + ime_pending: Option, + /// The viewport this UI pass belongs to (not the last immediate child viewport drawn). + ime_frame_viewport: Option, + /// Viewport whose tracker state is current. Closing the editing window or the last + /// presentation releases it (`ImeContextTracker::end_viewport`), so a later owner in that + /// viewport starts from a fresh context. + pub(crate) ime_viewport: Option, + /// The platform composition we know about: the owner its pre-edit began in and whether the + /// application has since settled it. See `ime_event`. + ime_composition: Option, + /// Empty preedit cleared visible model text, but its terminal mutation may arrive later. + ime_terminal_pending: Option, + /// A composition generation the application settled. Events queued for it in the settling + /// input batch stay stale until the pass that publishes its platform interrupt (or an accepted + /// pass with no descriptor, which resets the backend); the tombstone is retired there so later + /// commit-only input is not lost (see [`ImeCancelled`]). + ime_cancelled: Option, + /// Monotonic composition generation; every accepted pre-edit starts a new one. + ime_generation: u64, + /// The last [`Session::composition_settles`] value seen. + ime_settles_seen: u64, + /// A composition the application settled has not been announced to the platform yet. + ime_interrupt_pending: bool, + /// Token-bound reset lifetime for the owning root viewport's custom canvas editor. The map is + /// keyed by viewport so a request or completion stamped for another viewport is never bound; + /// render-only child viewports are rejected before they can create or overwrite an entry. + ime_reset: HashMap, +} + +/// The platform's in-progress IME composition, as far as this editor tracks it. +#[derive(Clone, Copy, Debug)] +struct ImeComposition { + /// The target whose text the input method started composing in. + owner: egui::Id, + /// Generation of this composition; a new pre-edit after a settled generation starts the next. + generation: u64, + /// The application settled the composition itself (a click, a key, a command). The platform + /// may still hold it and must be interrupted rather than believed. + settled: bool, +} + +/// A settled composition generation whose platform copy may still deliver the rest of the +/// input batch it was queued in. +/// +/// The tombstone lives only until the pass that publishes this generation's +/// `should_interrupt_composition` in its platform output, because that is the point at which the +/// backend resets (disables and re-enables) the platform composition. Events earlier in the same +/// batch still belong to the old composition and are rejected; from the next batch on egui's +/// events carry no generation, so an event the OS queued across the reset cannot be distinguished +/// from a legitimate new composition (see `ime_event`). +#[derive(Clone, Copy, Debug)] +struct ImeCancelled { + owner: egui::Id, + generation: u64, +} + +/// A cleared preedit still owns its separately delivered terminal delete/commit. Application +/// work invalidates this snapshot; publishing an interrupt is not delivery of that terminal. +/// Keep a cancelled record until the terminal commit (or platform enable/disable) is observed. +struct ImeTerminalPending { + owner: egui::Id, + revision: u64, + anchor: (usize, usize), + caret: (usize, usize), + cancelled: bool, + /// An interruption has been observed since this guard was recorded. This stays distinct from + /// the origin snapshot even when there is no active destination to stamp: `interrupted_at == + /// None` then means "interrupted with no target", not "not interrupted yet". + interrupted: bool, + /// The destination at which this terminal guard was last interrupted. Kept separately from + /// the retired origin above: repeated passes at the same interrupted destination must not + /// request another reset, while a genuinely newer target/selection/revision change still + /// supersedes the current fence. When `interrupted` is false, `None` means the origin snapshot + /// is still the reference; once interrupted, it means the interruption left no destination. + interrupted_at: Option, +} + +/// The live target/selection stamp used to detect a new application interruption without +/// discarding the retired composition's origin. Owner is optional because an interruption can +/// remove the canvas target entirely. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImeTerminalStamp { + owner: Option, + revision: u64, + anchor: Option<(usize, usize)>, + caret: Option<(usize, usize)>, +} + +/// The live canvas target and caret/selection stamp a reset fence is bound to. +/// +/// The retired composition's origin is deliberately absent: an app-originated interruption can +/// move the destination, and the replacement request belongs to the current destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImeDestination { + owner: egui::Id, + revision: u64, + anchor: Option<(usize, usize)>, + caret: Option<(usize, usize)>, +} + +/// The actual custom descriptor identity a reset request is allowed to acknowledge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ImeDescriptorKey { + owner: egui::Id, + context_generation: u64, + purpose: egui::IMEPurpose, +} + +impl ImeDescriptorKey { + /// Construct the exact custom-IME descriptor identity from owner, context generation, and + /// purpose. + fn new(owner: egui::Id, context_generation: u64, purpose: egui::IMEPurpose) -> Self { + Self { owner, context_generation, purpose } + } +} + +/// Explicit lifetime of the canvas's token-bound IME reset fence. +/// +/// This is separate from the composition provenance/tombstone state above. Only a matching +/// ordered `ImeResetCompleted` clears `Awaiting`; `Enabled`, `Disabled`, empty preedits, idle +/// passes and timers never do. `Suspended` retains no acceptable old id and may carry one newly +/// requested id that a later eligible pass can bind. +#[derive(Clone, Copy, Debug)] +enum ImeResetPhase { + /// No interrupted composition guard. + Clear, + /// The exact newest request has been published for this destination/descriptor. + Awaiting { id: egui::ImeResetId, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey }, + /// Guarded but no old id is acceptable; `request` is a new, not-yet-bound fence. + Suspended { viewport: egui::ViewportId, needs_rearm: bool, request: Option }, + /// No fence could be allocated; remain fail-closed without retrying on idle output. + AllocationFailed { viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey }, +} + +impl ImeResetPhase { + /// Only `Clear` admits custom-canvas input; awaiting, suspended, and allocation-failed phases + /// remain fail-closed. + fn blocks_custom_input(self) -> bool { + !matches!(self, Self::Clear) + } } fn grid_options_touched(p: &Value) -> bool { @@ -541,6 +788,8 @@ fn grid_flag(p: &Value, key: &str) -> Result, String> { } impl SlideApp { + /// Build application UI state, taking ownership of audio output and initialising media, dialogs, + /// synthetic input, and timing fields. pub fn new(session: Session, mut services: Services) -> Self { let audio_out = services.audio_out.take(); SlideApp { @@ -564,6 +813,18 @@ impl SlideApp { fonts_noted: Default::default(), perf: Perf { frame_ms: 0.0, render_ms: 0.0, fps: 0.0 }, synthetic: vec![], + synthetic_pending: false, + synthetic_next: vec![], + synthetic_next_target: None, + synthetic_injected: vec![], + synthetic_injected_at: None, + synthetic_generation: 0, + synthetic_target: None, + synthetic_focus: None, + synthetic_injected_time: None, + ribbon_scroll: HashMap::new(), + show_instance: 0, + opening_generation: 0, canvas_rect: None, eyedropper: None, canvas_scale: 1.0, @@ -574,9 +835,7 @@ impl SlideApp { backstage: None, pending_urls: vec![], pending_copy: None, - shot_token: 0, - queued_shots: vec![], - pending_shots: vec![], + capture: capture::Capture::default(), styled: false, restyle: true, fonts_ready: false, @@ -596,6 +855,17 @@ impl SlideApp { twisting: false, next_file_id: 1, pending_files: Vec::new(), + ime_tracker: egui::ime_context::ImeContextTracker::new(), + ime_pending: None, + ime_frame_viewport: None, + ime_viewport: None, + ime_composition: None, + ime_terminal_pending: None, + ime_cancelled: None, + ime_generation: 0, + ime_settles_seen: 0, + ime_interrupt_pending: false, + ime_reset: HashMap::new(), } } @@ -687,8 +957,72 @@ impl SlideApp { r } - pub(crate) fn docs_key(&self) -> (usize, Option) { - (self.session.documents().len(), self.session.active_index()) + /// Return the current document count plus the active document uid, so callers can detect a + /// replacement document rather than only an index change. + pub(crate) fn docs_key(&self) -> (usize, Option) { + // The document uid, not just its index: closing one presentation and opening another + // must not look like the same document to the Backstage view or a queued input batch. + (self.session.documents().len(), self.session.active().map(|d| d.uid)) + } + + /// What a queued synthetic event is meant for. The control protocol hands out window + /// coordinates, so a document, slide, view, zoom or canvas-layout change between queueing and + /// the next UI pass makes the event stale. + fn input_target(&self) -> InputTarget { + let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; + InputTarget { + documents: self.session.documents().len(), + active: self.session.active().map(|d| d.uid), + slide: self.session.active().map(|d| d.selection.slide), + view: self.ui.view, + zoom: self.ui.zoom.map(bits), + canvas: self.canvas_rect.map(|r| [bits(r.min.x), bits(r.min.y), bits(r.max.x), bits(r.max.y)]), + canvas_scale: bits(self.canvas_scale), + dialog: self.dialog.as_ref().map(|d| egui::Id::unique(("dialog", d.id.as_str()))), + palette: self.palette.as_ref().map(|(q, sel)| egui::Id::unique(("palette", q.as_str(), *sel))), + backstage: self.backstage.as_ref().map(|b| egui::Id::unique(("backstage", b.page.id()))), + ribbon_tab: egui::Id::unique(("ribbon-tab", self.ui.tab.as_str())), + ribbon_collapsed: self.ui.ribbon_collapsed, + ribbon_scroll: self.ribbon_scroll.get(&self.ui.tab).copied().unwrap_or(0), + show: self.show.as_ref().map(show::Show::input_state), + opening: self.opening.as_ref().map(|o| (egui::Id::unique(("opening", o.path.as_str())), o.generation)), + } + } + + /// Queue one synthetic control-channel event. The target is recorded when the batch starts, + /// before any host command in the same logic pass can change the document. + pub fn queue_input(&mut self, event: egui::Event) { + let target = self.input_target(); + // A batch that was already injected keeps the target it was injected for until its UI + // pass consumes it or retracts it. New events queued meanwhile belong to the current + // target, so they wait in a separate batch instead of joining the old one and being + // cleared with it when that batch turns out to be stale. + if self.synthetic_pending { + if self.synthetic_next_target != Some(target) { + self.synthetic_next.clear(); + self.synthetic_next_target = Some(target); + } + self.synthetic_next.push(event); + return; + } + if self.synthetic.is_empty() { + self.synthetic_target = Some(target); + } else if self.synthetic_target != Some(target) { + // The unsent queue belongs to a target that has already changed; drop it (and any + // older batch still waiting behind it) rather than appending a click intended for the + // new target to a stale batch. + self.synthetic.clear(); + self.synthetic_next.clear(); + self.synthetic_next_target = None; + self.synthetic_target = Some(target); + } + self.synthetic.push(event); + } + + /// Events admitted by the control channel but not yet injected (including a batch waiting + /// behind the one currently in flight). + pub(crate) fn synthetic_queued_len(&self) -> usize { + self.synthetic.len() + self.synthetic_next.len() } /// The File tab's view is open over a presentation (not just the start screen with nothing @@ -1103,11 +1437,16 @@ impl SlideApp { Ok(Some(from)) } + /// Leave animation preview, stop editor-owned media, and create a show at the requested slide + /// with the current reading/presenter configuration; an out-of-range slide starts an ended show. pub fn start_show(&mut self, from: usize, reading: bool) { self.anim_preview = None; self.media.stop_owned(media::Owner::Editor); if let Some(d) = self.session.active() { - self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading)); + self.show_instance = self.show_instance.wrapping_add(1).max(1); + let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); + show.instance = self.show_instance; + self.show = Some(show); } } @@ -1587,42 +1926,77 @@ impl SlideApp { } } - /// Per-frame logic before layout: styles, control requests, screenshots, shortcuts. + /// Keep a hidden show playing from logic alone. `run_logic` does not refresh + /// `InputState::time`, so the show anchors its last visible time to this monotonic instant + /// and advances from there. A forced hidden paint draws the current state but does not + /// advance it a second time, and no paused-show product policy is invented. + fn tick_hidden_show(&mut self, ctx: &egui::Context, now: web_time::Instant) { + let visible = show::viewport_visible(ctx); + let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; + let Some(show) = self.show.as_mut() else { return }; + if visible { + // A visible logic-only pass (`run_logic`) carries the last full frame's input time. + // Anchoring that stale time to `now` would move the hidden clock backwards if the + // window hides before the next full pass. `show::ui` already records the anchor on + // every visible full pass, where `InputState::time` is current. + return; + } + if !show.hidden_started() { + return; + } + let input_time = ctx.input(|i| i.time); + let show_time = show.hidden_logic_time(input_time, now); + let keep = show.advance_hidden(&doc, show_time); + let time_driven = show.hidden_time_driven(&doc); + if !keep { + self.show = None; + return; + } + if time_driven { + // A hidden event loop has no draw to piggyback on. A delayed repaint keeps the + // logic clock ticking; with no paint request pending eframe services it as a + // logic-only pass, not a visible frame. + ctx.request_repaint_after_for(std::time::Duration::from_millis(100), ctx.viewport_id()); + } + } + + /// Background work, run on every logic call (visible, hidden or `run_logic`). + /// + /// Only consumed queues and monotonic deadlines live here: control requests, asynchronous + /// opening/inbox results, screenshot completions/deadlines, AutoRecover pacing and media + /// clocks. UI events are handled in [`Self::ui`], where a forced hidden paint also arrives. pub fn logic(&mut self, ctx: &egui::Context) { - let scale = self.ui.ui_scale.clamp(0.5, 3.0); - if (ctx.zoom_factor() - scale).abs() > 1e-3 { - ctx.set_zoom_factor(scale); + if !Self::root_viewport_admitted(ctx) { + log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); + return; } - if !self.styled { - theme::install_fonts_with(ctx, &self.ui_fonts.faces); - self.styled = true; - self.restyle = true; - } else { - self.fonts_ready = true; - } - if self.restyle { - theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); - self.restyle = false; - } - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; - self.drain_control(ctx); - self.poll_opening(); - if self.fonts_ready { + self.tick_hidden_show(ctx, web_time::Instant::now()); + // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built + // `PointerState` and `keys_down` from every injected event, so retracting the events after + // this pass would not stop the derived click or key from reaching the UI. Defer instead: + // a control request, background-open result or inbox file that changes the document, + // target or modal owner waits until the admitted batch has been consumed by the old + // target's UI pass. A logic-only callback carries the RawInput into the next full pass + // (no live `InputState` here), so that pass still drains normally and the next + // `raw_input_hook` retracts a batch that went stale. + if !self.synthetic_batch_is_live(ctx) { + self.drain_control(ctx); + self.poll_opening(); + // Inbox results are host file bytes and do not need fonts or a painted frame. A + // window that starts hidden must still apply a file picked before its first UI pass. self.drain_inbox(); } - if !self.synthetic.is_empty() { + self.drain_screenshots(); + // `raw_input_hook` ran before this logic pass. This is normally a no-op now that + // target-changing work is deferred for a live batch; it still covers a target change + // made by other logic, and keeps a logic-only batch pending for the next hook. + self.retract_synthetic_after_logic(ctx); + // Queued/settling screenshots force the bounded hidden paints they need; issued ones + // rely on the screenshot command and the completion callback, with only a deadline wake. + self.capture.request_paint_if_needed(ctx, web_time::Instant::now()); + if !self.synthetic.is_empty() || !self.synthetic_next.is_empty() || self.synthetic_pending { ctx.request_repaint(); } - if self.fonts_ready { - self.note_ui_text(ctx); - } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); // Closing the window with unsaved changes asks first. if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.session.documents().iter().any(|d| d.is_dirty()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -1631,8 +2005,50 @@ impl SlideApp { self.dialog = Some(dialogs::Dialog::new("quit")); } } - // AutoRecover: unsaved presentations are written to the recovery folder periodically. - if self.session.recovery_dir.is_some() && now - self.last_recovery > (self.session.prefs.recovery_minutes * 60.0).max(10.0) { + self.autosave_if_due(ctx); + self.tick_media_clock(ctx); + self.note_settled_composition(ctx); + // In a full run_ui pass the UI callback after this logic half still owns the pass's final + // IME descriptor. Only a standalone run_logic callback proves that no custom editor was + // painted for this tick. Do not infer this from cumulative frame counters or stale + // geometry. + if !self.ime_in_ui_pass(ctx) { + self.finalize_ime_logic(ctx); + } + } + + /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because + /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. + fn ui_setup(&mut self, ctx: &egui::Context) -> bool { + let scale = self.ui.ui_scale.clamp(0.5, 3.0); + if (ctx.zoom_factor() - scale).abs() > 1e-3 { + ctx.set_zoom_factor(scale); + } + if !self.styled { + theme::install_fonts_with(ctx, &self.ui_fonts.faces); + self.styled = true; + self.restyle = true; + // Font definitions take effect at the next pass boundary. Do not paint a language + // or symbol with the old font set while the new set is still pending. + ctx.request_discard("install interface fonts before layout"); + return false; + } + if self.restyle { + theme::apply(ctx, &theme::Tokens::for_brightness(self.ui.brightness)); + self.restyle = false; + } + self.fonts_ready = true; + true + } + + /// AutoRecover on a monotonic deadline, even when nothing is painted. + fn autosave_if_due(&mut self, ctx: &egui::Context) { + if self.session.recovery_dir.is_none() { + return; + } + let every = (self.session.prefs.recovery_minutes * 60.0).max(10.0); + let now = monotonic_secs(); + if now - self.last_recovery > every { self.last_recovery = now; if self.session.documents().iter().any(|d| d.is_dirty()) && let Err(e) = self.session.execute("file.recovery.save", &json!({})) @@ -1640,17 +2056,40 @@ impl SlideApp { self.set_status(format!("Couldn't save AutoRecover information: {e}")); } } - // On the web, dropped files can only be read asynchronously: the host reads them and - // delivers them through `Services::inbox`, tagged with the request captured at the drop. - #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); - match f.bytes() { - Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), - Err(e) => self.set_status(e), + if self.session.documents().iter().any(|d| d.is_dirty()) { + let wait = (every - (monotonic_secs() - self.last_recovery)).clamp(0.5, every); + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + } + + /// Issue due screenshots and finish pass-local UI bookkeeping. Call once at the end of a real + /// pass after layout; the outer [`Self::ui`] wrapper observes IME output after this returns. + fn ui_end(&mut self, ctx: &egui::Context) { + self.ime_frame_viewport = None; + self.capture.observe_ui(ctx); + if !ctx.will_discard() { + let doc = self.session.active().map(|d| d.uid); + self.capture.issue(ctx, doc, web_time::Instant::now()); + } + if self.fonts_ready && !ctx.will_discard() { + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; } + self.promote_next_synthetic(); + } + if self.fonts_ready && !ctx.will_discard() { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; } - canvas::trackpad_twist(self, ctx); } /// Borrow faces for interface text in scripts the interface fonts lack (#101): the open @@ -1679,40 +2118,930 @@ impl SlideApp { self.ui_fonts.apply(ctx); } - /// An input method's event for the slide's text (#101): the preedit is shown in the text and - /// replaced on each update; the commit replaces it as one undo step. - fn ime_event(&mut self, e: &egui::ImeEvent) { - let r = match e { - egui::ImeEvent::Preedit { text, active_range_chars } => { - self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) - } - egui::ImeEvent::Commit(text) => self.session.commit_text(text), - #[allow(deprecated)] - egui::ImeEvent::Disabled => { - self.session.end_composition(); - Ok(Value::Null) - } - _ => Ok(Value::Null), + /// Stable IME owner for the canvas text target: document, view, shape and table cell. + /// + /// The caret, selection, text, revision and geometry are deliberately absent, so ordinary + /// edits and caret movement keep the same owner. A new document, shape or cell is a new + /// owner; [`egui::ime_context::ImeTarget::replacement_epoch`] stays zero because that key + /// itself changes on replacement. `file.revert` keeps the ids, so a composition it settles is + /// covered by the settle interrupt and by [`Self::ime_event`]'s staleness check instead. + fn ime_target(&self) -> Option { + let st = self.session.active()?; + let (shape, cell, notes) = match &st.selection.text { + Some(t) => (t.shape, t.cell, t.notes), + None => (*st.selection.shapes.first()?, None, false), }; - if let Err(e) = r { - self.set_status(e.to_string()); + if st.selection.text.is_none() && st.selection.shapes.len() != 1 { + return None; + } + let target = match st.selection.target { + Target::Slides => (0u8, 0usize, 0usize), + Target::Master { master } => (1, master, 0), + Target::Layout { master, layout } => (2, master, layout), + Target::Handout => (3, 0, 0), + Target::NotesMaster => (4, 0, 0), + }; + let owner = egui::Id::unique(("deckcraft-ime", st.uid, target, shape.0, cell, notes)); + Some(egui::ime_context::ImeTarget::new(owner, 0)) + } + + /// The committed surrounding snapshot for the current edit target, or `Unavailable` when + /// there is an owner but no committed text to describe. + /// + /// An unavailable stamp carries the document revision. The tracker requires a strictly + /// increasing revision on both sides of a receipt that involves `Unavailable`; without it, a + /// wide selection or a commit that starts from a selected shape would poison the whole frame. + fn ime_receipt_stamp(&self) -> Option { + let st = self.session.active()?; + self.ime_target()?; + Some(st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)).map_or_else( + || egui::ime_context::ImeMutationStamp::unavailable_at_revision(st.revision), + |s| egui::ime_context::ImeMutationStamp::from_committed(&s.text, s.cursor, s.anchor, None), + )) + } + + /// Report an application-origin edit in this frame. The tracker lets `Other` dominate any + /// earlier or later IME receipt in the same frame. + fn note_application_edit(&mut self, ctx: &egui::Context) { + let Some(viewport) = self.ime_frame_viewport else { return }; + if let Some(target) = self.ime_target() { + let frame = ctx.cumulative_frame_nr_for(viewport); + self.ime_tracker.note_application_change(viewport, frame, target); } } - /// Inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { + /// Record the expected selection after an accepted IME event. Its own deletion may move + /// the caret/revision; that is not an application interruption of the following commit. + fn refresh_ime_terminal_selection(&mut self) { + let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); + if let (Some(pending), Some((revision, anchor, caret))) = (&mut self.ime_terminal_pending, state) { + pending.revision = revision; + pending.anchor = anchor; + pending.caret = caret; + } + } + + /// The current live destination for an IME reset fence. + fn ime_destination(&self) -> Option { + let st = self.session.active()?; + let target = self.ime_target()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeDestination { owner: target.owner, revision: st.revision, anchor, caret }) + } + + /// Use the recorded IME viewport only while it matches the current context viewport. + fn ime_viewport_or(&self, ctx: &egui::Context) -> egui::ViewportId { + self.ime_frame_viewport.filter(|v| *v == ctx.viewport_id()).unwrap_or_else(|| ctx.viewport_id()) + } + + /// The reset guard belonging to exactly this viewport. There is deliberately no fallback to + /// another viewport's phase. + pub(crate) fn ime_reset_for(&self, viewport: egui::ViewportId) -> ImeResetPhase { + self.ime_reset.get(&viewport).copied().unwrap_or(ImeResetPhase::Clear) + } + + /// Store a non-clear reset phase for one viewport, or remove the entry when the phase is `Clear`. + fn set_ime_reset(&mut self, viewport: egui::ViewportId, phase: ImeResetPhase) { + if matches!(phase, ImeResetPhase::Clear) { + let _ = self.ime_reset.remove(&viewport); + } else { + self.ime_reset.insert(viewport, phase); + } + } + + /// The live stamp used to compare against the origin/interrupted terminal destination. + fn ime_terminal_stamp(&self) -> Option { + let st = self.session.active()?; + let (anchor, caret) = st.selection.text.as_ref().map_or((None, None), |t| (Some(t.anchor), Some(t.caret))); + Some(ImeTerminalStamp { owner: self.ime_target().map(|t| t.owner), revision: st.revision, anchor, caret }) + } + + /// Positive route predicate for the canvas IME producer: this pass must be one that can + /// actually paint `canvas::show` for the active document, with no modal, stock field or + /// overlay owning input. + /// + /// Sorter and Notes Page are editor views without a canvas producer, so they are named here + /// even though the retained selection still yields an `ime_target`. A hidden, unfocused or + /// closing window, a missing document/target, a focused stock field, modal dialog, palette, + /// Backstage, slide show and opening overlay lose admission too. The finalizer remains the + /// authority for this pass's actual descriptor; keeping this predicate positive means a view + /// added later is not admitted by omission simply because it does not call `canvas::show`. + fn ime_custom_canvas_route(&self, ctx: &egui::Context) -> bool { + matches!(self.ui.view, ViewMode::Normal | ViewMode::Outline | ViewMode::Reading) + && self.session.active().is_some() + && self.ime_target().is_some() + && ctx.input(|i| i.viewport().focused.unwrap_or(true)) + && ctx.input(|i| i.viewport().visible() != Some(false)) + && !ctx.input(|i| i.viewport().close_requested()) + && ctx.memory(|m| m.focused().is_none()) + && self.dialog.as_ref().is_none_or(|d| !d.modal()) + && self.palette.is_none() + && self.backstage.is_none() + && self.show.is_none() + && self.opening.is_none() + } + + /// The singleton app's session, composition tracker and terminal guard belong to its owning + /// viewport. View › New Window and Presenter View render through their own immediate + /// viewports and never call `logic`/`ui`; admitting such a callback would let a render-only + /// viewport mutate root composition state while only the reset map is per-viewport. Reject it + /// before any state changes instead of pretending to support multi-view editing. + fn root_viewport_admitted(ctx: &egui::Context) -> bool { + ctx.viewport_id() == egui::ViewportId::ROOT + } + + /// Report whether the current pass is a full UI pass for the IME-owning viewport rather than + /// standalone logic. + fn ime_in_ui_pass(&self, ctx: &egui::Context) -> bool { + let key = ime_in_ui_pass_key(self.ime_viewport_or(ctx)); + ctx.data(|d| d.get_temp::(key).unwrap_or(false)) + } + + /// A new application-origin interruption supersedes any previous pending fence. + /// + /// This runs from the logic half of a full pass and from raw input handling, before the + /// finalizer has seen this pass's actual custom descriptor. No request is allocated here: + /// putting a token into egui `Memory` now would let egui attach it to a later stock + /// descriptor, or to a canvas this route does not paint. Retire the superseded id + /// immediately and store an unbound `Suspended { request: None }`; only the finalizer may + /// allocate its one replacement after confirming an eligible custom output. + fn note_ime_interruption(&mut self, ctx: &egui::Context, fresh: bool) { + let viewport = self.ime_viewport_or(ctx); + if !fresh + && matches!( + self.ime_reset_for(viewport), + ImeResetPhase::Awaiting { viewport: guard_viewport, .. } + | ImeResetPhase::Suspended { viewport: guard_viewport, .. } + if guard_viewport == viewport + ) + { + // This viewport already has a live or suspended guard. The finalizer replaces it + // once if the actual destination changed; do not churn a request per event. return; } + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + + /// Custom admission was lost on this route. Retire the old id without inventing an + /// acknowledgement and block until a later eligible pass can request a replacement. + fn suspend_ime_reset(&mut self, viewport: egui::ViewportId) { + if self.ime_reset_for(viewport).blocks_custom_input() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// An ordered completion for the exact newest request. Wrong/foreign/obsolete markers are + /// inert; this never mutates text, caret or undo history. + fn ime_reset_completed(&mut self, request_id: egui::ImeResetId, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if request_id.viewport_id() != viewport { + return; + } + if let ImeResetPhase::Awaiting { id, viewport: waiting_viewport, .. } = self.ime_reset_for(viewport) + && id == request_id + && waiting_viewport == viewport + { + self.set_ime_reset(viewport, ImeResetPhase::Clear); + // The exact ordered completion is the platform-reset boundary. It also releases a + // cleared-preedit terminal guard: the app no longer has to wait for the old + // Commit/Enabled/Disabled before a fresh composition may start. Events ordered + // before this marker remain rejected; a late old terminal after the reset has the + // same documented residual as any other event crossing a platform reset. + if self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled) { + self.ime_terminal_pending = None; + } + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + } + + /// Controls-only routing for a route where the custom editor cannot mutate. Markers are + /// visited in raw order; stock text/key events stay untouched for the stock widgets. + fn route_ime_controls_only(&mut self, ctx: &egui::Context, events: &[egui::Event]) { + for e in events { + if let egui::Event::ImeResetCompleted { request_id } = e { + self.ime_reset_completed(*request_id, ctx); + } + } + } + + /// Bind a suspended reset to the actual custom descriptor, or retire it on loss. + fn finalize_ime_reset(&mut self, ctx: &egui::Context, custom: Option) { + let viewport = self.ime_viewport_or(ctx); + let phase = self.ime_reset_for(viewport); + let actual = ctx.output(|o| o.ime.as_ref().map(|i| ImeDescriptorKey::new(i.owner, i.context_generation, i.purpose))); + let destination = self.ime_destination(); + // The finalizer is the allocation authority, so it rechecks the positive canvas route + // and that this pass is not already known to be discarded. Sorter/Notes Page, a modal, + // palette, Backstage, show, opening overlay, hidden window, stock focus, font/layout + // discard or a missing target must suspend without putting a token into egui Memory. + let eligible = self.ime_custom_canvas_route(ctx) && !ctx.will_discard(); + let custom_output = match (eligible, custom, actual, destination) { + (true, Some(custom), Some(actual), Some(destination)) if custom == actual => Some((custom, destination)), + _ => None, + }; + let Some((output_context, destination)) = custom_output else { + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, .. } => { + // A request that was not bound to the actual custom output in this pass is + // retired locally; a later eligible activation obtains a fresh one. A guard + // stamped for another viewport (or a request issued for one) is never reused. + if guard_viewport != viewport || request.is_some() { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + ImeResetPhase::AllocationFailed { .. } => { + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + return; + }; + match phase { + ImeResetPhase::Clear => {} + ImeResetPhase::Awaiting { viewport: old_viewport, destination: old_destination, output_context: old_context, .. } => { + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + ImeResetPhase::Suspended { viewport: guard_viewport, request, needs_rearm } => { + let request = if needs_rearm && guard_viewport == viewport { + // Only a request issued for this exact viewport may be bound here. + request.filter(|id| id.viewport_id() == viewport) + } else { + None + }; + match request.or_else(|| ctx.request_ime_reset(viewport)) { + Some(id) => { + self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }); + } + None => { + self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }); + } + } + } + ImeResetPhase::AllocationFailed { viewport: old_viewport, destination: old_destination, output_context: old_context } => { + // Repeated idle output for the same destination does not retry allocation. + if old_viewport != viewport || old_destination != destination || old_context != output_context { + self.request_ime_rearm(ctx, viewport, destination, output_context); + } + } + } + } + + /// Request one replacement IME reset token and record `Awaiting` on success or + /// `AllocationFailed` when no token can be allocated. + fn request_ime_rearm(&mut self, ctx: &egui::Context, viewport: egui::ViewportId, destination: ImeDestination, output_context: ImeDescriptorKey) { + match ctx.request_ime_reset(viewport) { + Some(id) => self.set_ime_reset(viewport, ImeResetPhase::Awaiting { id, viewport, destination, output_context }), + None => self.set_ime_reset(viewport, ImeResetPhase::AllocationFailed { viewport, destination, output_context }), + } + } + + /// A standalone run_logic tick paints no custom geometry. Suspend once, without replaying + /// stale input; the next full UI pass can rearm from its actual descriptor. + fn finalize_ime_logic(&mut self, ctx: &egui::Context) { + let viewport = self.ime_viewport_or(ctx); + if self.ime_reset_for(viewport).blocks_custom_input() { + // Only this viewport's guard is touched. A root/child logic tick must not suspend or + // rebind another viewport's independent reset lifetime. + self.set_ime_reset(viewport, ImeResetPhase::Suspended { viewport, needs_rearm: true, request: None }); + } + } + + /// Note compositions the application settled since the last check (a click, a key, another + /// command). Those are announced to the platform as an interrupt in [`Self::observe_ime`], + /// and a platform event for them is stale from that moment on, even in the same pass. + fn note_settled_composition(&mut self, ctx: &egui::Context) { + let settles = self.session.composition_settles(); + let current_stamp = self.ime_terminal_stamp(); + // `file.close` then `file.new` can replace the active uid before any pass observes the + // empty document. That is not a safe boundary for the old terminal: the platform may + // still deliver its suffix. Keep the guard bound to its retired owner and treat the new + // destination as a fresh interruption; only exact reset completion or an observed + // teardown may clear the terminal metadata. + let pending_interrupted = self.ime_terminal_pending.as_ref().is_some_and(|p| { + let reference = if p.interrupted { + p.interrupted_at + } else { + Some(ImeTerminalStamp { owner: Some(p.owner), revision: p.revision, anchor: Some(p.anchor), caret: Some(p.caret) }) + }; + reference != current_stamp + }); + if settles == self.ime_settles_seen && !pending_interrupted { + return; + } + let fresh = settles != self.ime_settles_seen; + self.ime_settles_seen = settles; + if let Some(pending) = &mut self.ime_terminal_pending { + pending.cancelled = true; + pending.interrupted = true; + // Compare later passes against the destination where interruption actually happened, + // not forever against the retired composition's origin. The destination may be absent + // (the interruption closed its target); `interrupted` keeps that distinct from "not + // interrupted yet". + pending.interrupted_at = current_stamp; + } + self.ime_interrupt_pending = true; + self.note_ime_interruption(ctx, fresh); + let cancelled = self.ime_composition.as_mut().map(|c| { + c.settled = true; + (c.owner, c.generation) + }); + if let Some((owner, generation)) = cancelled { + // Retain a tombstone beyond the first stale event: clearing the composition must + // not let the rest of this input batch commit into the next target. `observe_ime` + // retires it in the pass that publishes the matching interrupt. + self.ime_cancelled = Some(ImeCancelled { owner, generation }); + } + } + + /// An input method's event for the slide's text (#101): the preedit is shown in the text and + /// replaced on each update; the commit replaces it as one undo step. + /// + /// An event only ever changes the target its composition began in. The application settles a + /// composition itself on a click, a key, a command or a target change, and the platform may + /// still hold it: pre-edits and commits queued for that generation in the settling input batch + /// are dropped (with [`egui::Memory::interrupt_ime`]) instead of being typed into whatever is + /// selected now. After the interrupt has been published the platform is reset, so the next + /// batch is treated as new input; egui's event payload has no generation, so an OS-queued + /// event that crosses that reset cannot be distinguished from a legitimate one. + /// + /// A receipt is captured immediately before and after a successful commit or + /// delete-surrounding mutation. Preedit, rejected/failed events and no-ops never earn + /// [`egui::output::ImeChangeCause::InputMethod`]. + fn ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent) { + self.note_settled_composition(ctx); + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + let frame = ctx.cumulative_frame_nr_for(viewport); + let target = self.ime_target(); + let owner = target.map(|t| t.owner); + let accepted = + matches!(e, egui::ImeEvent::Commit(_) | egui::ImeEvent::DeleteSurrounding { .. } | egui::ImeEvent::DeleteSurroundingBytes { .. }); + if self.reject_stale_ime_event(ctx, e, owner) { + return; + } + // Until the exact newest reset request completes at its ordered stream position, custom + // mutations stay blocked. Platform enable/disable signals are not acknowledgements and do + // not release this guard; they still settle the old composition below. + #[allow(deprecated)] + if self.ime_reset_for(viewport).blocks_custom_input() && !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) { + return; + } + let before = if accepted { self.ime_receipt_stamp() } else { None }; + let r = self.apply_ime_event(e); + self.settle_ime_event_state(e, owner, r.is_ok()); + if r.is_ok() && accepted { + self.refresh_ime_terminal_selection(); + } + if let Err(e) = &r { + self.set_status(e.to_string()); + } else if accepted + && let (Some(target), Some(before), Some(after)) = (target, before, self.ime_receipt_stamp()) + && before != after + && r.as_ref().is_ok_and(|v| !v.is_null() && v.get("handled").and_then(Value::as_bool) != Some(false)) + { + // A no-op receipt (an empty commit, a zero-length delete, same text reinserted) is + // not a mutation and must not poison the frame for a real commit later in the batch. + self.ime_tracker.note_input_method_apply(viewport, frame, target, before, after); + } + } + + /// Drop an event that belongs to a composition the application already settled, or to another + /// owner; returns `true` when the event must not be applied. + /// + /// A commit or deletion belongs to the composition that began the pre-edit: another owner + /// (the user clicked elsewhere) or a composition the app already settled makes it stale. + /// While the reset is still pending, the tombstone rejects pre-edits too (the platform has + /// not yet been told to cancel its copy). `observe_ime` retires the tombstone in the pass + /// that publishes the interrupt (or in an accepted no-descriptor pass, which resets the + /// backend just the same), so a later commit-only is not lost forever: it is a + /// legitimate direct commit or a new platform composition. egui's events carry no + /// generation, so an event queued by the OS across that reset is indistinguishable from + /// one the user just typed; only events in the same input batch as the settle are rejected + /// deterministically. A cleared-preedit terminal is a distinct case: its pending owner + /// remains guarded across that output boundary until its actual commit/enable/disable + /// arrives. Publishing a reset is not evidence that the separately queued terminal was + /// delivered. With no incoming event epoch, the first ambiguous new composition after an + /// unobserved reset may be rejected; no timer guesses that it became safe. + /// The tombstone is not filtered by the *current* target: a click can change the owner in + /// the settling pass, and every remaining event of that batch still belongs to the + /// pre-reset composition. Filtering would let the event after the first dropped pre-edit + /// start composing in the new target before the platform is reset. + fn reject_stale_ime_event(&mut self, ctx: &egui::Context, e: &egui::ImeEvent, owner: Option) -> bool { + let cancelled = self.ime_cancelled; + let pending_cancelled = self.ime_terminal_pending.as_ref().is_some_and(|p| p.cancelled); + #[allow(deprecated)] + let stale = !matches!(e, egui::ImeEvent::Disabled | egui::ImeEvent::Enabled) + && (self.ime_composition.is_some_and(|c| c.settled || Some(c.owner) != owner) || cancelled.is_some() || pending_cancelled); + if !stale { + return false; + } + if pending_cancelled && matches!(e, egui::ImeEvent::Commit(_)) { + // The old terminal finally arrived: reject it and release its pending owner now, + // so a new preedit later in this same raw batch can belong to the current target. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + return true; + } + if pending_cancelled { + // note_settled_composition already queued the reset. Do not also latch the + // frame's memory interrupt: a terminal later in this batch can consume it and + // permit a fresh preedit without resetting that new composition at pass end. + self.ime_composition = None; + return true; + } + if let Some(c) = cancelled { + log::debug!("IGNORING IME event for settled composition owner {:?} generation {}", c.owner, c.generation); + } + self.ime_composition = None; + // Ask the backend to drop its copy, so this is one stale event and not a stream. + ctx.memory_mut(|m| m.interrupt_ime()); + true + } + + /// Apply the document mutation an IME event asks for, before any receipt or tracker state. + fn apply_ime_event(&mut self, e: &egui::ImeEvent) -> deckcraft_engine::Result { + match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + self.session.compose_text(text, active_range_chars.as_ref().map(|r| (r.start, r.end))) + } + // A commit only replaces text that is being edited. It must never start editing a + // merely selected shape: without a tracked pre-edit, a stale platform commit would + // replace that shape's whole text. + egui::ImeEvent::Commit(text) => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.commit_text(text) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_chars, *after_chars, false) + } else { + Ok(Value::Null) + } + } + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + if self.session.active().is_some_and(|d| d.selection.text.is_some()) { + self.session.delete_surrounding(*before_bytes, *after_bytes, true) + } else { + Ok(Value::Null) + } + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // The platform ended (or is about to restart) its composition itself. + self.session.end_composition(); + Ok(Value::Null) + } + } + } + + /// Update the tracked composition after `e` has been applied; `applied` says whether the + /// document mutation succeeded. + fn settle_ime_event_state(&mut self, e: &egui::ImeEvent, owner: Option, applied: bool) { + match e { + // The platform ends its composition on a commit (and on Disabled/Enabled): nothing is + // left to interrupt. A pre-edit keeps (or starts) the tracked composition. + egui::ImeEvent::Commit(_) => { + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + #[allow(deprecated)] + egui::ImeEvent::Disabled | egui::ImeEvent::Enabled => { + // A platform-side disable/enable already reset the composition. Do not carry an + // older app-settled interrupt into the next (possibly new) composition. + self.ime_terminal_pending = None; + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_interrupt_pending = false; + } + egui::ImeEvent::Preedit { text, .. } => { + match (owner, applied, self.session.active().is_some_and(|d| d.composition.is_some())) { + (Some(owner), true, true) => { + // Updating the same composition keeps its generation. After a settle (or + // a first pre-edit) this is a new generation and clears the tombstone. + let generation = match self.ime_composition { + Some(c) if c.owner == owner && !c.settled => c.generation, + _ => { + self.ime_generation = self.ime_generation.wrapping_add(1).max(1); + self.ime_generation + } + }; + self.ime_composition = Some(ImeComposition { owner, generation, settled: false }); + self.ime_terminal_pending = None; + self.ime_cancelled = None; + } + (Some(owner), true, false) if text.is_empty() && self.ime_composition.is_some_and(|c| !c.settled && c.owner == owner) => { + // The model restored committed text, but no terminal was delivered. Keep + // this owner across passes; a later pointer/key/target change must reject + // its separately queued old delete/commit, even after an outgoing reset. + if let Some(st) = self.session.active() + && let Some(t) = &st.selection.text + { + self.ime_terminal_pending = Some(ImeTerminalPending { + owner, + revision: st.revision, + anchor: t.anchor, + caret: t.caret, + cancelled: false, + interrupted: false, + interrupted_at: None, + }); + } + } + _ => { + self.ime_composition = None; + self.ime_terminal_pending = None; + } + } + } + _ => {} + } + } + + /// Observe the canvas IME owner once per pass and publish its descriptor. + /// + /// No canvas target, a Sorter/Notes Page view, a focused egui field, a modal, the palette, + /// the Backstage view, a slide show, an opening overlay, or an unfocused/hidden window means + /// the custom owner is hidden: the tracker is told so and the next focus starts a new + /// generation. The descriptor is rebuilt from the current committed text every pass. + /// + /// A composition the application settled itself is announced once as + /// `should_interrupt_composition`, so the backend cancels the platform's copy of it. That + /// settled pass is also the active-composition tombstone's lifecycle end. A cleared-preedit + /// terminal retains its separate pending owner until the incoming terminal is observed. + fn observe_ime(&mut self, ctx: &egui::Context) -> Option { + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + self.note_settled_composition(ctx); + // The editing window going away really ends the viewport's context; a merely hidden + // owner (modal, palette, unfocused field) stays observed as unfocused instead. + if self.session.active().is_none() || ctx.input(|i| i.viewport().close_requested()) { + self.ime_interrupt_pending = false; + if let Some(old) = self.ime_viewport.take() { + self.ime_tracker.end_viewport(old); + // Explicit viewport destruction removes only that viewport's guard. + let _ = self.ime_reset.remove(&old); + } + let _ = self.ime_reset.remove(&viewport); + self.ime_composition = None; + self.ime_cancelled = None; + self.ime_terminal_pending = None; + return None; + } + if !ctx.input(|i| i.viewport().focused.unwrap_or(true)) { + // The native window itself lost focus: no custom editor is eligible this pass, even + // if the model still has a text selection and a last-pass geometry rect. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(rect) = self.ime_pending.take() else { + // No canvas editor was painted in this pass. A discarded font/layout pass publishes + // nothing the platform sees, so keep the interrupt and tombstone for a later pass. + // An accepted pass is a backend reset only when it publishes *no* IME descriptor at + // all: a focused stock `TextEdit` (notes pane, dialog, palette field) publishes its + // own `o.ime` while `ime_pending` is `None`, and that descriptor is not a reset. Gate + // retirement on the actual committed output, not on the absence of the canvas rect; + // otherwise the next legitimate commit in the focused field would be rejected. + let no_descriptor = ctx.output(|o| o.ime.is_none()); + if !ctx.will_discard() && no_descriptor { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + self.ime_tracker.observe(viewport, None); + return None; + }; + if !self.ime_custom_canvas_route(ctx) { + // The canvas can still paint behind an opening overlay or a Sorter/Notes Page + // reroute, and a forced hidden paint can run after the window lost focus or + // visibility. The positive route and the UI wrapper both reject those, so a pending + // rect must not republish the custom owner. + self.ime_tracker.observe(viewport, None); + return None; + } + let Some(st) = self.session.active() else { + self.ime_interrupt_pending = false; + self.ime_tracker.observe(viewport, None); + return None; + }; + let Some(target) = self.ime_target() else { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + self.ime_tracker.observe(viewport, None); + return None; + }; + if self.ime_viewport != Some(viewport) + && let Some(old) = self.ime_viewport.replace(viewport) + { + self.ime_tracker.end_viewport(old); + let _ = self.ime_reset.remove(&old); + } + let committed = st.selection.text.as_ref().and_then(|t| deckcraft_engine::cmd::text::ime_surrounding(st, t)); + let mut observation = + egui::ime_context::ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) + .purpose(egui::IMEPurpose::Normal) + .committed(committed.as_ref().map(|s| egui::ime_context::CommittedText::new(&s.text, s.cursor, s.anchor))); + if committed.is_none() { + observation = observation.unavailable_revision(st.revision); + } + let update = self.ime_tracker.observe(viewport, Some(observation))?; + // Clear the interrupt only once it is part of a settled pass's output descriptor. A + // discarded font/layout pass publishes nothing the platform sees, so keep it pending; + // that pass is not the boundary at which stale pre-edits become a new generation. + let interrupt = if ctx.will_discard() { false } else { std::mem::take(&mut self.ime_interrupt_pending) }; + if interrupt { + // This pass's output carries the backend reset. The tombstone did its job for the + // events already processed in this batch; from the next batch on every pre-edit or + // commit is a new platform generation. A late event the OS queued across the reset + // has no generation in its payload, so it cannot be rejected without also dropping + // legitimate direct commits (documented residual in `ime_event`). + self.ime_cancelled = None; + if self.ime_composition.is_some_and(|c| c.settled) { + self.ime_composition = None; + } + } + let key = ImeDescriptorKey::new(update.owner, update.context_generation, update.purpose); + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect: rect, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + Some(key) + } + + /// Forget the injected or queued synthetic batch and everything recorded about it. Events + /// waiting in `synthetic_next` are a separate, newer batch and are promoted by the caller. + fn clear_synthetic_batch(&mut self) { + self.synthetic.clear(); + self.synthetic_pending = false; + self.synthetic_injected.clear(); + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_target = None; + self.synthetic_focus = None; + } + + /// Start the batch that was queued behind an older one once the older batch is gone. + fn promote_next_synthetic(&mut self) { + if self.synthetic_pending || !self.synthetic.is_empty() || self.synthetic_next.is_empty() { + return; + } + self.synthetic = std::mem::take(&mut self.synthetic_next); + self.synthetic_target = self.synthetic_next_target.take(); + self.synthetic_focus = None; + } + + /// A font/layout pass will be discarded: egui already consumed the injected events but the + /// app deliberately lays nothing out, so put them back at the head of the queue. The next + /// frame injects them again after the new fonts are installed; otherwise a control click that + /// happened to arrive on the very first frame would be acknowledged without ever reaching the + /// UI. + fn requeue_injected_synthetic(&mut self) { + if !self.synthetic_pending { + return; + } + let mut combined = std::mem::take(&mut self.synthetic_injected); + combined.append(&mut self.synthetic); + self.synthetic = combined; + self.synthetic_pending = false; + self.synthetic_injected_at = None; + self.synthetic_injected_time = None; + self.synthetic_focus = None; + if self.synthetic.is_empty() { + self.synthetic_target = None; + self.promote_next_synthetic(); + } + } + + /// Remove exactly the recorded synthetic events from `events`. The recorded index is tried + /// first so an identical host event earlier in the batch is never removed; the last exact + /// match is only a fallback for a raw input that was rewritten around the events. + fn drain_synthetic_from(&self, events: &mut Vec) -> bool { + let injected = &self.synthetic_injected; + let n = injected.len(); + if n == 0 { + return false; + } + let exact = self.synthetic_injected_at.filter(|start| events.get(*start..*start + n).is_some_and(|w| w == injected.as_slice())); + let at = exact.or_else(|| events.windows(n).rposition(|w| w == injected.as_slice())); + if let Some(at) = at { + events.drain(at..at + n); + true + } else { + false + } + } + + /// Drop an injected batch whose document, canvas, focus or modal owner changed during + /// `logic`. eframe calls `raw_input_hook` before `App::logic`, so a control request drained + /// there can still retarget the same `RawInput`. + /// + /// This must not infer the pass phase from `ViewportInfo::visible()`: a visible root can run + /// logic-only (an overdue redraw), and a hidden root can run a full pass for a screenshot or + /// `RequestPaintWhileHidden`. A logic-only callback leaves `InputState` from the *previous* + /// pass; remove the batch only when the live input carries exactly what this pass injected + /// (same `RawInput::time` and the exact recorded event window). Otherwise keep the pending + /// bookkeeping so the next `raw_input_hook` can retract the events from the carried + /// `RawInput` before eframe delivers them in a full pass. + fn retract_synthetic_after_logic(&mut self, ctx: &egui::Context) { + if !self.synthetic_pending { + return; + } + let current = self.input_target(); + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_target == Some(current) && focus == self.synthetic_focus { + return; + } + let injected_time = self.synthetic_injected_time; + let mut removed = false; + ctx.input_mut(|i| { + // A logic-only call keeps the previous pass's `InputState`; its `raw.time` is not the + // time of the RawInput this batch was injected into. Never drain an older batch that + // happens to look similar. + if i.raw.time != injected_time { + return; + } + let mut events = std::mem::take(&mut i.events); + let ok = self.drain_synthetic_from(&mut events); + i.events = events; + if ok { + // Keep `InputState::raw` consistent with what UI code reads as `events`. + let mut raw_events = std::mem::take(&mut i.raw.events); + let _ = self.drain_synthetic_from(&mut raw_events); + i.raw.events = raw_events; + removed = true; + } + }); + if removed { + self.clear_synthetic_batch(); + self.promote_next_synthetic(); + } else { + // Logic-only (or a pass whose input does not contain the batch): the events are in + // eframe's carried RawInput. Keep `synthetic_pending`, `synthetic_injected` and the + // stale target so the next hook retracts them there. Events queued after the batch + // belong to the same stale target as well. + self.synthetic.clear(); + } + } + + /// True when the pending synthetic batch is in this pass's live `InputState`, i.e. this is + /// the full pass the batch was admitted for and egui has already derived pointer/key state + /// from it. A logic-only callback leaves the previous pass's `InputState` (with another + /// `raw.time`) and must not be mistaken for the live pass, so control/background work is + /// still drained there and the next `raw_input_hook` retracts a batch that went stale. + fn synthetic_batch_is_live(&self, ctx: &egui::Context) -> bool { + if !self.synthetic_pending || self.synthetic_injected.is_empty() { + return false; + } + let Some(injected_time) = self.synthetic_injected_time else { return false }; + let injected = &self.synthetic_injected; + ctx.input(|i| i.raw.time == Some(injected_time) && i.events.windows(injected.len()).any(|w| w == injected.as_slice())) + } + + /// Input that belongs to the pass about to run: native dropped files and one synthetic + /// control-channel batch. The synthetic batch is injected at most once; while it is + /// unacknowledged, later passes cannot replay it. A document, canvas, focus or modal change + /// drops the whole queued batch rather than typing into the wrong place. + /// + /// The hook always injects into the `RawInput`; it does not try to guess whether eframe will + /// run this as a full or a logic-only pass. `run_logic` does not consume events but eframe + /// carries the whole `RawInput` into the next full pass, so an already-injected batch is + /// retracted at the next hook if a control request changed the target, and otherwise is + /// delivered exactly once in the next real UI pass. `ViewportInfo::visible()` is not a phase + /// signal: a hidden root still gets a full pass for a screenshot or `RequestPaintWhileHidden`. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + #[cfg(not(target_arch = "wasm32"))] + for f in std::mem::take(&mut raw.dropped_files) { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped file".into()); + match f.bytes() { + Ok(bytes) => self.open_or_insert_bytes(&name, &bytes), + Err(e) => self.set_status(e), + } + } + let focus = ctx.memory(|m| m.focused()); + if self.synthetic_pending { + let current = self.input_target(); + if self.synthetic_target != Some(current) || focus != self.synthetic_focus { + // eframe keeps the whole RawInput from a logic-only pass in `pending_raw_input` + // and appends the next host input to it. The events are already in `raw.events`; + // clearing only the unsent queue would still deliver the stale click or key into + // the new document on the next full pass. Retract exactly the batch we appended. + let _ = self.drain_synthetic_from(&mut raw.events); + self.clear_synthetic_batch(); + // New control input queued for the current target while the stale batch was in + // flight is a separate batch; keep it instead of clearing it with the old one. + self.promote_next_synthetic(); + } else { + // This RawInput is the carrier for the next pass, and eframe refreshes its time + // on every call. Record the current time so `retract_synthetic_after_logic` can + // tell this full pass's live `InputState` from a logic-only callback's stale one. + self.synthetic_injected_time = raw.time; + } + } + if self.synthetic_pending || self.synthetic.is_empty() { + return; + } + let current = self.input_target(); + if self.synthetic_target != Some(current) { + // The batch was queued for a document/slide/canvas that has since changed. + self.synthetic.clear(); + self.synthetic_target = None; + self.promote_next_synthetic(); + if self.synthetic.is_empty() { + return; + } + if self.synthetic_target != Some(current) { + // The newer batch is stale too: drop it rather than delivering it to this target. + self.synthetic.clear(); + self.synthetic_target = None; + self.synthetic_next.clear(); + self.synthetic_next_target = None; + return; + } + } + self.synthetic_generation = self.synthetic_generation.wrapping_add(1).max(1); + log::debug!("injecting synthetic input batch {}", self.synthetic_generation); let n = match self.synthetic.first() { Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } let n = n.min(self.synthetic.len()); - raw.events.extend(self.synthetic.drain(..n)); + let batch: Vec = self.synthetic.drain(..n).collect(); + let at = raw.events.len(); + raw.events.extend(batch.iter().cloned()); + self.synthetic_injected = batch; + self.synthetic_injected_at = Some(at); + self.synthetic_injected_time = raw.time; + self.synthetic_pending = true; + self.synthetic_focus = focus; + } + + /// The app shortcuts that still make sense on the empty File/start screen: create, open, + /// the command palette, preferences and the File tab itself. Document commands (save, find, + /// hyperlink, show, zoom) stay out; none of them can act on a presentation that is not open. + fn start_screen_shortcut(key: egui::Key, m: Mods) -> bool { + use egui::Key::*; + matches!( + (key, m.cmd, m.shift, m.alt), + (O, true, false, false) | (P, true, true, false) | (Comma, true, false, false) | (F, false, false, true) + ) + } + + /// File-tab navigation and document-free shortcuts, also used on the start screen. + fn route_backstage_keys(&mut self, events: Vec) { + if self.backstage.is_none() { + return; + } + // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app + // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the + // hidden presentation. + let start_screen = self.session.active().is_none(); + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { + let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; + let handled = backstage::key(self, key, mods) + // With no presentation the File home only owns the shortcuts that create or + // open one. Borrowing the whole document shortcut table here would also arm + // Save/Save As, hyperlink, find/replace, show and zoom against the empty + // start screen, which is a scope expansion no editing route needs. + || (mods.cmd || mods.alt) + && (!start_screen || Self::start_screen_shortcut(key, mods)) + && menus::ui_shortcut(self, key, mods); + if handled { + continue; + } + // Engine shortcuts that don't touch the hidden slide (Ctrl+N). + if mods.cmd + && matches!(key, egui::Key::N) + && let Err(e) = self.session.key(key_name(key), mods) + { + self.set_status(e.to_string()); + } + } + } } /// Keyboard and clipboard for the slide (when no text field has focus). @@ -1722,8 +3051,13 @@ impl SlideApp { // key press, so the release of a V press never seen is the paste. Tracked even while a text // field has focus, so that a V typed there is not taken for one. let mut image_paste = false; + let mut pasted_image_event = false; for e in &events { match e { + egui::Event::PasteImage(_) => { + pasted_image_event = true; + self.v_seen = true; + } egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => { // Only a release with Command/Ctrl held is a paste: a bare V release is not. @@ -1733,6 +3067,18 @@ impl SlideApp { _ => {} } } + let viewport = self.ime_viewport_or(ctx); + if self.session.active().is_none() { + // No document: keep slide-editing input suspended, while the start screen's + // File-tab navigation and document-free shortcuts remain available. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + if self.palette.is_none() && self.dialog.as_ref().is_none_or(|d| !d.modal()) && ctx.memory(|m| m.focused().is_none()) { + self.route_backstage_keys(events); + } + self.drain_requests(); + return; + } let modal = self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some(); // While slide text is being edited, the text owns the keyboard (#93). Tab and the arrow // keys move egui's keyboard focus onto ordinary widgets (the window's drag handle, ribbon @@ -1747,27 +3093,16 @@ impl SlideApp { ctx.memory_mut(|m| m.surrender_focus(id)); } if ctx.memory(|m| m.focused().is_some()) || modal { + // A stock field/modal/palette owns input now. Retire the custom fence, then visit + // controls in raw order; stock text/key events are left for the stock widgets. + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); return; } if self.backstage.is_some() { - // The File tab: its own keys (Esc goes back, arrows walk the side menu) and app - // shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the - // hidden presentation. - for e in events { - if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; - if backstage::key(self, key, mods) || !(mods.cmd || mods.alt) || menus::ui_shortcut(self, key, mods) { - continue; - } - // Engine shortcuts that don't touch the hidden slide (Ctrl+N). - if mods.cmd - && matches!(key, egui::Key::N) - && let Err(e) = self.session.key(key_name(key), mods) - { - self.set_status(e.to_string()); - } - } - } + self.suspend_ime_reset(viewport); + self.route_ime_controls_only(ctx, &events); + self.route_backstage_keys(events); self.drain_requests(); return; } @@ -1779,15 +3114,32 @@ impl SlideApp { } } for e in events { + // An earlier command in this raw batch may have settled the composition. Request the + // replacement at this ordered boundary, before the current event is considered. + self.note_settled_composition(ctx); match e { + egui::Event::ImeResetCompleted { request_id } => self.ime_reset_completed(request_id, ctx), + egui::Event::PasteImage(image) => match color_image_png(&image) { + Ok(png) => { + let params = json!({"name": "Pasted image.png", "data": deckcraft_engine::cmd::base64_encode(&png)}); + if let Err(e) = self.session.execute("insert.picture", ¶ms) { + self.set_status(e.to_string()); + } + } + Err(e) => self.set_status(e), + }, egui::Event::Text(t) => { + let revision = self.session.active().map(|d| d.revision); if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) && let Err(e) = self.session.type_text(&t) { self.set_status(e.to_string()); } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } } - egui::Event::Ime(ime) => self.ime_event(&ime), + egui::Event::Ime(ime) => self.ime_event(ctx, &ime), egui::Event::Copy => self.publish_clipboard(ctx, false), egui::Event::Cut => self.publish_clipboard(ctx, true), egui::Event::Paste(text) => { @@ -1796,7 +3148,10 @@ impl SlideApp { // A picture (TIFF from Keynote, a screenshot) lands when the paste has no text. // Text keeps its HTML (bold, italic, underline) when the host can read it. let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); - let img = if text.is_empty() { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + // egui 0.37 sends `PasteImage` directly; the services fallback stays for + // integrations that still send an empty `Paste`. Never insert twice. + let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + let revision = self.session.active().map(|d| d.revision); let r = match img { Some(png) => self .session @@ -1812,6 +3167,9 @@ impl SlideApp { if let Err(e) = r { self.set_status(e.to_string()); } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } } egui::Event::Key { key, pressed: true, modifiers, .. } => { if key == egui::Key::Tab { @@ -1829,10 +3187,14 @@ impl SlideApp { continue; } let name = key_name(key); + let revision = self.session.active().map(|d| d.revision); match self.session.key(name, mods) { Ok(_) => {} Err(e) => self.set_status(e.to_string()), } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } } _ => {} } @@ -1859,15 +3221,42 @@ impl SlideApp { } /// Lay out the whole window. + /// + /// Every return route, including fonts/layout discards, show mode, modal/Backstage and the + /// no-document paths, reaches the same outer reset observation and finalizer below. No early + /// route may publish or inspect IME output on its own. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - if !self.fonts_ready { - ctx.request_repaint(); + if !Self::root_viewport_admitted(&ctx) { + log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id()); + return; + } + self.ui_inner(ui); + // The last writer (all panels, dialogs, the custom producer and deferred child windows) + // has run. Observe the actual custom descriptor for this pass, then reconcile the reset + // lifetime against the actual committed output. This also runs for discarded passes. + self.ime_frame_viewport = Some(ctx.viewport_id()); + let custom = self.observe_ime(&ctx); + self.finalize_ime_reset(&ctx, custom); + } + + /// Run the admitted root UI pass, including setup gating, panels, canvas, dialogs, overlays, and + /// show mode. + fn ui_inner(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.ime_frame_viewport = Some(ctx.viewport_id()); + self.ime_pending = None; + if !self.ui_setup(&ctx) { + // This pass is discarded for the new fonts. The input events were already consumed + // by egui, so keep them queued for the next frame instead of silently losing them. + self.requeue_injected_synthetic(); return; } let t0 = now_ms(); let t = theme::Tokens::get(&ctx); - self.tick_media(&ctx); + self.note_ui_text(&ctx); + self.media.sync_video_textures(&ctx); + canvas::trackpad_twist(self, &ctx); eyedropper::tick(self, &ctx); self.opening_ui(&ctx); if self.show.is_some() { @@ -1875,6 +3264,7 @@ impl SlideApp { show::ui(self, ui); doc_window::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; + self.ui_end(&ctx); return; } self.sync_backstage(); @@ -1932,11 +3322,13 @@ impl SlideApp { self.drain_requests(); doc_window::show(self, &ctx); self.perf.frame_ms = now_ms() - t0; + self.ui_end(&ctx); } - /// Media clocks and video textures. Voices belong to the open presentation: closing the last - /// one, or switching decks, stops them. The editor's media also stops when its slide is left. - fn tick_media(&mut self, ctx: &egui::Context) { + /// Media clocks. Voices belong to the open presentation: closing the last one, or switching + /// decks, stops them. The editor's media also stops when its slide is left. This runs from + /// `logic` on every call so a minimized window keeps playing; texture upload stays in `ui`. + fn tick_media_clock(&mut self, ctx: &egui::Context) { let uid = self.session.active().map(|d| d.uid); self.media.set_document(uid); if !self.media.any_active() { @@ -1957,13 +3349,18 @@ impl SlideApp { self.media.stop(id); } } - self.media.tick(ctx, &mut self.session); + let playing = self.media.tick_clock(monotonic_secs(), &mut self.session); + if playing { + ctx.request_repaint_after(std::time::Duration::from_millis(15)); + } } pub fn open_url(&mut self, url: &str) { self.pending_urls.push(url.to_string()); } + /// Drain pending control requests, executing ordinary requests and admitting screenshots for + /// later saving and reply. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -1972,69 +3369,36 @@ impl SlideApp { continue; } let reply = req.reply.clone(); + let revision = self.session.active().map(|d| d.revision); match control::handle(self, ctx, &req) { control::Outcome::Done(v) => { let _ = reply.send(v); } control::Outcome::Screenshot { path } => { - self.shot_token += 1; - let token = self.shot_token; - self.queued_shots.push((token, now_ms() + 120.0, 0)); - self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); + let doc = self.session.active().map(|d| d.uid); + if let Err(e) = self.capture.admit(path, reply.clone(), doc, web_time::Instant::now()) { + let _ = reply.send(json!({"ok": false, "error": e})); + } } } + if self.session.active().map(|d| d.revision) != revision { + self.note_application_edit(ctx); + } } self.control_rx = Some(rx); } - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - self.queued_shots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + /// Save completed screenshots on the app thread and send exactly one reply each. + fn drain_screenshots(&mut self) { + let doc = self.session.active().map(|d| d.uid); + let completed = self.capture.drain(web_time::Instant::now(), doc); + for c in completed { + let v = control::save_screenshot(self, c.image.as_ref(), c.path.as_deref()); + // Keep the aggregate charged through RGBA/PNG/base64 encoding and the write. + self.capture.release(c.reserved); + let _ = c.reply.send(v); } } - - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_shots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_shots.remove(i); - let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); - } - } - let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; - } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); - false - }); - } } /// Engine key name for an egui key. @@ -2062,16 +3426,40 @@ pub fn key_name(key: egui::Key) -> &'static str { } } +/// Seconds since this process's monotonic origin. +/// +/// `web_time::Instant` is `std::time::Instant` natively and `performance.now()` on wasm; it never +/// jumps backwards and is not `Date.now()` (which is wall time and may be zero/frozen in a +/// browser). Use this for deadlines, show/media clocks and duration displays. +static CLOCK_ORIGIN: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Monotonic seconds since the first call. +pub fn monotonic_secs() -> f64 { + CLOCK_ORIGIN.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() +} + +/// Monotonic milliseconds. Kept for status/toast durations and frame timings; never wall time. pub fn now_ms() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + monotonic_secs() * 1000.0 +} + +/// Largest clipboard image accepted into the document: 16 megapixels, the same bound as +/// background removal. The image was already allocated by the integration; this keeps the +/// RGBA copy, PNG and base64 from multiplying an arbitrarily large paste before `insert.picture`. +const MAX_PASTE_IMAGE_PIXELS: usize = 16_000_000; + +/// Encode an egui color image as the PNG bytes `insert.picture` stores. +fn color_image_png(image: &egui::ColorImage) -> Result, String> { + let [w, h] = image.size; + let pixels = w.checked_mul(h).ok_or_else(|| "the pasted image is too large".to_string())?; + if pixels == 0 { + return Err("the pasted image is empty".into()); } - #[cfg(target_arch = "wasm32")] - { - js_now() + if pixels > MAX_PASTE_IMAGE_PIXELS { + return Err(format!("the pasted image is {w}×{h} pixels; the limit is 16 megapixels")); } + let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + Ok(deckcraft_render::Image { width: w as u32, height: h as u32, pixels: rgba }.to_png()) } /// Images for the browser download: the current slide, or every slide (named `1.png`, @@ -2102,11 +3490,6 @@ fn web_image_files( .collect() } -#[cfg(target_arch = "wasm32")] -fn js_now() -> f64 { - js_sys::Date::now() -} - #[cfg(test)] mod animpane_tests; #[cfg(test)] @@ -2132,6 +3515,8 @@ mod replacefonts_tests; #[cfg(test)] mod slide_size_tests; #[cfg(test)] +mod split_button_tests; +#[cfg(test)] mod texture_limit_tests; #[cfg(test)] mod ui_soak_tests; @@ -3185,7 +4570,7 @@ mod tests { SlideApp::new(session, Services::default()), ); harness.run_steps(2); - let id = egui::Id::new("notes_edit"); + let id = egui::Id::unique("notes_edit"); let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| { let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default(); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at)))); @@ -3442,6 +4827,7 @@ mod tests { unit: egui::MouseWheelUnit::Point, delta: step, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Trackpad, modifiers: egui::Modifiers::NONE, }); } @@ -3456,6 +4842,7 @@ mod tests { assert_eq!(crate::canvas::page_flick(egui::vec2(4.0, 10.0)), None); } + /// Test helper: place the pointer over the canvas centre, inject wheel notches, and step once. fn mouse_wheel(harness: &mut egui_kittest::Harness<'static, SlideApp>, notches: &[f32]) { let centre = harness.state().canvas_rect.unwrap().center(); let input = harness.input_mut(); @@ -3465,6 +4852,7 @@ mod tests { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, y), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); } @@ -3563,6 +4951,8 @@ mod tests { assert_eq!(slide_at(h.state()), 0, "switching away without a hover still drops the fraction"); } + /// Wheel input over a zoomed canvas pans it, while the same input outside the canvas must not + /// navigate slides. #[test] fn mouse_wheel_over_a_zoomed_canvas_pans_and_outside_the_canvas_does_not_navigate() { let mut h = flick_harness(); @@ -3583,6 +4973,7 @@ mod tests { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, modifiers: egui::Modifiers::NONE, }); h.run_steps(30); @@ -3715,4 +5106,731 @@ mod tests { assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}"); assert!(harness.state().dialog.is_some(), "Preferences stays open"); } + /// The status bar's zoom slider has no room for a visible label; it still needs an accessible + /// name, and adding that name must not change the slider's shared control geometry at either + /// size. + #[test] + fn status_zoom_slider_is_named_without_growing_the_status_bar() { + for size in [egui::vec2(900.0, 560.0), egui::vec2(1440.0, 900.0)] { + let mut harness = egui_kittest::Harness::builder().with_size(size).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(3); + let mut zoom = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Zoom"); + let rect = zoom.next().unwrap_or_else(|| panic!("a named Zoom slider at {size:?}")).rect(); + assert!(zoom.next().is_none(), "exactly one Zoom slider"); + // egui 0.37's slider uses its own `slider_width` and the theme's control height + // (22 px), not the old hard-coded 110x18. Assert that real, shared geometry so the + // status row stays consistent with every other control instead of loosening a bound. + let style = harness.ctx.global_style(); + let (expected_w, expected_h) = (style.spacing.slider_width, style.spacing.interact_size.y); + assert!( + (rect.width() - expected_w).abs() <= 0.5 && (rect.height() - expected_h).abs() <= 0.5, + "the status-bar slider uses the shared {expected_w}x{expected_h} control size at {size:?}: {rect:?}" + ); + } + } + + /// Accessibility names survive the port: the Preferences dialog and the Format Shape fill page + /// expose no unnamed input widgets, and the fill Transparency row has one named slider rather + /// than a visible label duplicated by `Slider::text`. + #[test] + fn preferences_and_format_pane_name_every_input() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(3); + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(3); + harness.check_accessibility(); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(harness.state().dialog.is_none(), "Preferences closed before the pane check"); + + harness.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + harness.state_mut().ui.pane = Some("format".into()); + harness.state_mut().ui.format_tab = "fill".into(); + harness.run_steps(3); + harness.check_accessibility(); + let sliders: Vec<_> = harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").collect(); + assert_eq!(sliders.len(), 1, "one fill Transparency slider"); + assert!(sliders[0].rect().width() > 40.0, "the slider keeps room next to its one visible label: {:?}", sliders[0].rect()); + } + + /// A control `ui.screenshot` is admitted once, issued from a settled real UI pass, completed by + /// kittest's screenshot callback, and replied to exactly once after a logic drain. + #[test] + fn ui_screenshot_round_trips_through_the_callable_slot() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + let (tx, rx) = std::sync::mpsc::channel(); + harness.state_mut().control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.screenshot", json!({})); + tx.send(req).unwrap(); + harness.run_steps(2); + assert_eq!(harness.state().capture.pending(), 1, "the request is admitted"); + harness.state_mut().capture.force_settle(); + harness.run_steps(4); + let reply = reply.recv_timeout(std::time::Duration::from_secs(5)).expect("screenshot reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(reply["result"]["width"].as_u64().is_some_and(|w| w > 0)); + assert_eq!(harness.state().capture.pending(), 0); + assert_eq!(harness.state().capture.reserved_bytes(), 0, "the reservation is released after the reply"); + } + + /// A hidden/logic-only iteration drains control requests and autosave/screenshot queues; it does + /// not need a painted frame for those side effects. + #[test] + fn logic_drains_control_without_a_ui_pass() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = ControlRequest::new("ui.inspect", json!({})); + tx.send(req).unwrap(); + app.logic(&ctx); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("reply from a logic-only call"); + assert_eq!(v["ok"], true, "{v}"); + } + + /// One synthetic pointer batch is injected at most once while it waits for a real UI pass. + /// eframe carries the whole `RawInput` from a logic-only frame into the next full pass, so a + /// document or layout change between them must retract the already-appended events too. + #[test] + fn synthetic_input_is_one_pending_batch_and_never_replays() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the one pending event is injected once"); + assert!(app.synthetic.is_empty()); + // eframe's next logic-only frame hands the same RawInput back to the hook. The batch is + // still unacknowledged, so it must not be appended again. + let mut carried = raw.clone(); + app.raw_input_hook(&ctx, &mut carried); + assert_eq!(carried.events.len(), 1, "the unacknowledged batch is not injected twice"); + + // A settled UI pass acknowledges it; the next batch may then be injected. This test has + // no renderer, so clear the frame's texture uploads explicitly: egui 0.37 panics when a + // `TexturesDelta` with pending uploads is dropped. + app.fonts_ready = true; + app.styled = true; + ctx.run_ui(Default::default(), |ui| app.ui(ui)).drop_without_applying_deltas(); + + // Changing the document before the next pass drops a batch queued for the old one. + app.queue_input(egui::Event::PointerMoved(egui::pos2(20.0, 20.0))); + app.session.execute("file.new", &json!({})).unwrap(); + let mut raw2 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw2); + assert!(raw2.events.is_empty(), "a batch queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + + // A pending batch for the current document is injected, then a document change before + // the next pass must retract it from the carried RawInput while keeping the newer click + // queued for the new document. + // The host supplies an identical pointer event before the injected batch: retraction must + // remove the batch at its recorded index, not the first identical window in the batch. + let moved = egui::Event::PointerMoved(egui::pos2(30.0, 30.0)); + app.queue_input(moved.clone()); + let mut raw3 = egui::RawInput::default(); + raw3.events.push(moved.clone()); + app.raw_input_hook(&ctx, &mut raw3); + assert_eq!(raw3.events, vec![moved.clone(), moved.clone()], "a settled pass permits the next batch"); + app.session.execute("file.new", &json!({})).unwrap(); + let newer = egui::Event::PointerMoved(egui::pos2(40.0, 40.0)); + app.queue_input(newer.clone()); + let host = egui::Event::Text("host".into()); + let mut carried3 = raw3.clone(); + carried3.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut carried3); + assert_eq!( + carried3.events, + vec![moved, host, newer], + "the stale batch is retracted, host input and the newer click for the changed document are kept" + ); + assert!(app.synthetic.is_empty(), "the newer batch was injected, not cleared with the stale one"); + assert!(app.synthetic_pending, "the newer batch is now the one in flight"); + + let mut raw4 = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw4); + assert!(raw4.events.is_empty(), "the in-flight batch is not injected twice"); + } + + /// The first real pass installs the interface fonts and asks egui to discard it before any + /// layout. A control click that arrived in that same pass has already been consumed by egui, + /// so the app must put it back in the queue for the next frame instead of acknowledging and + /// losing it. + #[test] + fn a_control_click_on_the_first_font_discard_is_requeued_and_lands() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + pass(&mut app, 1.0); + let size = app.session.active().unwrap().doc.slide_size; + app.session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + app.session.execute("edit.deselect", &json!({})).unwrap(); + pass(&mut app, 2.0); + let point = app.slide_rect.expect("the canvas has been laid out").center(); + assert!(app.session.active().unwrap().selection.shapes.is_empty()); + + // Back to the first-frame state: the next pass installs fonts and is discarded. + app.styled = false; + app.fonts_ready = false; + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + pass(&mut app, 3.0); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the discarded pass did not run the canvas"); + assert!(!app.synthetic.is_empty(), "the click was requeued for the next frame"); + + // One pointer event is injected per pass: move, press, release. + for time in [4.0, 5.0, 6.0] { + pass(&mut app, time); + } + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the requeued click selected the slide's shape"); + assert!(!app.synthetic_pending && app.synthetic.is_empty(), "the click was consumed exactly once"); + } + + /// A modal opening changes what a queued window-point event means even when the document and + /// canvas are unchanged; the injected batch is retracted before it reaches the modal. + #[test] + fn synthetic_batch_is_retracted_when_a_modal_opens_first() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "the click queued for the canvas does not reach the modal"); + assert!(!app.synthetic_pending); + } + + /// A control click is admitted for the full pass it was queued for. In that pass egui has + /// already derived `PointerState` from the injected press/release, so retracting only the raw + /// events would still let the click reach a document opened by the same pass's logic. The + /// control request therefore waits: the old target's real `logic` + `ui` pass consumes the + /// whole pointer gesture, and the next pass (no synthetic input) applies the document change. + #[test] + fn synthetic_click_is_consumed_on_its_old_target_before_logic_changes_the_document() { + // The destination has one shape over the whole slide, so a click that leaked into it + // would leave the shape selected. Opening it creates a fresh document state with an + // empty selection. + let mut destination = Session::with_new(); + let size = destination.active().unwrap().doc.slide_size; + destination.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap(); + let data = destination.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string(); + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let pass = |app: &mut SlideApp, time: f64| { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + }; + + pass(&mut app, 1.0); + app.run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap(); + assert!(!app.session.active().unwrap().selection.shapes.is_empty(), "the old document has a selection to clear"); + pass(&mut app, 2.0); + let old = app.session.active().unwrap().uid; + let slide = app.slide_rect.expect("the canvas has been laid out"); + // Well away from the inserted rectangle, so a press on the bare canvas clears the + // selection in the old document. + let point = egui::pos2(slide.min.x + slide.width() * 0.8, slide.min.y + slide.height() * 0.8); + app.queue_input(egui::Event::PointerMoved(point)); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + app.queue_input(egui::Event::PointerButton { + pos: point, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let (req, reply) = + ControlRequest::new("engine.execute", json!({"command": "file.openBytes", "params": {"name": "destination.deckcraft", "data": data}})); + tx.send(req).unwrap(); + + // `raw_input_hook` admits one pointer event per pass. Every one of those passes must stay + // on the old target; the control request may not create the destination before ui sees + // the press/release. + for time in [3.0, 4.0, 5.0] { + pass(&mut app, time); + assert_eq!(app.session.active().unwrap().uid, old, "the click's pass stays on its captured target"); + assert_eq!(app.session.documents().len(), 1, "logic must not open the destination under the click"); + } + assert!(!app.synthetic_pending, "the full passes consumed the gesture"); + assert!(app.session.active().unwrap().selection.shapes.is_empty(), "the press selected the bare canvas on the old document"); + + // With no synthetic input pending, the next logic applies the deferred request. The + // destination's full-slide shape is unselected because no pointer state from the old + // pass was replayed into it. + pass(&mut app, 6.0); + let reply = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the click"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.session.documents().len(), 2); + let active = app.session.active().unwrap(); + assert_ne!(active.uid, old, "the destination opened only after the click was consumed"); + assert!(!active.doc.slides[0].shapes.is_empty(), "the destination really has a clickable shape"); + assert!(active.selection.shapes.is_empty(), "the old pass's click did not select the destination shape"); + } + + /// A queued control click belongs to the full-screen show, opening window and palette + /// selection that were on screen when it was queued; each transition is its own target. + #[test] + fn input_target_tracks_slideshow_opening_and_palette_selection() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let base = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), base, "a slideshow is a different input target"); + let started = app.input_target(); + app.show.as_mut().unwrap().state.step += 1; + let stepped = app.input_target(); + assert_ne!(stepped, started, "the show step changes which click areas are live"); + let reading_now = app.show.as_ref().unwrap().reading; + app.show.as_mut().unwrap().reading = !reading_now; + let reading = app.input_target(); + assert_ne!(reading, stepped, "the reading layout moves the slide"); + app.show.as_mut().unwrap().grid = true; + let grid = app.input_target(); + assert_ne!(grid, reading, "See All Slides turns a click into a thumbnail choice"); + app.show.as_mut().unwrap().ended = true; + let ended = app.input_target(); + assert_ne!(ended, grid, "the end screen has different click targets"); + app.show = None; + assert_eq!(app.input_target(), base, "leaving the show returns to the editor target"); + + // Starting the show again is a new instance even at the same slide, so an old batch cannot + // match by value after an ABA transition. + app.start_show(0, true); + let first_instance = app.input_target(); + app.start_show(0, true); + assert_ne!(app.input_target(), first_instance, "a new show instance is a new target"); + app.show = None; + + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + assert_ne!(app.input_target(), base, "the background-open window is a different target"); + let opening = app.input_target(); + app.opening.as_mut().unwrap().generation = 2; + assert_ne!(app.input_target(), opening, "reopening the same path is a new attempt"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + let first = app.input_target(); + app.palette = Some(("open".into(), 1)); + assert_ne!(app.input_target(), first, "the palette's highlighted entry is part of the target"); + app.palette = None; + + app.ui.tab = "Insert".into(); + assert_ne!(app.input_target(), base, "another ribbon tab is another click target"); + let insert = app.input_target(); + app.ui.ribbon_collapsed = true; + assert_ne!(app.input_target(), insert, "collapsing the ribbon changes the layout under a click"); + app.ui.ribbon_collapsed = false; + app.ribbon_scroll.insert("Insert".into(), 64.0_f32.to_bits()); + assert_ne!(app.input_target(), insert, "scrolling the ribbon moves the buttons under a click"); + app.ui.tab = "Home".into(); + app.ribbon_scroll.remove("Insert"); + assert_eq!(app.input_target(), base); + } + + /// A click queued for the editor is retracted when a slideshow, background-open window or + /// different palette entry takes over before the input pass. + #[test] + fn input_target_changes_retract_a_queued_click() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let pointer = || egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + app.queue_input(pointer()); + app.start_show(0, true); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the slideshow"); + assert!(app.synthetic.is_empty()); + app.show = None; + + app.queue_input(pointer()); + app.opening = Some(crate::opening::Opening::for_test("/tmp/quarterly.pptx")); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for the editor does not enter the opening window"); + app.opening = None; + + app.palette = Some(("open".into(), 0)); + app.queue_input(pointer()); + app.palette = Some(("open".into(), 1)); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "a click queued for one palette selection does not activate another"); + assert!(app.synthetic.is_empty()); + } + + /// A logic-only callback does not consume events, but eframe carries the whole RawInput into + /// the next full pass. The hook injects without guessing the phase; when a control request + /// drained by that logic-only callback changes the document, the carried batch is retracted at + /// the next hook instead of being delivered into the new document. + #[test] + fn hidden_logic_pass_carries_a_synthetic_batch_and_drops_it_when_the_document_changes() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput::default(); + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "the batch is injected into the RawInput eframe will carry"); + assert!(app.synthetic.is_empty()); + assert!(app.synthetic_pending, "run_logic leaves the batch unacknowledged"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the hidden pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the hidden pass opened another document"); + assert!(app.synthetic_pending, "a logic-only callback cannot retract the carried batch"); + + // eframe hands the hidden RawInput to the next visible pass, appending new host input. + let mut visible = hidden.clone(); + visible.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + let host = egui::Event::Text("host".into()); + visible.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut visible); + assert_eq!(visible.events, vec![host.clone()], "the click queued for the old document is dropped"); + assert!(app.synthetic.is_empty()); + ctx.run_ui(visible, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.synthetic.is_empty(), "nothing replays after the visible pass"); + } + + /// eframe may run logic-only for a *visible* root when a redraw deadline is overdue. The + /// previous pass's InputState is still live, so retraction must not drain an identical event + /// from it; the batch belongs to the carried RawInput and is removed at the next hook. + #[test] + fn visible_logic_pass_does_not_retract_from_the_previous_input_state() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + let pointer = egui::Event::PointerMoved(egui::pos2(10.0, 10.0)); + + // Live InputState from the previous full pass, with an identical event at the same index. + let mut previous = egui::RawInput { time: Some(1.0), ..Default::default() }; + previous.events.push(pointer.clone()); + ctx.run_ui(previous, |_ui| {}).drop_without_applying_deltas(); + + app.queue_input(pointer.clone()); + let mut raw = egui::RawInput { time: Some(2.0), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events, vec![pointer.clone()]); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply from the visible logic pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2); + assert!(app.synthetic_pending, "the carried batch is still pending"); + assert_eq!(ctx.input(|i| i.events.clone()), vec![pointer.clone()], "the previous pass's events are untouched"); + + let mut next = raw.clone(); + next.time = Some(3.0); + let host = egui::Event::Text("host".into()); + next.events.push(host.clone()); + app.raw_input_hook(&ctx, &mut next); + assert_eq!(next.events, vec![host.clone()], "the stale batch is removed from the carried RawInput"); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty()); + } + + /// A batch injected in a logic-only pass is carried into the full pass with a refreshed + /// `RawInput::time`. That full pass must consume it on the old target: a control request + /// drained by the same logic waits until the next pass instead of changing the document + /// after egui has already derived the carried pointer state. + #[test] + fn carried_batch_is_consumed_before_the_full_pass_applies_a_control_change() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + let ctx = egui::Context::default(); + let (tx, rx) = std::sync::mpsc::channel(); + app.control_rx = Some(rx); + app.queue_input(egui::Event::PointerMoved(egui::pos2(10.0, 10.0))); + let mut hidden = egui::RawInput { time: Some(1.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.synthetic_pending, "the logic-only pass keeps the carried batch"); + + // eframe refreshes the carried input's time before calling the hook for the full pass. + let mut full = hidden.clone(); + full.time = Some(2.0); + app.raw_input_hook(&ctx, &mut full); + assert!(app.synthetic_pending, "the carried batch is not injected twice"); + + let (req, reply) = ControlRequest::new("engine.execute", json!({"command": "file.new", "params": {}})); + tx.send(req).unwrap(); + ctx.run_ui(full, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert_eq!(app.session.documents().len(), 1, "the full pass's UI sees its captured document"); + assert!(!app.synthetic_pending, "the full pass consumed the carried batch"); + + // The next pass has no synthetic input, so logic applies the deferred request. + let mut next = egui::RawInput { time: Some(3.0), ..Default::default() }; + next.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(false), occluded: Some(false), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut next); + ctx.run_ui(next, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let v = reply.recv_timeout(std::time::Duration::from_secs(1)).expect("control reply after the full pass"); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(app.session.documents().len(), 2, "the deferred control request ran on the next pass"); + } + + /// `visible() == false` does not prove a logic-only pass: a screenshot or + /// `RequestPaintWhileHidden` makes eframe run the full UI. The synthetic batch must be + /// delivered there, not postponed until the viewport is visible. + #[test] + fn hidden_full_pass_receives_synthetic_ui_input() { + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.fonts_ready = true; + app.styled = true; + app.dialog = Some(crate::dialogs::Dialog::new("replaceFonts")); + app.queue_input(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + let ctx = egui::Context::default(); + let mut hidden = egui::RawInput { time: Some(4.0), ..Default::default() }; + hidden.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(true), occluded: Some(true), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + app.raw_input_hook(&ctx, &mut hidden); + assert_eq!(hidden.events.len(), 1, "a forced hidden full pass still gets the event"); + assert!(app.synthetic_pending); + + ctx.run_ui(hidden, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + assert!(app.dialog.is_none(), "the synthetic Escape reached the UI in the hidden full pass"); + assert!(!app.synthetic_pending, "the full pass acknowledged the batch"); + assert!(app.synthetic.is_empty()); + } + + /// egui 0.37 carries a clipboard image as `Event::PasteImage`; it inserts once, without + /// asking the host clipboard service for the same image again. + #[test] + fn paste_image_event_inserts_one_picture() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 600.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + let image = egui::ColorImage::filled([2, 2], egui::Color32::from_rgb(1, 2, 3)); + harness.event(egui::Event::PasteImage(std::sync::Arc::new(image))); + harness.run_steps(2); + let after = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + assert_eq!(after, before + 1, "one image is inserted"); + } + + /// A minimized/occluded show keeps playing from logic alone: the clock advances on wall + /// time, an After transition advances the slide without a draw, and a visible pass resumes + /// from the show state instead of inventing a paused-show policy. + #[test] + fn hidden_viewport_advances_show_and_presenter_clocks_from_logic() { + /// Nested helper in the hidden-show test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 1000})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // A real visible UI pass starts the show and its clocks. There is no renderer here, so + // each frame's texture uploads are cleared explicitly (egui 0.37 panics on drop). + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let start = app.show.as_ref().expect("show started").clock_snapshot(); + assert_eq!(start.step_start, 10.0); + assert_eq!(start.show_start, 10.0); + + // Hidden logic starts at the last visible egui time, then advances on the monotonic + // instant. Two seconds later the slide's After: 1 s transition has advanced the show. + let t0 = web_time::Instant::now(); + app.show.as_mut().expect("show started").observe_visible_frame(10.0, t0); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0)); + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(2))); + let hidden = app.show.as_ref().expect("hidden show"); + assert_eq!(hidden.state.slide, 1, "the After transition advances without a draw"); + assert!((hidden.clock_snapshot().slide_start - 12.0).abs() < 1e-9); + assert!((hidden.presenter_elapsed(12.0) - 2.0).abs() < 1e-9, "presenter time keeps running"); + + // A later hidden tick does not replay the transition or stack time. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, t0 + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1); + assert!((app.show.as_ref().unwrap().clock_snapshot().slide_start - 12.0).abs() < 1e-9); + + // A visible pass re-anchors to egui's real time and keeps the state it reached hidden. + ctx.run_ui(raw_at(20.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let resumed = app.show.as_ref().expect("show resumed"); + assert_eq!(resumed.state.slide, 1); + assert!((resumed.presenter_elapsed(20.0) - 10.0).abs() < 1e-9); + assert!((resumed.clock_snapshot().slide_start - 12.0).abs() < 1e-9); + } + + /// A visible window can receive an overdue logic-only pass before the next paint. That pass + /// still carries the previous full frame's `InputState::time`; treating it as a visible frame + /// re-anchors the hidden clock to the later monotonic instant and loses elapsed show time. + /// Re-anchoring belongs to `show::ui`, which only runs on a real visible pass. + #[test] + fn overdue_visible_logic_does_not_reanchor_a_later_hidden_show() { + /// Nested helper in the overdue-logic test: build root `RawInput` with the given time and + /// visibility flags. + fn raw_at(time: f64, minimized: bool, occluded: bool) -> egui::RawInput { + let mut raw = egui::RawInput { time: Some(time), ..Default::default() }; + raw.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { minimized: Some(minimized), occluded: Some(occluded), native_pixels_per_point: Some(1.0), ..Default::default() }, + ); + raw + } + + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("slide.new", &json!({})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 0, "after": 2500})).unwrap(); + app.session.execute("transition.timing", &json!({"index": 1, "after": null})).unwrap(); + app.start_show(0, false); + let ctx = egui::Context::default(); + + // The full visible pass is the only pass that anchors the show: egui time 10. + ctx.run_ui(raw_at(10.0, false, false), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .drop_without_applying_deltas(); + let visible_anchor = web_time::Instant::now(); + + // An overdue visible logic-only pass does not refresh InputState::time. It must not move + // the hidden anchor from the full visible frame to this later instant. + let _ = ctx.run_logic(&raw_at(12.0, false, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(2))); + assert_eq!(ctx.input(|i| i.time), 10.0, "run_logic retains the last full pass's egui time"); + assert_eq!(app.show.as_ref().unwrap().state.slide, 0); + + // Three seconds after the full visible frame is past the slide's 2.5 s After delay. If the + // logic-only pass had re-anchored at +2 s, this hidden tick would still be only +1 s and + // the slide would not advance. + let _ = ctx.run_logic(&raw_at(12.0, true, false), |ctx| app.tick_hidden_show(ctx, visible_anchor + std::time::Duration::from_secs(3))); + assert_eq!(app.show.as_ref().unwrap().state.slide, 1, "time from the full visible frame is preserved through the logic-only pass"); + } } diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index fc23902a..a65358eb 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -288,10 +288,10 @@ impl MediaHost { self.videos.get(&id).and_then(|v| v.tex.as_ref()) } - /// Per frame: run the clocks, upload due video frames, apply loop/rewind, and report state to - /// the engine. Returns whether anything is playing (the caller keeps repainting). - pub fn tick(&mut self, ctx: &egui::Context, session: &mut Session) -> bool { - let now = ctx.input(|i| i.time); + /// Run the media clocks, apply loop/rewind and report state to the engine. Called from + /// `logic` on every call (including hidden logic), with monotonic seconds, so playback does + /// not depend on a painted frame. Returns whether anything is playing. + pub fn tick_clock(&mut self, now: f64, session: &mut Session) -> bool { self.player.tick(now); let mut ended = vec![]; for (id, owned) in &self.owners { @@ -306,21 +306,6 @@ impl MediaHost { for id in ended { self.stop(id); } - for (id, v) in &mut self.videos { - let Some(st) = self.player.status(key(*id)) else { continue }; - let Some(feed) = v.feed.as_mut() else { continue }; - let f = feed.frame_at(st.position); - if let Some(f) = f - && v.frame.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &f)) - { - let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); - match v.tex.as_mut() { - Some(t) => crate::textures::replace(ctx, t, img), - None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), - } - v.frame = Some(f); - } - } session.media_status = self .owners .keys() @@ -338,11 +323,27 @@ impl MediaHost { }) }) .collect(); - let playing = self.owners.keys().any(|id| self.is_playing(*id)); - if playing { - ctx.request_repaint_after(std::time::Duration::from_millis(15)); + self.owners.keys().any(|id| self.is_playing(*id)) + } + + /// Upload due video frames for the pass that is about to draw them. Called only from the real + /// UI phase: a hidden window accumulates no texture uploads and catches up when it appears. + pub fn sync_video_textures(&mut self, ctx: &egui::Context) { + for (id, v) in &mut self.videos { + let Some(st) = self.player.status(key(*id)) else { continue }; + let Some(feed) = v.feed.as_mut() else { continue }; + let f = feed.frame_at(st.position); + if let Some(f) = f + && v.frame.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &f)) + { + let img = egui::ColorImage::from_rgba_unmultiplied([f.width as usize, f.height as usize], &f.rgba); + match v.tex.as_mut() { + Some(t) => crate::textures::replace(ctx, t, img), + None => v.tex = Some(crate::textures::upload(ctx, format!("media-{id}"), img)), + } + v.frame = Some(f); + } } - playing } /// Carry out a `media.*` request from the engine for presentation `doc_uid`. @@ -480,7 +481,14 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: let button = |ui: &mut Ui, x: &mut f32, id: &str, paint: &dyn Fn(&egui::Painter, Rect)| -> bool { let r = Rect::from_min_size(pos2(*x, rect.min.y + 2.0), vec2(h - 4.0, h - 4.0)); *x += h - 2.0; - let resp = ui.interact(r, ui.id().with(("media_bar", id)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("media_bar", id)), Sense::click()); + let name = match id { + "play" => "Play or pause media", + "back" => "Back 0.25 seconds", + "fwd" => "Forward 0.25 seconds", + _ => "Media control", + }; + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); if resp.hovered() { painter.rect_filled(r, CornerRadius::same(3), Color32::from_white_alpha(28)); } @@ -534,7 +542,8 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: } painter.circle_filled(pos2(done.max.x, track.center().y), 5.0, white); let hit = track.expand2(vec2(4.0, 8.0)); - let resp = ui.interact(hit, ui.id().with("media_bar_track"), Sense::click_and_drag()); + let resp = ui.interact(hit, ui.scope_id().with("media_bar_track"), Sense::click_and_drag()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Media position")); if (resp.clicked() || resp.dragged()) && duration > 0.0 && let Some(p) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 318cacfb..5bbd0a35 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -10,6 +10,9 @@ use std::sync::mpsc::{Receiver, TryRecvError}; pub struct Opening { pub path: String, pub started_ms: f64, + /// Monotonic identity of this attempt. Cancelling and reopening the same path must not match + /// a click queued for the previous progress window's Cancel button. + pub(crate) generation: u64, rx: Receiver>, } @@ -18,6 +21,13 @@ impl Opening { pub fn name(&self) -> String { std::path::Path::new(&self.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.path.clone()) } + + /// A progress window for identity tests; the receiver never yields a file. + #[cfg(test)] + pub(crate) fn for_test(path: &str) -> Self { + let (_tx, rx) = std::sync::mpsc::channel(); + Opening { path: path.to_string(), started_ms: 0.0, generation: 0, rx } + } } impl SlideApp { @@ -44,7 +54,10 @@ impl SlideApp { let _ = tx.send(r); }); match spawned { - Ok(_) => self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), rx }), + Ok(_) => { + self.opening_generation = self.opening_generation.wrapping_add(1).max(1); + self.opening = Some(Opening { path: path.to_string(), started_ms: crate::now_ms(), generation: self.opening_generation, rx }); + } Err(_) => { if let Err(e) = self.open_path(path) { self.notify(e); @@ -90,7 +103,7 @@ impl SlideApp { let name = o.name(); let secs = ((crate::now_ms() - o.started_ms) / 1000.0).max(0.0); let mut cancel = false; - egui::Modal::new(egui::Id::new("deckcraft-opening")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("deckcraft-opening")).show(ctx, |ui| { ui.set_width(320.0); ui.horizontal(|ui| { ui.add(egui::Spinner::new().size(18.0)); @@ -178,13 +191,15 @@ mod tests { assert!(app.toast.as_ref().is_some_and(|t| t.text.contains("missing.pptx")), "the reason is shown"); } + /// Opening a path displays progress; cancelling closes the progress window without opening the + /// deck. #[test] fn the_progress_window_shows_and_cancel_closes_it() { use egui_kittest::kittest::Queryable; let mut app = SlideApp::new(Session::with_new(), crate::Services::default()); // A read that never answers: the window stays until Cancel. let (_tx, rx) = std::sync::mpsc::channel(); - app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), rx }); + app.opening = Some(Opening { path: "/decks/Big deck.pptx".into(), started_ms: crate::now_ms(), generation: 1, rx }); let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, app: &mut SlideApp| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 17af49b6..d7b42907 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -82,11 +82,11 @@ fn smartart(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(4.0); for (i, node) in d.nodes.iter().enumerate() { let Some(draft) = drafts.get_mut(i) else { break }; - let id = egui::Id::new(("smartart_line", gid.0, i)); + let id = egui::Id::unique(("smartart_line", gid.0, i)); ui.horizontal(|ui| { ui.add_space(16.0 * node.level as f32); ui.label("•"); - let te = egui::TextEdit::singleline(draft).id(id).desired_width(f32::INFINITY); + let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY); let r = ui.add(te); if app.ui.smartart_focus == Some(i) { r.request_focus(); @@ -204,6 +204,7 @@ fn color_button( }); } +/// Render the shape-format pane with fill/line, effects, size/properties, text, and picture tabs. fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -327,13 +328,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } "size" => { - let lock_id = egui::Id::new("fmt-lock-aspect"); + let lock_id = egui::Id::unique("fmt-lock-aspect"); let mut lock: bool = ui.data_mut(|d| d.get_temp(lock_id).unwrap_or(false)); let locked = lock; let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| { ui.horizontal(|ui| { ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { + if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { let mut p = json!({key: nv * unit}); if cmd == "shape.resize" && locked { p["lockAspect"] = json!(true); @@ -355,10 +356,10 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); ui.add_space(6.0); ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); - let id = ui.id().with(("alt", uid, sh.id.0)); + let id = ui.scope_id().with(("alt", uid, sh.id.0)); let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); let mut alt = widgets::text_draft(stored, &sh.descr); - let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text("Alt text").desired_rows(3).desired_width(f32::INFINITY)); if r.lost_focus() && alt != sh.descr { run(app, "shape.altText", json!({"text": alt})); } @@ -403,7 +404,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ] { ui.horizontal(|ui| { ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l)); - if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), v, 1.0, " pt", 60.0) { + if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), l, v, 1.0, " pt", 60.0) { run(app, "format.margins", json!({k: nv.max(0.0)})); } }); @@ -494,16 +495,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if !transparency { return; } - ui.label("Transparency"); + let label = ui.label("Transparency"); // Seed from this shape. A shared temp defaulted to 0, so a shape that was // already 40% transparent showed as opaque, and leaving the slider wrote 0. let seed = match &sh.fill { Some(Fill::Picture(pf)) => ((1.0 - pf.alpha.unwrap_or(1.0)) * 100.0) as f32, fill => solid_fill_transparency_percent(fill.as_ref()), }; - let id = ui.id().with(("ftr", sh.id.0)); + let id = ui.scope_id().with(("ftr", sh.id.0)); let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(seed)); - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); if r.drag_stopped() || r.lost_focus() { if let Some(t) = fill_transparency_to_apply(v, seed) { let _ = app.run("shape.fill", json!({"transparency": t})); @@ -551,7 +552,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.label("Width"); let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); - if let Some(v) = widgets::spinner(ui, "lw", w, 0.25, " pt", 60.0) { + if let Some(v) = widgets::spinner(ui, "lw", "Width", w, 0.25, " pt", 60.0) { run(app, "shape.line", json!({"width": v.max(0.0)})); } }); @@ -572,15 +573,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { /// `shape.line` with `{key: value}`. fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); - egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { for (l, v) in items { if ui.selectable_label(*v == current, *l).clicked() { run(app, "shape.line", json!({key: v})); } } }); + let _ = combo.response.labelled_by(field.id); }); } @@ -644,11 +646,11 @@ fn fx_slider( scale: f64, ) { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); // Hold the value while dragging; otherwise follow the shape. - let sid = ui.id().with(id); + let sid = ui.scope_id().with(id); let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); - let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)); + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(0)).labelled_by(label.id); if r.dragged() || r.has_focus() { ui.data_mut(|d| d.insert_temp(sid, v)); } else { @@ -675,9 +677,9 @@ fn pick_fill_picture(app: &mut SlideApp) { /// when it changes. fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let mut v = value; - let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { run(app, "shape.fill", json!({"pictureProps": {key: v * scale}})); } @@ -717,24 +719,25 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); - egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { for (k, l) in opts { if ui.selectable_label(*k == cur.as_str(), *l).clicked() { run(app, "shape.fill", json!({"pictureProps": {key: k}})); } } }); + let _ = combo.response.labelled_by(field.id); }); } } PictureMode::Stretch { fill_rect } => { for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let mut v = fill_rect[i] * 100.0; - let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)); + let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { let mut rect = *fill_rect; rect[i] = v / 100.0; @@ -769,23 +772,24 @@ fn preset_label(name: &str) -> String { /// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { for o in options { if ui.selectable_label(*o == cur, preset_label(o)).clicked() { run(app, "shape.effects", json!({"threeD": {key: o}})); } } }); + let _ = combo.response.labelled_by(field.id); }); } /// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); let mut v = value; - let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { run(app, "shape.effects", json!({"threeD": {key: v}})); } @@ -933,6 +937,8 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render picture-fill background controls and route transparency, tiling, and tile geometry +/// changes through the engine. fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) { use deckcraft_model::style::PictureMode; let alpha = pf.alpha.unwrap_or(1.0); @@ -953,7 +959,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo ] { ui.horizontal(|ui| { ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); - if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { + if let Some(v) = widgets::spinner(ui, id, label, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { let key = match id { "bg-ox" => "offsetX", "bg-oy" => "offsetY", @@ -976,8 +982,8 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "Top Left", }; ui.horizontal(|ui| { - ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); - egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [ ("tl", "Top Left"), ("t", "Top"), @@ -994,6 +1000,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo } } }); + let _ = combo.response.labelled_by(field.id); }); let mirror_name = match flip.as_str() { "x" => "Horizontal", @@ -1002,17 +1009,19 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "None", }; ui.horizontal(|ui| { - ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); - egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { if ui.selectable_label(flip == k, l).clicked() { run(app, "design.background", json!({"pictureProps": {"mirror": k}})); } } }); + let _ = combo.response.labelled_by(field.id); }); } +/// Render the animation pane and route its controls through the engine's animation commands. fn animation(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -1026,7 +1035,7 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { // Play From starts at the effect last clicked here, or else the first effect of the selected // shape; with nothing selected it is Play All. Reordering or removing forgets the click: the // index would point at another effect. - let pick_id = egui::Id::new(("anim_pane_pick", st.uid, st.selection.slide)); + let pick_id = egui::Id::unique(("anim_pane_pick", st.uid, st.selection.slide)); let pick = ui.data(|m| m.get_temp::(pick_id)); // A triggered effect is not on the click sequence, so it can't be played from. let eligible = |i: usize| anims.get(i).is_some_and(|a| selected.contains(&a.shape.0)) && tl.start_of(i).is_some(); @@ -1100,17 +1109,17 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { pos2(x_of(b.start + b.span).max(x_of(b.start) + 4.0), r.center().y + 5.0), ); let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0)); - let body_resp = ui.interact(bar, ui.id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); - body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Timing bar {}", i + 1))); - let edge_resp = ui.interact(edge, ui.id().with(("anim_edge", i)), Sense::drag()); - edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("Duration handle {}", i + 1))); + let body_resp = ui.interact(bar, ui.scope_id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); + body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Timing bar {}", i + 1))); + let edge_resp = ui.interact(edge, ui.scope_id().with(("anim_edge", i)), Sense::drag()); + edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Duration handle {}", i + 1))); if edge_resp.hovered() || edge_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } else if body_resp.hovered() || body_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } // The drag offset is remembered: the pointer's press origin is gone on release. - let dx_id = ui.id().with(("anim_drag_dx", i)); + let dx_id = ui.scope_id().with(("anim_drag_dx", i)); let live = ui.input(|inp| match (inp.pointer.press_origin(), inp.pointer.interact_pos()) { (Some(o), Some(p)) => Some(p.x - o.x), _ => None, @@ -1290,6 +1299,7 @@ fn focus_comment(app: &mut SlideApp, slide: usize, index: usize) { app.ui.comment_focus = Some((slide, index)); } +/// Render one comment card, preserving its slide/index identity and focus state through actions. fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: &deckcraft_model::Comment, focused: bool) { let t = Tokens::get(ui.ctx()); let stroke = if focused { Stroke::new(2.0, t.accent) } else { Stroke::NONE }; @@ -1325,7 +1335,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: ui.label(egui::RichText::new(format!("↳ {}: {}", r.author, r.text)).color(t.text_dim)); } ui.horizontal(|ui| { - let id = ui.id().with(("reply", slide, index)); + let id = ui.scope_id().with(("reply", slide, index)); let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { @@ -1350,7 +1360,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: }); }); if focused { - let key = egui::Id::new("comment-focus-scrolled"); + let key = egui::Id::unique("comment-focus-scrolled"); let seen = ui.data(|d| d.get_temp::<(usize, usize)>(key)); if seen != Some((slide, index)) { ui.scroll_to_rect(shown.response.rect, Some(egui::Align::Center)); @@ -1368,6 +1378,8 @@ struct IdeaThumbs { tex: Vec<(String, egui::TextureHandle, u32, u32)>, } +/// Render per-slide Design Ideas and whole-presentation theme thumbnails; a click runs +/// `design.applyIdea` or `design.theme`. fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); @@ -1382,7 +1394,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { let px = (w * ppp).round().max(16.0) as u32; let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx); let themes = deckcraft_model::theme::builtin_themes(); - let cache_id = egui::Id::new("design-idea-thumbs"); + let cache_id = egui::Id::unique("design-idea-thumbs"); let key = (key_doc.0, key_doc.1, idx, px); let mut cache: IdeaThumbs = ui.ctx().data_mut(|d| d.get_temp(cache_id)).unwrap_or_default(); if cache.key != key || cache.tex.is_empty() { @@ -1463,11 +1475,11 @@ pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { /// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { ui.horizontal(|ui| { - ui.label("Transparency"); - let id = ui.id().with(("text-transparency", sh.id)); + let label = ui.label("Transparency"); + let id = ui.scope_id().with(("text-transparency", sh.id)); let stored = (text_transparency_of(sh) * 100.0) as f32; let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")).labelled_by(label.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { let _ = app.run("format.textTransparency", json!({"value": v as f64 / 100.0})); ui.data_mut(|d| d.remove::(id)); @@ -1572,9 +1584,14 @@ mod tests { assert!(clear.iter().any(|v| (*v - 40.0).abs() < 0.5), "40% fill should show 40%, got {clear:?}"); assert_eq!(solid_alpha(h.state(), b), Some(60_000)); - h.get_by_label("Transparency").click(); + // 0.37 exposes the slider's numeric entry as a SpinButton with the same name; target the + // Slider itself so the click lands on the slider, not its text field. A click at its + // middle is a real edit (50%), not a side effect of opening the pane or selecting b. + h.get_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").click(); h.run_steps(2); - assert_eq!(solid_alpha(h.state(), b), Some(60_000)); + assert_eq!(solid_alpha(h.state(), b), Some(50_000)); + let changed: Vec = slider_values(&h); + assert!(changed.iter().any(|v| (*v - 50.0).abs() < 0.5), "the changed slider shows 50%, got {changed:?}"); } /// A blank slide with one selected rectangle, its fill set by `fill` (none for an automatic fill), @@ -1611,6 +1628,7 @@ mod tests { h.query_all_by_role(egui::accesskit::Role::Button).filter(|n| n.accesskit_node().label().is_some_and(|l| l.trim() == "Color")).count() } + /// Fill colour and transparency controls appear only for fill kinds they can actually modify. #[test] fn fill_color_and_transparency_show_only_where_they_change_the_fill() { let gradient = json!({"gradient": {"stops": [[0, "accent1"], [1, "accent2"]], "angle": 90}}); @@ -1625,10 +1643,17 @@ mod tests { let h = fill_pane(fill); let fill_color = color_buttons(&h) == 2; assert_eq!(fill_color, color, "{name}: fill Color button"); - assert_eq!(h.query_all_by_label("Transparency").count(), transparency, "{name}: Transparency rows"); + // 0.37 gives each slider's numeric entry its own SpinButton with the same name, so count + // the sliders themselves: one per visible Transparency row. + assert_eq!( + h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Transparency").count(), + transparency, + "{name}: Transparency rows" + ); } } + /// The picture-fill transparency slider reflects and edits the picture fill's own transparency. #[test] fn picture_fill_transparency_slider_shows_the_picture_transparency() { let mut h = fill_pane(Some(picture_fill(0.4))); @@ -1641,7 +1666,14 @@ mod tests { }; let shown = slider_values(&h); assert!(shown.iter().any(|v| (*v - 40.0).abs() < 0.5), "a 40% transparent picture should show 40%, got {shown:?}"); - h.get_by_label("Transparency").click(); + // 0.37 exposes the slider's numeric entry as a SpinButton with the same name and excludes + // a label that names another control from label queries, so find the painted label itself: + // clicking it must leave the picture's transparency alone. + let label = h + .get_all_by_role(egui::accesskit::Role::Label) + .find(|n| n.value().as_deref() == Some("Transparency")) + .expect("the Transparency label is there"); + label.click(); h.run_steps(2); assert!(alpha(&h).is_some_and(|a| (a - 0.6).abs() < 1e-9), "{:?}", alpha(&h)); } diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index fb8e796b..02977307 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -60,27 +60,37 @@ fn paragraph_button_aligns_text_in_one_undo() { assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Paragraph…"); } +/// Paragraph-dialog tab settings apply the chosen stop and return to the paragraph controls. #[test] fn tabs_from_paragraph_applies_a_stop_and_returns() { let mut h = harness(); h.get_by_label("Paragraph…").click(); h.run_steps(2); h.state_mut().dialog.as_mut().unwrap().fields.insert("align".into(), "right".into()); - h.get_by_label("Tabs…").click_accesskit(); - h.run_steps(2); + // Settle the anchored window's geometry before hit-testing the button with the real pointer + // path. egui 0.37 keeps a window invisible for its first sizing frame; AccessKit can activate + // such a node, but a pointer must actually hit the visible button inside the window. + h.run_steps(3); + let tabs = h.get_by_label("Tabs…"); + let rect = tabs.rect(); + assert!(rect.min.x >= 0.0 && rect.max.x <= 1440.0 && rect.min.y >= 0.0 && rect.max.y <= 900.0, "Tabs… has settled on screen: {rect:?}"); + tabs.click(); + // The click switches the dialog at the end of one pass; wait for that and the first visible + // frame before querying the Tabs window's buttons. + h.run_steps(3); assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("tabs"), "status {:?}", h.state().status); { let d = h.state_mut().dialog.as_mut().unwrap(); d.fields.insert("stops".into(), "2:ctr".into()); } - h.get_by_label("OK").click_accesskit(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click_accesskit(); // The Tabs OK frame restores Paragraph; the next frame fills its fields. h.run_steps(2); assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("paragraph"), "status {:?}", h.state().status); assert_eq!(h.state().dialog.as_ref().unwrap().fields.get("align").map(String::as_str), Some("right")); assert_eq!(tabs_of(&h), vec![(144.0, "ctr".into())]); assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Tabs…"); - h.get_by_label("Cancel").click_accesskit(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click_accesskit(); h.run_steps(1); assert!(h.state().dialog.is_none()); assert_eq!(align_of(&h).as_deref(), Some("ctr"), "cancelling Paragraph leaves the alignment shape.insert wrote"); diff --git a/crates/ui-egui/src/pattern_fill_tests.rs b/crates/ui-egui/src/pattern_fill_tests.rs index 66ad4a8f..f7f5a784 100644 --- a/crates/ui-egui/src/pattern_fill_tests.rs +++ b/crates/ui-egui/src/pattern_fill_tests.rs @@ -136,12 +136,15 @@ fn pattern_gallery_shows_which_swatch_has_keyboard_focus() { assert!(outlined(&h), "the focused swatch is outlined"); } +/// A solid fill shows its transparency slider and does not expose a pattern gallery. #[test] fn shape_solid_fill_keeps_its_transparency_without_a_pattern_gallery() { let (mut s, _) = patterned_shape(); s.execute("shape.fill", &json!({"color": "accent2"})).unwrap(); let h = harness(SlideApp::new(s, Services::default()), "format"); - let _ = h.get_by_label("Transparency"); + // 0.37 exposes the slider's numeric entry as a SpinButton with the same name; the slider is + // the control the user drags. + let _ = h.get_by_role_and_label(Role::Slider, "Transparency"); assert!(h.query_by_label("Foreground color").is_none()); assert!(h.query_by_label("Zig zag").is_none()); } diff --git a/crates/ui-egui/src/photo_album_tests.rs b/crates/ui-egui/src/photo_album_tests.rs index b97abe4e..b28a1c17 100644 --- a/crates/ui-egui/src/photo_album_tests.rs +++ b/crates/ui-egui/src/photo_album_tests.rs @@ -76,6 +76,8 @@ fn create_inserts_the_album_in_one_step() { assert_eq!(text, ["Text Box"]); } +/// Updating photo-album data reflows the already-open album without requiring it to be +/// reopened. #[test] fn update_reflows_the_open_album() { let data = deckcraft_engine::cmd::base64_encode(&png()); @@ -89,7 +91,9 @@ fn update_reflows_the_open_album() { assert!(h.query_by_label("Update").is_some()); h.get_by_label("ALL pictures black and white").click(); h.run_steps(2); - h.get_by_label("1 picture").click(); + let layout = h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Picture layout"); + assert_eq!(layout.value().as_deref(), Some("1 picture")); + layout.click(); h.run_steps(2); h.get_by_label("2 pictures").click(); h.run_steps(2); diff --git a/crates/ui-egui/src/preferences_tests.rs b/crates/ui-egui/src/preferences_tests.rs index a0e4cba4..c3a3ebfb 100644 --- a/crates/ui-egui/src/preferences_tests.rs +++ b/crates/ui-egui/src/preferences_tests.rs @@ -2,7 +2,7 @@ //! window's close button put the settings back the way they were (#752, reported as #878). use deckcraft_engine::Session; -use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::kittest::Queryable; use egui_kittest::{Harness, Node}; use serde_json::json; @@ -11,7 +11,10 @@ use crate::{Services, SlideApp}; /// The slider itself, not the label drawn beside it. fn interface_size<'a>(h: &'a Harness<'_, SlideApp>) -> Node<'a> { - h.get_all_by_label("Interface size").find(|n| n.accesskit_node().role() == egui::accesskit::Role::Slider).expect("the slider is there") + let mut sliders = h.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size"); + let slider = sliders.next().expect("the slider is there"); + assert!(sliders.next().is_none(), "exactly one Interface size slider"); + slider } fn harness() -> Harness<'static, SlideApp> { diff --git a/crates/ui-egui/src/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index c42efce2..449b4b46 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::{Services, SlideApp}; +/// Build the standard Replace Fonts UI harness with a fresh session. fn harness() -> Harness<'static, SlideApp> { let mut s = Session::with_new(); s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); @@ -15,11 +16,10 @@ fn harness() -> Harness<'static, SlideApp> { s.execute("format.font", &json!({"family": "Comic Neue"})).unwrap(); s.execute("format.size", &json!({"size": 40})).unwrap(); let app = SlideApp::new(s, Services::default()); - // Home's ribbon is wider than it was when this test was written: at 1400 the Editing group - // begins at x=1398, so the Replace Fonts button is two pixels wide on screen and a click at - // its centre lands outside the window. The ribbon does not scroll sideways, so the window has - // to be wide enough to hold it -- 1800 is what the other full-ribbon tests here use. - let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).with_step_dt(1.0 / 60.0).build_ui_state( + // The product's default window. The Home ribbon overflows here with the bundled interface + // fonts; the ribbon's own edge chevron must make its Editing group reachable instead of the + // regression widening the fixture past the width the app actually opens at. + let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut SlideApp| { app.logic(ui.ctx()); app.ui(ui); @@ -42,10 +42,29 @@ fn run_font(h: &Harness<'_, SlideApp>) -> Option { .and_then(|r| r.props.font.clone()) } +/// Scroll the ribbon to its right edge through its own visible overflow control. +fn reveal_ribbon_end(h: &mut Harness<'_, SlideApp>) { + for _ in 0..16 { + let Some(cue) = h.query_by_label("Scroll ribbon right") else { break }; + cue.click(); + h.run_steps(1); + } +} + +/// The ribbon command opens Replace Fonts; its Replace button updates the document and leaves +/// the dialog open with a confirmation. #[test] fn ribbon_button_opens_the_dialog_and_replaces() { let mut h = harness(); - h.get_by_label("Replace Fonts…").click(); + reveal_ribbon_end(&mut h); + let button = h.get_by_label("Replace Fonts…"); + assert!(button.rect().max.x <= 1440.0 && button.rect().min.x >= 0.0, "Replace Fonts… is on screen at the default width: {:?}", button.rect()); + if let Some(left) = h.query_by_label("Scroll ribbon left") { + let cue = left.rect(); + assert!(!button.rect().intersects(cue), "the left overflow cue {cue:?} does not cover Replace Fonts… {:?}", button.rect()); + assert!(button.rect().min.x >= cue.max.x, "ribbon content starts after the reserved left chevron gutter"); + } + button.click(); h.run_steps(3); assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("replaceFonts")); // Pick the font to replace and type the new one, the way a person would. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 7f36a25a..117e08e8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -8,7 +8,9 @@ use serde_json::{Value, json}; use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; -use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex}; +use crate::widgets::{ + self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex, small_button_named, +}; pub const TITLE_H: f32 = 38.0; @@ -58,7 +60,9 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { egui::Panel::top("title_bar").exact_size(height).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title bar moves the window. - let drag = ui.interact(rect, ui.id().with("titledrag"), Sense::click_and_drag()); + let drag = ui.interact(rect, ui.scope_id().with("titledrag"), Sense::click_and_drag()); + // The drag surface is not a button: a full-width Button would be announced as one. + drag.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Window, true, "Window title bar")); if drag.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -170,8 +174,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { { let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); - let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File")); + let resp = ui.interact(r, ui.scope_id().with("tab_file"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "File")); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); } @@ -186,7 +190,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let font = theme::font(13.0); let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); - let resp = ui.interact(r, ui.id().with(("tab", name)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("tab", name)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); let active = app.ui.tab == name; if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); @@ -219,43 +224,101 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_filled(card.translate(vec2(0.0, 1.0)), CornerRadius::same(10), t.card_border); ui.painter().rect_filled(card, CornerRadius::same(10), t.card); let inner = Rect::from_min_size(pos2(card.min.x + 10.0, card.min.y + 5.0), vec2(card.width() - 20.0, RIBBON_H)); - let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Min))); + let mut cui = ui.new_child(egui::UiBuilder::new().max_rect(inner)); cui.set_clip_rect(inner.expand2(vec2(0.0, 3.0))); - cui.spacing_mut().item_spacing.x = 1.0; - let ui = &mut cui; + // Reserve a gutter on both sides for the overflow chevrons. They are painted and + // interacted in that gutter, never on top of the first/last visible button: the bar they + // reveal must not steal the click they were meant to make reachable. + let edge = 18.0_f32.min(inner.width() * 0.25); + let scroll_rect = inner.shrink2(vec2(edge, 0.0)); + let mut sui = ui.new_child(egui::UiBuilder::new().max_rect(scroll_rect)); + sui.set_clip_rect(scroll_rect.expand2(vec2(0.0, 3.0))); let tab = app.ui.tab.clone(); - match tab.as_str() { - "Home" => home(app, ui), - "Insert" => insert(app, ui), - "Draw" => draw(app, ui), - "Design" => design(app, ui), - "Transitions" => transitions(app, ui), - "Animations" => animations(app, ui), - "Slide Show" => slide_show(app, ui), - "Record" => record(app, ui), - "Review" => review(app, ui), - "View" => view(app, ui), - "Shape Format" => shape_format(app, ui), - "Picture Format" => picture_format(app, ui), - "Table Design" => table_design(app, ui), - "Layout" => table_layout(app, ui), - "Chart Design" => chart_design(app, ui), - "SmartArt Design" => smartart_design(app, ui), - "Playback" => playback(app, ui), - "Slide Master" => slide_master(app, ui), - "Handout Master" => handout_master(app, ui), - "Notes Master" => notes_master(app, ui), - _ => {} + // The card used to clip a too-wide group row with no way to reach the rest. A horizontal + // scroll area plus edge chevrons keeps every group reachable at the default 1440 px + // window, including Home's Editing group (Replace Fonts…). The bar is hidden; the + // chevrons are the visible overflow control and are accessible buttons of their own. + let request_id = cui.make_persistent_id(("ribbon_scroll_request", tab.as_str())); + let requested = cui.ctx().data_mut(|d| d.remove_temp::(request_id)); + let mut area = egui::ScrollArea::horizontal().id_salt(("ribbon_scroll", tab.as_str())); + if let Some(offset) = requested { + area = area.horizontal_scroll_offset(offset); + } + let scroll = + area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).auto_shrink([false, false]).show(&mut sui, |ui| { + ui.set_min_height(RIBBON_H); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + match tab.as_str() { + "Home" => home(app, ui), + "Insert" => insert(app, ui), + "Draw" => draw(app, ui), + "Design" => design(app, ui), + "Transitions" => transitions(app, ui), + "Animations" => animations(app, ui), + "Slide Show" => slide_show(app, ui), + "Record" => record(app, ui), + "Review" => review(app, ui), + "View" => view(app, ui), + "Shape Format" => shape_format(app, ui), + "Picture Format" => picture_format(app, ui), + "Table Design" => table_design(app, ui), + "Layout" => table_layout(app, ui), + "Chart Design" => chart_design(app, ui), + "SmartArt Design" => smartart_design(app, ui), + "Playback" => playback(app, ui), + "Slide Master" => slide_master(app, ui), + "Handout Master" => handout_master(app, ui), + "Notes Master" => notes_master(app, ui), + _ => {} + } + }); + }); + let r = scroll.inner_rect; + let overflow = (scroll.content_size.x - r.width()).max(0.0); + let offset = scroll.state.clamped_offset().x; + // Record the rendered offset for `InputTarget`: the same window point hits a different + // button when the ribbon scrolls between queueing and delivering a control click. + app.ribbon_scroll.insert(tab.clone(), offset.to_bits()); + let step = 160.0; + let mut next = None; + if offset > 1.0 { + let left = Rect::from_min_max(pos2((r.left() - edge).max(inner.left()), r.top()), pos2(r.left(), r.bottom())); + if edge_cue(&mut cui, left, "‹", "Scroll ribbon left", &t) { + next = Some(offset - step); + } + } + if overflow - offset > 1.0 { + let right = Rect::from_min_max(pos2(r.right(), r.top()), pos2((r.right() + edge).min(inner.right()), r.bottom())); + if edge_cue(&mut cui, right, "›", "Scroll ribbon right", &t) { + next = Some(offset + step); + } + } + if let Some(x) = next { + ui.ctx().data_mut(|d| d.insert_temp(request_id, x.clamp(0.0, overflow))); + ui.ctx().request_repaint(); } }); } +/// A chevron over a ribbon edge that scrolls it; `true` on the frame it is clicked. +fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { + let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, hint)); + let ink = if resp.hovered() { t.text } else { t.text_dim }; + ui.painter().rect_filled(rect, CornerRadius::ZERO, t.card); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, glyph, egui::FontId::proportional(18.0), ink); + resp.clicked() +} + +/// Draw a large ribbon command button, honour its enabled state, run the command on click, and +/// attach its menu. fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) { let en = enabled(app, id); let r = big_button(ui, icon, label, en); if id == "insert.dateTime" { let name = label.replace('\n', " "); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, &name)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, &name)); } let r = r.on_hover_text(tip(id, label)); if r.clicked() { @@ -269,10 +332,10 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { let en = enabled(app, "insert.photoAlbum"); let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album…")); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); if let Some(a) = &arrow { let _ = a.clone().on_hover_text("Photo Album options"); - a.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, en, "Photo Album options")); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); } if main.clicked() { run(app, "insert.photoAlbum", json!({})); @@ -420,6 +483,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { }); } +/// Render the Home Slides group: New Slide, Layout, Reset, and Section controls. fn slides_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); @@ -427,42 +491,52 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { run(app, "slide.new", json!({})); } if let Some(a) = arrow { - egui::Popup::menu(&a).width(420.0).show(|ui| { - layout_menu(app, ui, "slide.new"); - ui.separator(); - if ui.button("Duplicate Selected Slides").clicked() { - run(app, "slide.duplicate", json!({})); - } - if ui.button("Slides from Outline…").clicked() { - app.dialog = Some(crate::dialogs::Dialog::new("outline")); - } - let desktop = app.services.pick_open.is_some(); - for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { - let r = ui.add_enabled(desktop, egui::Button::new(label)); - if r.clicked() { - run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); - } - r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); - } - }); + egui::Popup::menu(&a).width(420.0).show(|ui| new_slide_menu(app, ui)); } rows(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); - egui::Popup::menu(&r).width(420.0).show(|ui| layout_menu(app, ui, "slide.layout")); + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layout", enabled(app, "slide.layout"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| layout_menu(app, ui, "slide.layout")); + } small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); - let (r, _) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); - egui::Popup::menu(&r).show(|ui| { - if ui.button("Add Section").clicked() { - run(app, "section.add", json!({})); - } - if ui.button("Remove All Sections").clicked() { - run(app, "section.removeAll", json!({})); - } - }); + let (main, arrow) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| section_menu(app, ui)); + } }); }); } +/// The New Slide button's menu: layouts, duplication, outline import and slide reuse. +fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { + layout_menu(app, ui, "slide.new"); + ui.separator(); + if ui.button("Duplicate Selected Slides").clicked() { + run(app, "slide.duplicate", json!({})); + } + if ui.button("Slides from Outline…").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("outline")); + } + let desktop = app.services.pick_open.is_some(); + for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { + let r = ui.add_enabled(desktop, egui::Button::new(label)); + if r.clicked() { + run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); + } + r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + } +} + +/// The Section split button's menu. +fn section_menu(app: &mut SlideApp, ui: &mut Ui) { + if ui.button("Add Section").clicked() { + run(app, "section.add", json!({})); + } + if ui.button("Remove All Sections").clicked() { + run(app, "section.removeAll", json!({})); + } +} + pub fn font_families(app: &SlideApp) -> Vec { let mut v = vec![]; if let Some(m) = app.session.active().and_then(|d| d.doc.masters.first()) { @@ -677,7 +751,7 @@ fn smartart_gallery( None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), }; ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, *label)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); r.on_hover_text(*label) }) .inner; @@ -705,6 +779,7 @@ fn smartart_gallery( }); } +/// Render paragraph formatting controls using the current paragraph state. fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bullets"); group(ui, |ui| { @@ -712,7 +787,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 1.0; let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - let (m, a) = small_button_ex(ui, Icon::Bullets, "", en, b("bullets"), true); + let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); if m.on_hover_text("Bullets").clicked() { run(app, "format.bullets", json!({})); } @@ -734,7 +809,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); } - let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true); + let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); if m.on_hover_text("Numbering").clicked() { run(app, "format.numbering", json!({})); } @@ -929,6 +1004,7 @@ fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { }); } +/// Render drawing controls for shapes, text, arrange, quick styles, fill, and outline. fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let en = enabled(app, "shape.insert"); @@ -972,9 +1048,9 @@ fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { if let Some(a) = a { widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); } - let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| effects_menu(app, ui)); + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); } }); }); @@ -1344,7 +1420,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { /// Hover-to-size table grid. Returns (rows, cols) when clicked. pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { let t = Tokens::get(ui.ctx()); - let id = ui.id().with("tgrid"); + let id = ui.scope_id().with("tgrid"); let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); let cell = 16.0; let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; @@ -1408,7 +1484,7 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng fn icon_library(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (color, hover) = (t.text, t.hover); - let id = ui.id().with("icon-query"); + let id = ui.scope_id().with("icon-query"); let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); let q = query.trim().to_lowercase(); @@ -1518,6 +1594,8 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { // ---------- Design ---------- +/// Render the Design tab's themes, variants, colours, fonts, effects, background, and slide +/// size controls. fn design(app: &mut SlideApp, ui: &mut Ui) { let themes = deckcraft_model::theme::builtin_themes(); let cur = app.session.active().and_then(|d| d.doc.masters.first().map(|m| m.theme.name.clone())).unwrap_or_default(); @@ -1568,7 +1646,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Colors")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); egui::Popup::menu(&r).width(240.0).show(|ui| { for cs in deckcraft_model::theme::builtin_color_schemes() { ui.horizontal(|ui| { @@ -1595,7 +1673,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } }); let r = big_button(ui, Icon::Fonts, "Fonts", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Fonts")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); egui::Popup::menu(&r).width(220.0).show(|ui| { for fs in deckcraft_model::theme::builtin_font_schemes() { if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { @@ -1609,7 +1687,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } }); let r = big_button(ui, Icon::ShapeEffects, "Effects", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Effects")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); egui::Popup::menu(&r).width(180.0).show(|ui| { for fs in deckcraft_model::theme::builtin_format_schemes() { if ui.button(&fs.name).clicked() { @@ -1685,6 +1763,8 @@ pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) // ---------- Transitions ---------- +/// Render transition preview, gallery, options, sound, and timing controls for the current +/// slide. fn transitions(app: &mut SlideApp, ui: &mut Ui) { let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); @@ -1749,14 +1829,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { // Sound in its own column, so the timing column keeps its three rows. rows(ui, |ui| { ui.horizontal(|ui| { - ui.label("Sound:"); + let field = ui.label("Sound:"); let stop = cur.as_ref().is_some_and(|t| t.stop_sound); let name = cur .as_ref() .and_then(|t| t.sound) .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); - egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); if ui.selectable_label(none, "[No Sound]").clicked() { run(app, "transition.sound", json!({"none": true})); @@ -1785,13 +1865,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { run(app, "transition.sound", json!({"loop": lp})); } }); + let _ = combo.response.labelled_by(field.id); }); }); rows(ui, |ui| { ui.horizontal(|ui| { ui.label("Duration:"); let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); - if let Some(v) = widgets::spinner(ui, "tdur", d, 0.25, "", 46.0) { + if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); @@ -1807,7 +1888,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { if ui.checkbox(&mut on, "After:").changed() { run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); } - if let Some(v) = widgets::spinner(ui, "tafter", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { + if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); } }); @@ -1913,6 +1994,7 @@ pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &st // ---------- Animations ---------- +/// Render animation preview, gallery, add/effect options, trigger, timing, and playback controls. fn animations(app: &mut SlideApp, ui: &mut Ui) { let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); let cur: Option = app.session.active().and_then(|d| { @@ -2076,13 +2158,17 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { ui.label("Duration:"); - if let Some(v) = widgets::spinner(ui, "adur", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) { + if let Some(v) = + widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) + { run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); ui.horizontal(|ui| { ui.label("Delay:"); - if let Some(v) = widgets::spinner(ui, "adelay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) { + if let Some(v) = + widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) + { run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); } }); @@ -2433,6 +2519,7 @@ fn stroke_guide(p: &egui::Painter, g: &deckcraft_geom::warp::Guide, color: Color } } +/// Render the Shape Format tab's shape, fill, outline, effects, text, arrange, and size controls. fn shape_format(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let sel = enabled(app, "shape.fill"); @@ -2503,17 +2590,17 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { if let Some(a) = a { widgets::color_popup(&a).show(|ui| outline_menu(app, ui, &sc)); } - let (_, a) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| effects_menu(app, ui)); + let (main, arrow) = small_button_ex(ui, Icon::ShapeEffects, "Shape Effects", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| effects_menu(app, ui)); } }); }); group(ui, |ui| { rows(ui, |ui| { - let (_, a) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { + let (main, arrow) = small_button_ex(ui, Icon::TextFill, "Text Fill", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { run(app, "format.color", json!({"color": cref_param(&c)})); } @@ -2523,9 +2610,9 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { } }); } - let (_, a) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); - if let Some(a) = a { - widgets::color_popup(&a).show(|ui| { + let (main, arrow) = small_button_ex(ui, Icon::TextOutline, "Text Outline", sel, false, true); + if let Some(arrow) = arrow { + widgets::split_color_popup(&main, &arrow).show(|ui| { if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { run(app, "format.textOutline", json!({"color": c.map(|c| cref_param(&c))})); } @@ -2552,6 +2639,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render ordering, align, group/ungroup, and rotate controls for the current selection. fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let sel = enabled(app, "arrange.bringForward"); @@ -2579,17 +2667,23 @@ fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::AlignObjects, "Align", sel); egui::Popup::menu(&r).show(|ui| align_menu(app, ui)); rows(ui, |ui| { - let (_, a) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { + let (main, arrow) = small_button_ex(ui, Icon::Group, "Group", sel, false, true); + if main.clicked() { + run(app, "arrange.group", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); }); } - let (_, a) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); - if let Some(a) = a { - egui::Popup::menu(&a).show(|ui| { + let (main, arrow) = small_button_ex(ui, Icon::Rotate, "Rotate", sel, false, true); + if main.clicked() { + run(app, "arrange.rotateRight", json!({})); + } + if let Some(arrow) = arrow { + egui::Popup::menu(&arrow).show(|ui| { menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); @@ -2600,6 +2694,7 @@ fn arrange_group(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render width and height controls bound to the selected shape, plus the size-position dialog. fn size_group(app: &mut SlideApp, ui: &mut Ui) { let bx = app .session @@ -2614,13 +2709,13 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { }; ui.horizontal(|ui| { ui.label("Height:"); - if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) { + if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / 72.0, 0.1, "\"", 54.0) { run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0})); } }); ui.horizontal(|ui| { ui.label("Width: "); - if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) { + if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / 72.0, 0.1, "\"", 54.0) { run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0})); } }); @@ -2631,6 +2726,8 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Render the Picture Format tab's corrections, colour, artistic effects, transparency, styles, +/// border, and picture actions. fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); @@ -2712,9 +2809,12 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({})); small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({})); - let (_, r) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); - if let Some(r) = r { - egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).width(260.0).show(|ui| compress_menu(app, ui)); + let (main, arrow) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true); + if let Some(arrow) = arrow { + widgets::split_popup(&main, &arrow) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .width(260.0) + .show(|ui| compress_menu(app, ui)); } }); }); @@ -3133,21 +3233,23 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); } rows(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); - egui::Popup::menu(&r).show(|ui| { - for (label, pos) in [ - ("Add Shape After", "after"), - ("Add Shape Before", "before"), - ("Add Shape Above", "above"), - ("Add Shape Below", "below"), - ("Add Assistant", "assistant"), - ] { - if ui.button(label).clicked() { - let index = if n == 0 { Value::Null } else { json!(at) }; - run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + let (main, arrow) = small_button_ex(ui, Icon::Plus, "Add Shape", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (label, pos) in [ + ("Add Shape After", "after"), + ("Add Shape Before", "before"), + ("Add Shape Above", "above"), + ("Add Shape Below", "below"), + ("Add Assistant", "assistant"), + ] { + if ui.button(label).clicked() { + let index = if n == 0 { Value::Null } else { json!(at) }; + run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); + } } - } - }); + }); + } if small_button(ui, Icon::DecreaseIndent, "Promote", n > 0, false).clicked() { run(app, "smartArt.promote", json!({"id": gid, "index": at})); } @@ -3164,47 +3266,55 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { } }); group(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Layouts", gid.is_some(), false, true); let outline = sel.as_ref().map(|(_, d)| d.nodes.clone()); - egui::Popup::menu(&r).show(|ui| { - smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); - }); - let (r, _) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); - egui::Popup::menu(&r).show(|ui| { - if ui.button("Convert to Text").clicked() { - run(app, "smartArt.toText", json!({"id": gid})); - } - if ui.button("Convert to Shapes").clicked() { - run(app, "smartArt.toShapes", json!({"id": gid})); - } - }); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + smartart_gallery(app, ui, "smartArt.layout", json!({"id": gid}), &kind, &style, &colors, outline.as_deref()); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + if ui.button("Convert to Text").clicked() { + run(app, "smartArt.toText", json!({"id": gid})); + } + if ui.button("Convert to Shapes").clicked() { + run(app, "smartArt.toShapes", json!({"id": gid})); + } + }); + } }); group(ui, |ui| { - let (r, _) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); - egui::Popup::menu(&r).show(|ui| { - for (k, label) in deckcraft_engine::cmd::smartart::COLORS { - if matches!(k, "range23" | "accent1" | "accent1Grad") { - ui.separator(); + let (main, arrow) = small_button_ex(ui, Icon::ChangeChartType, "Change Colors", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::COLORS { + if matches!(k, "range23" | "accent1" | "accent1Grad") { + ui.separator(); + } + let on = colors == k || (colors.is_empty() && k == "colorful"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + } } - let on = colors == k || (colors.is_empty() && k == "colorful"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.colors", json!({"id": gid, "colors": k})); + }); + } + let (main, arrow) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); + if let Some(arrow) = arrow { + widgets::split_menu(&main, &arrow, |ui| { + for (k, label) in deckcraft_engine::cmd::smartart::STYLES { + if k == "polished" { + ui.separator(); + ui.label("3-D"); + } + let on = style == k || (style.is_empty() && k == "flat"); + if ui.selectable_label(on, label).clicked() { + run(app, "smartArt.style", json!({"id": gid, "style": k})); + } } - } - }); - let (r, _) = small_button_ex(ui, Icon::SmartArt, "SmartArt Styles", gid.is_some(), false, true); - egui::Popup::menu(&r).show(|ui| { - for (k, label) in deckcraft_engine::cmd::smartart::STYLES { - if k == "polished" { - ui.separator(); - ui.label("3-D"); - } - let on = style == k || (style.is_empty() && k == "flat"); - if ui.selectable_label(on, label).clicked() { - run(app, "smartArt.style", json!({"id": gid, "style": k})); - } - } - }); + }); + } }); arrange_group(app, ui); } @@ -3570,16 +3680,16 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { }) else { return; }; - let key = egui::Id::new(("deckcraft-smooth-corners", id.0)); + let key = egui::Id::unique(("deckcraft-smooth-corners", id.0)); let mut state: (f64, f64) = ui.data(|d| d.get_temp(key)).unwrap_or((radius.unwrap_or(0.0), 0.0)); if let Some(r) = radius { state.0 = r; } let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); ui.horizontal(|ui| { - ui.label("Smooth Corners"); + let label = ui.label("Smooth Corners"); let mut pct = state.1; - let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()); + let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; if resp.changed() { state.1 = pct; @@ -3596,7 +3706,7 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { /// Insert Zoom dialogs do (a checklist of slides or sections). fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { let Some(doc) = app.session.active().map(|d| d.doc.clone()) else { return }; - let id = egui::Id::new("insert_zoom_menu"); + let id = egui::Id::unique("insert_zoom_menu"); let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); ui.horizontal(|ui| { for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { @@ -3653,7 +3763,7 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { /// Compress Pictures options, as in PowerPoint's dialog: resolution, apply to this picture or /// all, delete cropped areas. The choices last for the session. fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { - let id = egui::Id::new("compress_pictures_opts"); + let id = egui::Id::unique("compress_pictures_opts"); let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); ui.label(egui::RichText::new("Compression options").strong()); ui.checkbox(&mut only_selected, "Apply only to this picture"); diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs index 36e634c7..1e4c5464 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/selpane.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use deckcraft_model::Shape; -use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; +use egui::{Align2, CornerRadius, Id, Rect, Role, Sense, Stroke, Ui, WidgetInfo, pos2, vec2}; use serde_json::json; use crate::SlideApp; @@ -102,13 +102,14 @@ fn run(app: &mut SlideApp, id: &str, p: serde_json::Value) { /// Each row is a widget with an id of its own (keyboard focus sits on the selected row). fn row_id(id: u32) -> Id { - Id::new(("selection_pane_row", id)) + Id::unique(("selection_pane_row", id)) } +/// Draw a small labelled icon button and return its interaction response. fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, enabled, label)); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, label)); if enabled && resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover); } @@ -117,6 +118,8 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu resp.on_hover_text(label) } +/// Render the Selection Pane and route visibility, order, rename, and selection actions through +/// engine commands. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -157,7 +160,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let resp = ui.interact(r, row_id(row.id), Sense::click_and_drag()); rects.push(r); let sel = selected.contains(&row.id); - resp.widget_info(|| WidgetInfo::selected(WidgetType::SelectableLabel, true, sel, &row.name)); + resp.widget_info(|| WidgetInfo::selected(Role::Button, true, sel, &row.name)); let painter = ui.painter_at(r); if sel { painter.rect_filled(r, CornerRadius::same(3), t.selected); @@ -170,9 +173,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { if row.group { let open = !app.selpane.collapsed.contains(&row.id); let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with(("fold", row.id)), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); - cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); if cresp.clicked() { if open { @@ -188,8 +191,9 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let name_rect = Rect::from_min_max(pos2(name_x, r.min.y + 2.0), pos2(eye.min.x - 6.0, r.max.y - 2.0)); let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone()); if let Some(mut buf) = renaming { - let edit_id = ui.id().with(("rename", row.id)); - let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::font(12.5)).margin(vec2(2.0, 1.0))); + let edit_id = ui.scope_id().with(("rename", row.id)); + let out = + ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::font(12.5)).margin(vec2(2.0, 1.0))); if !app.selpane.rename_started { out.request_focus(); if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) { @@ -218,12 +222,12 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let ink = if row.dimmed { t.text_faint } else { t.text_dim }; icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false); icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false); - let eye_resp = ui.interact(eye.expand(3.0), ui.id().with(("eye", row.id)), Sense::click()); + let eye_resp = ui.interact(eye.expand(3.0), ui.scope_id().with(("eye", row.id)), Sense::click()); let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name); - eye_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &eye_label)); - let lock_resp = ui.interact(lock.expand(3.0), ui.id().with(("lock", row.id)), Sense::click()); + eye_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &eye_label)); + let lock_resp = ui.interact(lock.expand(3.0), ui.scope_id().with(("lock", row.id)), Sense::click()); let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name); - lock_resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &lock_label)); + lock_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &lock_label)); if eye_resp.clicked() { run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); } else if lock_resp.clicked() { diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 4aaecd54..2bb950a3 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -13,6 +13,9 @@ use crate::SlideApp; use crate::theme; pub struct Show { + /// Monotonic identity of this show instance. Starting a show again (even at the same slide + /// and step) is a new instance, so a queued click cannot cross an ABA transition. + pub(crate) instance: u64, pub state: ShowState, pub reading: bool, pub presenter: bool, @@ -73,6 +76,10 @@ pub struct Show { media_clock: Option<(usize, f64)>, /// Media effects set off and not yet carried out: (shape, effect id). media_cues: Vec<(ShapeId, String)>, + /// Hidden-logic time anchor: the last visible egui time and the monotonic instant of that + /// visible frame. `run_logic` does not refresh `InputState::time`, so this lets a hidden show + /// keep playing from wall time without a draw. + hidden_anchor: Option<(f64, web_time::Instant)>, } #[derive(Clone, Debug, PartialEq)] @@ -270,9 +277,48 @@ struct Transition { dur: f64, } +/// A copy of the show's clock origins for regressions that must not depend on wall time. +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ClockSnapshot { + pub step_start: f64, + pub slide_start: f64, + pub step_end: f64, + pub show_start: f64, + pub last_move: f64, +} + +/// The parts of a show that change what a window-point event means. Coordinates are relative +/// to the whole viewport, so the step, end screen, reading/presenter layout, pen/grid/zoom modes +/// and blank screen all have to match the batch's target as well as the instance and slide. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ShowInputState { + pub instance: u64, + pub slide: usize, + pub step: usize, + pub ended: bool, + pub reading: bool, + pub presenter: bool, + pub grid: bool, + pub pen: bool, + pub laser: bool, + pub blank: Option<[u8; 4]>, + pub zoom: Option<[u32; 3]>, + pub zoom_arm: bool, +} + +/// True unless the root viewport explicitly reports itself minimized or fully occluded. +/// Integrations that do not report visibility stay visible rather than freezing the show. +pub(crate) fn viewport_visible(ctx: &egui::Context) -> bool { + ctx.input(|i| i.viewport().visible() != Some(false)) +} + impl Show { + /// Initialise show state at the requested slide with reading/presenter modes; an out-of-range + /// slide starts an ended show. pub fn new(doc: &Presentation, from: usize, reading: bool, presenter: bool) -> Self { Show { + instance: 0, state: ShowState::start_at(doc, from), reading, presenter, @@ -313,9 +359,123 @@ impl Show { links: None, media_clock: None, media_cues: vec![], + hidden_anchor: None, } } + /// Identity of the show state a queued window-point event belongs to. + pub(crate) fn input_state(&self) -> ShowInputState { + let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; + ShowInputState { + instance: self.instance, + slide: self.state.slide, + step: self.state.step, + ended: self.ended || self.state.ended, + reading: self.reading, + presenter: self.presenter, + grid: self.grid, + pen: self.pen, + laser: self.laser, + blank: self.blank.map(|c| [c.r(), c.g(), c.b(), c.a()]), + zoom: self.zoom.map(|z| [bits(z.nx), bits(z.ny), bits(z.scale)]), + zoom_arm: self.zoom_arm, + } + } + + /// The show time for a hidden logic pass. `run_logic` leaves `InputState::time` at the last + /// visible frame, so anchor that value to the monotonic instant of the visible frame and + /// advance from wall time. Only a full visible UI pass re-anchors to egui's real frame time; + /// a visible logic-only pass must not reuse its stale input time as a new anchor. + pub(crate) fn hidden_logic_time(&self, visible_time: f64, now: web_time::Instant) -> f64 { + let (base, at) = self.hidden_anchor.unwrap_or((visible_time, now)); + base + now.saturating_duration_since(at).as_secs_f64() + } + + /// A visible UI pass drives the clocks through its real frame time and records the anchor a + /// later hidden logic tick advances from. + pub(crate) fn observe_visible_frame(&mut self, visible_time: f64, now: web_time::Instant) { + self.hidden_anchor = Some((visible_time, now)); + } + + /// Whether hidden logic is running a show that has already passed its first visible frame. + pub(crate) fn hidden_started(&self) -> bool { + self.started + } + + /// Whether hidden logic still has time-driven work, so it must keep requesting wake-ups. + pub(crate) fn hidden_time_driven(&self, doc: &Presentation) -> bool { + self.started && !self.ended && (self.state.playing || self.trans.is_some() || self.autoplay || self.state.advance_after(doc).is_some()) + } + + /// Advance the show's own clocks and automatic steps on a hidden logic pass. Drawing is a + /// separate concern, so a minimized show keeps playing instead of inventing a pause policy. + /// Returns false when the show should close (preview ended). + pub(crate) fn advance_hidden(&mut self, doc: &Presentation, now: f64) -> bool { + self.tick_triggers(doc, now); + self.expire_goto(now); + if self.trans.as_ref().is_some_and(|trans| now - trans.start >= trans.dur.max(0.01)) { + self.trans = None; + } + let mut actions = Vec::new(); + let mut keep = self.queue_clock_actions(doc, now, &mut actions); + for action in actions { + if !self.apply(action, doc, now) { + keep = false; + } + } + if self.state.playing { + let step = self.state.step; + self.cue_media(doc, step, now - self.step_start); + } + keep + } + + /// Queue the step/auto-advance actions for `now`. Shared by the visible UI pass and the + /// hidden logic tick so both read the same clock. Returns false when a preview should close. + fn queue_clock_actions(&mut self, doc: &Presentation, now: f64, actions: &mut Vec) -> bool { + let (step, playing) = (self.state.step, self.state.playing); + let t_step = if playing { now - self.step_start } else { 0.0 }; + if playing { + let dur = self.timeline(doc).step_duration(step); + if t_step >= dur { + actions.push(self.state.step_done(doc)); + self.step_end = now; + } + } else if self.trans.is_none() { + // Auto-advance (After: timings) or autoplay preview. An automatic first step is started + // by `ShowState` as the slide appears; starting it here too replayed it after Back (#844). + let idle = now - self.step_end.max(self.slide_start); + let cur_step = self.state.step; + let n_steps = self.timeline(doc).steps(); + let step_due = self.autoplay && cur_step < n_steps && idle > 0.3; + let holding = self.grid || self.presenter_grid || self.zoom.is_some() || self.zoom_arm; + if !holding && (step_due || (!self.rehearse && self.state.auto_advance_due(doc, idle) && !self.ended)) { + actions.push(self.state.next(doc)); + } else if self.preview_only && cur_step >= n_steps && idle > 0.8 { + return false; + } + } + true + } + + /// The clock origins used by the show, for deterministic hidden/resume regressions. + #[cfg(test)] + pub(crate) fn clock_snapshot(&self) -> ClockSnapshot { + ClockSnapshot { + step_start: self.step_start, + slide_start: self.slide_start, + step_end: self.step_end, + show_start: self.show_start, + last_move: self.last_move, + } + } + + /// Presenter elapsed seconds at `now`, using the same clock as the view. + #[cfg(test)] + pub(crate) fn presenter_elapsed(&self, now: f64) -> f64 { + self.timer.elapsed(now) + } + /// True while the pointer moved in the last 2.5 s; the media controls and GIF button hide after that. fn pointer_awake(&self, now: f64) -> bool { now - self.last_move < 2.5 @@ -671,6 +831,8 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep } +/// Draw and advance the active show, handling hidden frames, timing, input, transitions, and +/// presenter/reading presentation. pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else { @@ -679,10 +841,31 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { }; let now = ctx.input(|i| i.time); let Some(mut show) = app.show.take() else { return }; + if !viewport_visible(&ctx) { + // A forced hidden paint (for example a pending screenshot) draws the current frame; + // the show clocks are advanced by logic, not by this draw, so it must not advance them a + // second time. Keep drawing the last frame rather than returning a blank. + if show.started { + let full = ui.max_rect(); + let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full }; + let srect = fit(avail, &doc); + let painter = ui.painter_at(full); + painter.rect_filled(full, CornerRadius::ZERO, Color32::BLACK); + if let Some((_, tex)) = &show.tex { + painter.image(tex.id(), srect, unit_uv(), Color32::WHITE); + } + } + app.show = Some(show); + return; + } + show.observe_visible_frame(now, web_time::Instant::now()); if !show.started { show.started = true; show.slide_start = now; show.step_start = now; + // The first step is settled at the show's own start: every clock origin is a real frame + // time, so hiding and resuming shifts all of them by the same interval. + show.step_end = now; show.show_start = now; show.timer = PresenterTimer::start(now); show.last_move = now; @@ -798,28 +981,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } - // Step clock. - let (step, playing) = (show.state.step, show.state.playing); - let t_step = if playing { now - show.step_start } else { 0.0 }; - if playing { - let dur = show.timeline(&doc).step_duration(step); - if t_step >= dur { - actions.push(show.state.step_done(&doc)); - show.step_end = now; - } - } else if show.trans.is_none() { - // Auto-advance (After: timings) or autoplay preview. An automatic first step is started - // by `ShowState` as the slide appears; starting it here too replayed it after Back (#844). - let idle = now - show.step_end.max(show.slide_start); - let cur_step = show.state.step; - let n_steps = show.timeline(&doc).steps(); - let step_due = show.autoplay && cur_step < n_steps && idle > 0.3; - let holding = show.grid || show.presenter_grid || show.zoom.is_some() || show.zoom_arm; - if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) { - actions.push(show.state.next(&doc)); - } else if show.preview_only && cur_step >= n_steps && idle > 0.8 { - keep = false; - } + // Step clock, shared with the hidden logic tick. + if !show.queue_clock_actions(&doc, now, &mut actions) { + keep = false; } for a in actions { if !show.apply(a, &doc, now) { @@ -1094,7 +1258,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ctx.request_repaint_after(std::time::Duration::from_millis(200)); } // Pen. - let resp = ui.interact(full, ui.id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); + let resp = ui.interact(full, ui.scope_id().with("show_pen"), if show.pen { Sense::drag() } else { Sense::hover() }); + if show.pen { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Pen")); + } if show.pen { if resp.drag_started() { show.strokes.push(vec![]); @@ -1931,6 +2098,7 @@ fn render_thumb(ctx: &egui::Context, show: &mut Show, doc: &Presentation, i: usi Some(tex) } +/// Render the presenter window and set `keep` false when the user ends or closes the show. fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { let cur = show.state.slide; let next = show.state.peek_next(doc); @@ -2019,7 +2187,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if show.presenter_grid { // The grid fills this window; the audience keeps seeing the current slide. paint_grid(ui.painter(), ui.ctx(), show, doc, avail); - let hit = ui.interact(avail, ui.id().with("presenter_grid"), Sense::click()); + let hit = ui.interact(avail, ui.scope_id().with("presenter_grid"), Sense::click()); if hit.clicked() && let Some(p) = hit.interact_pointer_pos() && let Some(n) = see_all_hit(avail, grid_indices(doc.slides.len()).len(), slide_aspect(doc), p) @@ -2045,7 +2213,8 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside); } if show.zoom_arm { - let hit = ui.interact(cr, ui.id().with("presenter_zoom"), Sense::click()); + let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom")); if hit.clicked() && let Some(p) = hit.interact_pointer_pos() && cr.width() > 1.0 @@ -2788,6 +2957,34 @@ mod tests { assert_eq!(font_of("Agenda"), theme::font(24.0)); } + /// Hidden logic keeps playing from wall time even though `run_logic` leaves egui's input + /// time at the last visible frame. A visible pass re-anchors instead of shifting a pause. + #[test] + fn hidden_logic_advances_the_show_and_visible_frames_reanchor() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.started = true; + show.step_start = 10.0; + show.slide_start = 10.0; + show.step_end = 10.0; + show.show_start = 10.0; + show.last_move = 10.0; + show.timer = PresenterTimer::start(10.0); + let t0 = web_time::Instant::now(); + show.observe_visible_frame(10.0, t0); + assert_eq!(show.hidden_logic_time(10.0, t0), 10.0, "the first hidden tick starts at the last visible time"); + let hidden = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(2)); + assert!((hidden - 12.0).abs() < 1e-9, "hidden logic advances the show time: {hidden}"); + assert!((show.presenter_elapsed(hidden) - 2.0).abs() < 1e-9, "presenter time keeps running"); + assert!((show.clock_snapshot().step_start - 10.0).abs() < 1e-9, "the clock origin is not shifted"); + let later = show.hidden_logic_time(10.0, t0 + std::time::Duration::from_secs(5)); + assert!((later - 15.0).abs() < 1e-9, "repeated hidden ticks do not stack a pause: {later}"); + show.observe_visible_frame(20.0, t0 + std::time::Duration::from_secs(10)); + assert_eq!(show.hidden_logic_time(20.0, t0 + std::time::Duration::from_secs(10)), 20.0, "a visible frame re-anchors"); + } + #[test] fn presenter_timer_pauses_resets_and_notes_size_clamps() { let mut t = PresenterTimer::start(10.0); @@ -2917,4 +3114,50 @@ mod tests { let st = &harness.state().show.as_ref().unwrap().state; assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing); } + + /// Click-target state follows show instance, reading/presenter modes, grid, and other layout + /// changes instead of retaining a stale target. + #[test] + fn input_state_tracks_the_modes_that_move_show_click_targets() { + let session = deckcraft_engine::Session::with_new(); + let doc = &session.active().unwrap().doc; + let mut show = Show::new(doc, 0, false, false); + let base = show.input_state(); + assert_eq!(show.input_state(), base); + + show.instance += 1; + assert_ne!(show.input_state(), base, "a new show instance is a new input target"); + show.instance = base.instance; + + show.reading = true; + assert_ne!(show.input_state(), base, "the reading layout moves the slide"); + show.reading = false; + + show.presenter = true; + assert_ne!(show.input_state(), base, "presenter view changes the available area"); + show.presenter = false; + + show.grid = true; + assert_ne!(show.input_state(), base, "See All Slides replaces the slide with thumbnails"); + show.grid = false; + + show.pen = true; + assert_ne!(show.input_state(), base, "the pen changes what a drag means"); + show.pen = false; + + show.laser = true; + assert_ne!(show.input_state(), base, "the laser changes what a drag means"); + show.laser = false; + + show.zoom_arm = true; + assert_ne!(show.input_state(), base, "armed zoom changes what a click means"); + show.zoom_arm = false; + + show.zoom = Some(Zoom::at(0.25, 0.75)); + assert_ne!(show.input_state(), base, "a zoomed slide has a different click mapping"); + show.zoom = None; + + show.blank = Some(Color32::BLACK); + assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); + } } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 3c840860..4d39d62c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use deckcraft_model::{Presentation, SlideId}; -use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Rect, Role, Sense, Stroke, Ui, WidgetInfo, pos2, vec2}; use serde_json::json; use crate::SlideApp; @@ -70,12 +70,14 @@ struct Header<'a> { first: Option, } +/// Render one slide-sorter section header, handle its collapse/drag interactions, and return the +/// occupied rectangle. fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { let t = Tokens::get(ui.ctx()); let width = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover()); - let resp = ui.interact(r, ui.id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag()); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, h.name)); + let resp = ui.interact(r, ui.scope_id().with(("sorter-section", h.uid, h.index)), Sense::click_and_drag()); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, h.name)); let open = h.first.is_none_or(|id| !app.sorter.collapsed.contains(&(h.uid, id))); let dragging = app.sorter.drag.is_some_and(|(from, _)| from == h.index); let bg = if dragging { @@ -88,15 +90,16 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { let painter = ui.painter_at(r); painter.rect_filled(r, CornerRadius::same(4), bg); let chev = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(chev, ui.id().with(("sorter-fold", h.uid, h.index)), Sense::click()); + let cresp = ui.interact(chev, ui.scope_id().with(("sorter-fold", h.uid, h.index)), Sense::click()); let fold = format!("{} {}", if open { "Collapse" } else { "Expand" }, h.name); - cresp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &fold)); + cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &fold)); icons::paint(&painter, chev.shrink(4.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, h.first.is_none()); let renaming = app.sorter.renaming.clone().filter(|(uid, index, _)| *uid == h.uid && *index == h.index).map(|(_, _, text)| text); let name_rect = Rect::from_min_max(pos2(r.min.x + 40.0, r.min.y + 4.0), pos2(r.max.x - 56.0, r.max.y - 4.0)); if let Some(mut buf) = renaming { - let edit_id = ui.id().with(("sorter-rename", h.uid, h.index)); - let out = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).font(theme::bold(13.0)).margin(vec2(4.0, 2.0))); + let edit_id = ui.scope_id().with(("sorter-rename", h.uid, h.index)); + let out = + ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::bold(13.0)).margin(vec2(4.0, 2.0))); let already = app.sorter.rename_started; if !already { out.request_focus(); @@ -199,7 +202,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, let Some(&i) = g.indices.get(row * cols + c) else { break }; let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag()); let label = format!("Slide {}", i + 1); - resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label)); + resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp); rects.push((i, cell)); let p = ui.painter(); @@ -400,8 +403,8 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect)); // A draft belongs to this document and to the notes it started from (#413): another document, // a reorder or an undo that changes them drops it. - let id = egui::Id::new(("notespage", st.uid, idx)); - let edit_id = egui::Id::new(("notespage-edit", idx)); + let id = egui::Id::unique(("notespage", st.uid, idx)); + let edit_id = egui::Id::unique(("notespage-edit", idx)); let focused = child.ctx().memory(|m| m.has_focus(edit_id)); let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default(); let notes = notes_of(app); @@ -452,7 +455,7 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { let origin = ui.cursor().top(); let margin = 300.0; for (i, s) in doc.slides.iter().enumerate() { - let hid = egui::Id::new(("ol-height", s.id.0)); + let hid = egui::Id::unique(("ol-height", s.id.0)); let known: Option = ui.data(|d| d.get_temp(hid)); let h = known.unwrap_or_else(|| outline_estimate(s)); let y = ui.cursor().top() - origin; @@ -493,10 +496,12 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim)); let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover()); ui.painter().rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, if i == cur { t.accent } else { t.text_dim }), egui::StrokeKind::Inside); - let id = egui::Id::new(("ol-title", s.id.0)); + let id = egui::Id::unique(("ol-title", s.id.0)); let cur_title = s.title(); let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone())); - let resp = ui.add(egui::TextEdit::singleline(&mut v).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY)); + let resp = ui.add( + egui::TextEdit::singleline(&mut v).hint_text("Slide title").font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY), + ); if resp.gained_focus() { let _ = app.run("slide.go", json!({"index": i})); } @@ -517,12 +522,13 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck if tb.is_empty() { continue; } - let id = egui::Id::new(("ol-body", s.id.0, sh.id.0)); + let id = egui::Id::unique(("ol-body", s.id.0, sh.id.0)); let cur_text: String = tb.paragraphs.iter().map(|p| format!("{}{}", "\t".repeat(p.level as usize), p.text())).collect::>().join("\n"); let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone())); ui.horizontal(|ui| { ui.add_space(36.0); - let resp = ui.add(egui::TextEdit::multiline(&mut v).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); + let resp = ui + .add(egui::TextEdit::multiline(&mut v).hint_text("Slide text").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); if resp.gained_focus() { let _ = app.run("slide.go", json!({"index": i})); } diff --git a/crates/ui-egui/src/split_button_tests.rs b/crates/ui-egui/src/split_button_tests.rs new file mode 100644 index 00000000..3f91942b --- /dev/null +++ b/crates/ui-egui/src/split_button_tests.rs @@ -0,0 +1,136 @@ +//! Split controls on real ribbon callers: a dropdown-only control must not expose a dead named +//! half, while Group and Rotate keep a real main action beside their options arrow. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use deckcraft_model::ShapeId; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +/// Two rectangles selected on a blank slide, so all the arrange controls are enabled. +fn harness(tab: &str) -> (Harness<'static, SlideApp>, [ShapeId; 2]) { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut ids = [ShapeId(0); 2]; + for (i, x) in [100.0, 300.0].iter().enumerate() { + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 100.0, 80.0, 60.0]})).unwrap(); + ids[i] = ShapeId(r["id"].as_u64().unwrap() as u32); + } + s.execute("selection.select", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = tab.into(); + let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + (h, ids) +} + +/// Click the accessible button or chevron with the given name and run three harness steps. +fn click(h: &mut Harness<'static, SlideApp>, name: &str) { + h.get_by_role_and_label(Role::Button, name).click(); + h.run_steps(3); +} + +/// Click each named half, see the same menu, toggle it closed from that same half, then repeat on +/// the other half. The old callers wired only one half, so one full click produced no menu. +fn both_halves_open_same_menu(tab: &str, name: &str, item: &str) { + let (mut h, _) = harness(tab); + for half in [name.to_string(), format!("{name} options")] { + assert!(h.query_by_label(item).is_none(), "{tab}: the {half} menu starts closed"); + click(&mut h, &half); + assert!(h.query_by_label(item).is_some(), "{tab}: clicking {half} opens the shared menu"); + click(&mut h, &half); + assert!(h.query_by_label(item).is_none(), "{tab}: clicking {half} again closes the same menu"); + } +} + +/// Both the Home Layout label and chevron open and toggle the same layout menu. +#[test] +fn home_layout_opens_the_layout_menu_from_its_label_and_arrow() { + both_halves_open_same_menu("Home", "Layout", "Title Slide"); +} + +/// Keyboard entry must recognize either named half as the menu's origin. In particular, Up +/// enters at the last item rather than letting spatial focus navigation escape into the ribbon. +#[test] +fn home_section_menu_enters_wraps_and_closes_from_either_keyboard_trigger() { + let (mut h, _) = harness("Home"); + for trigger in ["Section", "Section options"] { + for (entry, first, wrapped) in + [(egui::Key::ArrowUp, "Remove All Sections", "Add Section"), (egui::Key::ArrowDown, "Add Section", "Remove All Sections")] + { + h.get_by_role_and_label(Role::Button, trigger).focus(); + h.run_steps(1); + assert!(h.get_by_role_and_label(Role::Button, trigger).is_focused(), "{trigger}: the named half has focus"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(h.query_by_role_and_label(Role::Button, "Add Section").is_some(), "{trigger}: Enter opens the menu"); + + h.key_press(entry); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, first).is_focused(), "{trigger}: {entry:?} enters the menu"); + // The opposite direction wraps at the edge, then the entry direction wraps back. + h.key_press(if entry == egui::Key::ArrowUp { egui::Key::ArrowDown } else { egui::Key::ArrowUp }); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, wrapped).is_focused(), "{trigger}: focus wraps inside the menu"); + h.key_press(entry); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, first).is_focused(), "{trigger}: focus wraps back"); + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_role_and_label(Role::Button, "Add Section").is_none(), "{trigger}: Escape closes the menu"); + } + } +} + +/// Main and chevron halves of Shape Effects open the same effects menu on Home and Shape +/// Format. +#[test] +fn shape_effects_opens_the_effects_menu_from_its_label_and_arrow_on_both_tabs() { + both_halves_open_same_menu("Home", "Shape Effects", "Format Shape…"); + both_halves_open_same_menu("Shape Format", "Shape Effects", "Format Shape…"); +} + +/// Text Fill's label and chevron both open its colour menu. +#[test] +fn shape_format_text_fill_opens_its_colour_menu_from_its_label_and_arrow() { + both_halves_open_same_menu("Shape Format", "Text Fill", "More Colors… #"); +} + +/// The Group main area executes grouping while only its chevron opens the group menu. +#[test] +fn shape_format_group_main_groups_and_its_arrow_opens_the_group_menu() { + let (mut h, _) = harness("Shape Format"); + click(&mut h, "Group"); + { + let d = h.state().session.active().unwrap(); + assert_eq!(d.selection.shapes.len(), 1, "the main half grouped the two selected shapes"); + let grouped = d.shape(d.selection.shapes[0]).unwrap(); + assert!(grouped.is_group(), "the result is a group, not a menu choice"); + } + click(&mut h, "Group options"); + assert!(h.query_by_label("Ungroup").is_some(), "the arrow opens the Group actions"); +} + +/// The Rotate main area rotates while its chevron opens the rotate menu. +#[test] +fn shape_format_rotate_main_rotates_and_its_arrow_opens_the_rotate_menu() { + let (mut h, ids) = harness("Shape Format"); + h.state_mut().session.execute("selection.select", &json!({"ids": [ids[0].0]})).unwrap(); + h.run_steps(2); + click(&mut h, "Rotate"); + let rot = h.state().session.active().unwrap().shape(ids[0]).unwrap().xfrm.unwrap().rot; + assert!((rot - 90.0).abs() < 1e-9, "the main half rotates right: {rot}"); + click(&mut h, "Rotate options"); + assert!(h.query_by_label("Rotate Left 90°").is_some(), "the arrow opens the Rotate actions"); +} diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs index 8cd42665..30a94a25 100644 --- a/crates/ui-egui/src/squiggles.rs +++ b/crates/ui-egui/src/squiggles.rs @@ -64,16 +64,9 @@ struct Done { ranges: Vec<(Tag, usize, usize)>, } +/// Return the shared application clock in milliseconds for spelling-squiggle animation timing. fn now_ms() -> f64 { - #[cfg(target_arch = "wasm32")] - { - js_sys::Date::now() - } - #[cfg(not(target_arch = "wasm32"))] - { - static T0: std::sync::OnceLock = std::sync::OnceLock::new(); - T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 - } + crate::now_ms() } /// The checking itself, step by step: the newest job of each kind, a paragraph at a time, with @@ -251,6 +244,15 @@ pub fn lang_name(tag: &str) -> String { LANGS.iter().find(|(_, t)| t.eq_ignore_ascii_case(tag)).map_or_else(|| tag.to_string(), |(n, _)| n.to_string()) } +/// A language name the current interface fonts can actually draw: the native name when every +/// character has a glyph, otherwise the tag. On a machine without a Korean, Chinese or Hebrew +/// face, egui 0.37's strict missing-glyph policy would abort the frame instead of drawing boxes, +/// so state the fallback explicitly (and never skip the row). +pub fn display_lang_name(ctx: &egui::Context, tag: &str) -> String { + let name = lang_name(tag); + if ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(13.0), &name)) { name } else { tag.to_string() } +} + /// The flagged word (misspelt or repeated) of `pp` starting at `start`, with what the menu offers. pub fn menu_word(pp: &Paragraph, default: &str, tag: Tag, start: usize) -> Option { let w = deckcraft_engine::spell::misspelt(pp, default, &[]).into_iter().find(|w| w.start == start)?; diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 5f922aea..0301af44 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -29,13 +29,13 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let right_x = rect.max.x - 560.0; // The selection's proofing language; a click opens Review › Language. let (lang, no_proof) = crate::dialogs::selected_language(app); - let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" }; + let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { " (not checked)" } else { "" }; let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0); let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y)); let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); let hit = lr.expand(3.0).intersect(lclip); if hit.is_positive() { - let resp = ui.interact(hit, ui.id().with("status_lang"), Sense::click()).on_hover_text("Language"); + let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language"); resp.widget_info(|| named_with_value("Language", &name)); if resp.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("language")); @@ -70,9 +70,16 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { // Slider 10%–400% (log scale). let mut z = app.ui.zoom.unwrap_or(app.canvas_scale).clamp(0.1, 4.0); let mut zp = z * 100.0; - let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false)); + // egui 0.37 gives a slider its own width and the theme's control height; ask for exactly + // those so the status row stays consistent with every other control without hard-coding + // a size the slider would override anyway. + let slider_size = vec2(right.spacing().slider_width, right.spacing().interact_size.y); + let s = right.add_sized(slider_size, egui::Slider::new(&mut zp, 10.0..=400.0).logarithmic(true).show_value(false)); + // The status bar has no room for a visible "Zoom" label; name the slider for assistive + // technology only, leaving its geometry unchanged. Its value is the percentage shown. let shown = format!("{}%", zp.round()); - s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(true, f64::from(zp), "Zoom") }); + let enabled = s.enabled(); + s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(enabled, f64::from(zp), "Zoom") }); if s.changed() { z = zp / 100.0; app.ui.zoom = Some(z); @@ -108,16 +115,17 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { }); } +/// Draw a small status icon with a tooltip and return its interaction response. fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { let resp = icon_button(ui, icon, on); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); resp.on_hover_text(tip) } /// A view button: screen readers hear its name and whether its view is the one shown. fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { let resp = icon_button(ui, icon, on); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); resp.on_hover_text(tip) } @@ -133,27 +141,31 @@ fn icon_button(ui: &mut Ui, icon: Icon, on: bool) -> egui::Response { /// A clickable status-bar text: screen readers hear `label` as its name and `text` as its value. fn named_with_value(label: &str, text: &str) -> egui::WidgetInfo { - egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label) } + egui::WidgetInfo { current_text_value: Some(text.to_string()), ..egui::WidgetInfo::labeled(egui::Role::Button, true, label) } } /// Painted status-bar text: screen readers read `text` from a label over `rect`. fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) { if rect.is_positive() { - let resp = ui.interact(rect, ui.id().with(id), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + let resp = ui.interact(rect, ui.scope_id().with(id), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); } } +/// Draw a labelled status icon, painting the text directly and exposing the toggle state as its +/// accessible selected state. fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, label)); if on || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 11.0, r.center().y), vec2(15.0, 15.0)), icon, t.text_dim, false); ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, theme::font(12.0), t.text_dim); + // The visible label is painted directly, so name the interactive control explicitly and + // expose the toggle state as selected: a screen reader hears "Notes, pressed" not "Notes". + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, label)); resp } @@ -172,7 +184,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let (idx, uid) = (st.selection.slide, st.uid); // Save an edited draft once the field has lost focus or another slide is shown (before // anything reloads it), to the slide it was written on. - let id = egui::Id::new("notes_edit"); + let id = egui::Id::unique("notes_edit"); let focused = ui.ctx().memory(|m| m.has_focus(id)); if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) { app.notes_buf.2.edited = false; @@ -298,13 +310,15 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { #[cfg(test)] mod tests { - #![allow(clippy::unwrap_used)] + #![allow(clippy::unwrap_used, clippy::expect_used)] use deckcraft_engine::Session; use egui::accesskit::{Role, Toggled}; use egui::{Event, Modifiers, PointerButton}; - use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; + use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, + }; use serde_json::json; use crate::{Services, SlideApp, ViewMode}; @@ -327,6 +341,12 @@ mod tests { ); h.run_steps(3); assert!(!h.state().ui.notes); + { + let mut notes = h.query_all_by_role_and_label(Role::Button, "Notes"); + let button = notes.next().expect("the Notes button is there"); + assert!(notes.next().is_none(), "exactly one Notes button, not a duplicated label target"); + assert_eq!(button.accesskit_node().toggled(), Some(Toggled::False), "the Notes button reports its off state to screen readers"); + } // The controls on the right start 560 points from the bar's right edge, which is 8 points // inside the window (x 132); the Notes button is the leftmost of them. Its position depends // on the interface font's widths, so the clicks use the position it was laid out at. @@ -347,6 +367,11 @@ mod tests { assert!(!h.state().ui.notes, "the click in the gap did not reach the Notes button"); click(&mut h, notes.center()); assert!(h.state().ui.notes, "the click reached the Notes button"); + assert_eq!( + h.get_by_role_and_label(egui::Role::Button, "Notes").accesskit_node().toggled(), + Some(Toggled::True), + "the open notes pane is exposed as a pressed Notes button" + ); assert!(h.state().dialog.is_none(), "the Language dialog stayed closed"); } @@ -354,8 +379,6 @@ mod tests { /// hear which view button is on, and read the slide counter and the status message. #[test] fn status_bar_controls_have_names() { - use egui_kittest::kittest::NodeT; - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SlideApp| { app.logic(ui.ctx()); @@ -364,8 +387,12 @@ mod tests { SlideApp::new(Session::with_new(), Services::default()), ); h.run_steps(3); + let status_y = h.get_by_role_and_label(Role::Button, "Fit slide to current window").rect().center().y; for name in ["Fit slide to current window", "Zoom In", "Zoom Out", "Slide Show", "Reading View"] { - h.get_by_role_and_label(Role::Button, name); + // The ribbon also has a "Slide Show" control. Require exactly one named button + // in the Fit button's laid-out status row, allowing the app's UI zoom. + let buttons = h.get_all_by_role_and_label(Role::Button, name).filter(|n| n.rect().y_range().contains(status_y)).count(); + assert_eq!(buttons, 1, "one {name} button in the status bar"); } let pct = format!("{}%", (h.state().canvas_scale * 100.0).round()); assert_eq!(h.get_by_role_and_label(Role::Button, "Zoom level").value().as_deref(), Some(pct.as_str())); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 109196e3..2e14bbaa 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -189,7 +189,14 @@ pub fn font_definitions(extra: &[std::sync::Arc]) -> for (i, f) in deckcraft_fonts::CRAFT_FONTS.iter().enumerate() { let wanted = (f.family == "Inter" && (f.style == "Regular" || f.style == "SemiBold" || f.style == "Bold")) || (f.family == "BIZ UDPGothic" && f.style == "Regular") - || (f.family == "Noto Sans Arabic"); + // Exact craft-fonts family names: the Korean face is "NanumGothic" (no space), and + // the symbol faces are "Noto Sans Symbols" / "Noto Sans Symbols 2". A near-miss here + // would leave Hangul and the ◉ laser toggle without a glyph. Hebrew backs the + // Language dialog's native "עברית" label where no system font supplies it. + || matches!(f.family, "Noto Sans Arabic" | "Noto Sans Hebrew" | "NanumGothic" | "Noto Sans Symbols" | "Noto Sans Symbols 2") + // This opt-in native face follows the existing families. Keep per-character + // fallback and Latin metrics; no Chinese payload enters the wasm allowlist. + || (cfg!(not(target_arch = "wasm32")) && f.family == "LXGW WenKai Lite" && f.style == "Regular"); if !wanted { continue; } @@ -207,6 +214,15 @@ pub fn font_definitions(extra: &[std::sync::Arc]) -> let mut bold: Vec = names.iter().filter(|(fam, st, _)| *fam == "Inter" && *st == "SemiBold").map(|(_, _, n)| n.clone()).collect(); bold.extend(defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); defs.families.insert(FontFamily::Name("bold".into()), bold); + // Hack is already bundled for Monospace and covers the interface's arrows and triangles. + // Keep it after every proportional face so ordinary text metrics stay unchanged. + if defs.font_data.contains_key("Hack") { + for chain in defs.families.values_mut() { + if !chain.iter().any(|name| name == "Hack") { + chain.push("Hack".into()); + } + } + } add_fallback_faces(&mut defs, extra); // The Japanese interface face (the first one when Inter isn't embedded) hints its en and em // dashes, bars thinner than a pixel, down to nothing, so they drew as gaps in interface text; @@ -251,10 +267,15 @@ pub fn to_color32(c: deckcraft_color::Rgba) -> Color32 { #[cfg(test)] mod tests { + use super::*; + /// Each character of `text` laid out in the interface fonts at interface sizes, and whether it /// has ink (a glyph image). fn drawn(text: &str) -> Vec<(char, f32, bool)> { let ctx = egui::Context::default(); + // A replacement glyph also gets a nonempty atlas rectangle. Fail before layout when no + // font can draw a character, so `uv_rect` cannot mistake tofu for real ink. + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); ctx.set_fonts(super::font_definitions(&[])); let mut o = ctx.run_ui(Default::default(), |_| {}); o.textures_delta.clear(); @@ -262,7 +283,11 @@ mod tests { ctx.fonts_mut(|f| { for size in [11.0, 12.0, 13.0, 16.0] { for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into())] { - let g = f.layout_no_wrap(text.to_string(), egui::FontId::new(size, family), egui::Color32::WHITE); + let font = egui::FontId::new(size, family); + for c in text.chars() { + assert!(f.has_glyph(&font, c), "no font supplies {c:?} at {size} px in {font:?}"); + } + let g = f.layout_no_wrap(text.to_string(), font, egui::Color32::WHITE); for gl in g.rows.iter().flat_map(|r| &r.glyphs) { out.push((gl.chr, size, gl.uv_rect.size.x > 0.0 && gl.uv_rect.size.y > 0.0)); } @@ -274,6 +299,163 @@ mod tests { out } + /// The UI font library supplies default text fonts and an explicit monochrome emoji face, + /// independent of kittest defaults. + #[test] + fn the_ui_library_has_default_fonts_and_explicit_monochrome_emoji_for_tests() { + // `egui`'s `default_fonts` is no longer a default feature and kittest's own dev + // dependencies do not propagate. This library enables it (and the test build enables + // `monochrome_emoji_fonts`) explicitly. + let defs = FontDefinitions::default(); + assert!(defs.font_data.contains_key("Hack"), "default_fonts must be enabled by deckcraft-ui-egui itself"); + assert!(defs.font_data.contains_key("Ubuntu-Light")); + assert!(defs.font_data.contains_key("NotoEmoji-Regular"), "monochrome_emoji_fonts is an explicit test feature"); + assert!(defs.families.contains_key(&FontFamily::Proportional)); + // The production font policy still owns the UI families: craft faces first, then egui's. + let ours = super::font_definitions(&[]); + assert!(ours.families.contains_key(&FontFamily::Name("bold".into()))); + assert!(ours.font_data.contains_key("Hack")); + } + + /// When the Chinese face is present (required builds guarantee it), exactly one LXGW WenKai + /// Lite Regular is embedded, and Chinese labels have glyph coverage and ink; builds without the + /// optional face skip this test. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn native_chinese_fallback_draws_initial_document_labels() { + let faces: Vec<_> = deckcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.family == "LXGW WenKai Lite").collect(); + if faces.is_empty() { + assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native font input must include the reviewed Chinese fallback"); + eprintln!("optional Chinese input absent: bundled Chinese coverage was not qualified"); + return; + } + assert_eq!(faces.len(), 1); + assert_eq!(faces[0].style, "Regular"); + assert_eq!(faces[0].bytes.len(), 13_949_472); + let face = skrifa::FontRef::new(faces[0].bytes).unwrap(); + use skrifa::MetadataProvider as _; + assert!(face.charmap().map('简').is_some(), "the approved fallback supplies the original missing glyph"); + let defs = font_definitions(&[]); + for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] { + let chain = &defs.families[&family]; + let chinese = chain.iter().position(|name| name.contains("LXGW WenKai Lite")).unwrap(); + let japanese = chain.iter().position(|name| name.contains("BIZ UDPGothic")).unwrap(); + assert!(japanese < chinese, "retain existing glyph selection and Latin metrics"); + assert_eq!(chain.iter().filter(|name| name.contains("LXGW WenKai Lite")).count(), 1); + } + let text = "简体中文演示文稿"; + let ctx = egui::Context::default(); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] { + let font = egui::FontId::new(13.0, family); + for c in text.chars() { + assert!(fonts.has_glyph(&font, c), "the embedded fallback does not supply {c:?} in {font:?}"); + } + } + }); + let ink = drawn(text); + assert_eq!(ink.len(), text.chars().count() * 4 * 2); + for (c, size, visible) in ink { + assert!(visible, "Chinese label {c:?} has no ink at {size} px"); + } + } + + /// When the face is present (required builds guarantee it), the native Hebrew fallback is exactly + /// one static Regular Noto Sans Hebrew face with glyph coverage and ink for language labels. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn native_hebrew_fallback_is_one_regular_face_and_draws_language_labels() { + let faces: Vec<_> = deckcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.family == "Noto Sans Hebrew").collect(); + if faces.is_empty() { + assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native font input must include Noto Sans Hebrew Regular"); + eprintln!("optional font input absent: native Hebrew coverage was not qualified"); + return; + } + assert_eq!(faces.len(), 1, "one static face, without variable named instances"); + assert_eq!(faces[0].style, "Regular"); + assert_eq!(faces[0].bytes.len(), 66_156, "use the approved full static Regular face"); + use skrifa::raw::{ReadError, TableProvider}; + let face = skrifa::FontRef::new(faces[0].bytes).unwrap(); + assert_eq!(face.os2().unwrap().us_weight_class(), 400, "the embedded bytes must be Regular, not a renamed Thin face"); + assert!(matches!(face.fvar(), Err(ReadError::TableIsMissing(_))), "embed one static face, not variable named instances"); + let defs = super::font_definitions(&[]); + let names: Vec<_> = defs.font_data.keys().filter(|name| name.contains("Noto Sans Hebrew")).collect(); + assert_eq!(names.len(), 1); + for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] { + assert_eq!(defs.families[&family].iter().filter(|name| *name == names[0]).count(), 1); + } + let ctx = egui::Context::default(); + ctx.set_fonts(defs); + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + for family in [FontFamily::Proportional, FontFamily::Name("bold".into())] { + let font = egui::FontId::new(13.0, family); + for c in "עבריתשלוםdeck42".chars() { + assert!(fonts.has_glyph(&font, c), "missing language-label glyph {c:?}"); + } + } + }); + // This checks ink and coverage only, not bidi ordering or GPOS mark positioning. + for (c, size, ink) in drawn("עבריתשלוםdeck42") { + assert!(ink, "{c:?} has no ink at {size} px"); + } + } + + /// Hebrew fallback preserves logical-order editing in mixed-direction text. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn native_hebrew_fallback_keeps_mixed_text_editing_in_logical_order() { + if !deckcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "Noto Sans Hebrew") { + assert!(option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required native Hebrew face is absent"); + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(super::font_definitions(&[])); + let id = egui::Id::unique("mixed-hebrew-label-edit"); + let mut text = "deck שלום 42".to_owned(); + let frame = |text: &mut String, events: Vec| { + let output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 100.0))), + events, + ..Default::default() + }, + |ui| { + ui.memory_mut(|m| m.request_focus(id)); + ui.add(egui::TextEdit::singleline(text).id(id)); + }, + ); + output.drop_without_applying_deltas(); + }; + frame(&mut text, vec![]); + // Character offsets remain logical even though the Hebrew glyphs are drawn right-to-left. + let mut state = egui::TextEdit::load_state(&ctx, id).unwrap(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(7)))); + egui::TextEdit::store_state(&ctx, id, state); + frame(&mut text, vec![egui::Event::Text("א".into())]); + assert_eq!(text, "deck שלאום 42"); + let state = egui::TextEdit::load_state(&ctx, id).unwrap(); + assert_eq!(state.cursor.char_range().unwrap().primary.index.0, 8); + frame( + &mut text, + vec![egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ); + assert_eq!(text, "deck שלום 42"); + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.clone(), egui::FontId::proportional(13.0), egui::Color32::WHITE); + let row = &galley.rows[0]; + let x = |c| row.glyphs.iter().find(|g| g.chr == c).unwrap().pos.x; + assert!(x('ש') > x('ל') && x('ל') > x('ו') && x('ו') > x('ם'), "Hebrew paints in visual RTL order"); + assert!(x('4') < x('2'), "the adjacent number keeps its LTR order"); + }); + frame(&mut text, vec![]); // consume the atlas updates from the explicit layout above + } + #[test] fn light_checkboxes_and_slider_rails_stand_out_from_their_background() { // An unchecked checkbox and a slider rail are filled with the inactive widget fill and diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 4e3f4f0b..0fe82166 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -8,6 +8,7 @@ use serde_json::json; use crate::SlideApp; use crate::theme::{self, Tokens}; +/// Render the slide thumbnail pane, including section headers, selection, and scroll state. pub fn show(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let in_master = app.session.active().is_some_and(|d| d.selection.target != Target::Slides); @@ -32,7 +33,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(20.0); }); // Where the pane is scrolled to, for the control channel and tests (#917). - app.ui.thumbs_scroll = out.state.offset.y; + app.ui.thumbs_scroll = out.state.clamped_offset().y; }); // Focus follows the last press: inside the pane keeps it, anywhere else (canvas, ribbon) drops it. if let Some(pos) = ui.input(|i| if i.pointer.primary_pressed() { i.pointer.interact_pos() } else { None }) { @@ -40,6 +41,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } +/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes. fn list(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -53,7 +55,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { // along in the pane (#917). The scroll itself is minimal, only when the thumbnail leaves // the view, as in PowerPoint, so the user's own scrolling stands; the first frame only // records the slide. - let scroll_key = ui.id().with("thumb_scrolled_to"); + let scroll_key = ui.scope_id().with("thumb_scrolled_to"); let slide_changed = ui.data_mut(|d| { let last: Option = d.get_temp(scroll_key); d.insert_temp(scroll_key, cur); diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs index 0dcfb057..896f9049 100644 --- a/crates/ui-egui/src/toast.rs +++ b/crates/ui-egui/src/toast.rs @@ -39,6 +39,7 @@ pub fn note_missing_fonts(app: &mut SlideApp) { } } +/// Render and dismiss the active transient toast without disturbing the document. pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(toast) = &app.toast else { return }; if crate::now_ms() - toast.at > SHOW_MS { @@ -48,7 +49,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let text = toast.text.clone(); let mut close = false; - egui::Area::new(egui::Id::new("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("toast")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -44.0)).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.card_border)) @@ -60,7 +61,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap()); let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Close")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close")); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); } diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs index 6e25c696..edfd169f 100644 --- a/crates/ui-egui/src/ui_soak_tests.rs +++ b/crates/ui-egui/src/ui_soak_tests.rs @@ -9,7 +9,7 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; use deckcraft_engine::Session; -use egui::{Event, Key, Modifiers, MouseWheelUnit, Pos2, vec2}; +use egui::{Event, Key, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, vec2}; use egui_kittest::Harness; use crate::{Services, SlideApp}; @@ -83,6 +83,8 @@ fn harness() -> Harness<'static, SlideApp> { h } +/// Apply one deterministic random UI/document action and return a label describing it for the +/// soak log, which is used as context when a later assertion fails. fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { match r.below(9) { 0..=2 => { @@ -128,6 +130,7 @@ fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { delta: vec2(0.0, if r.below(2) == 0 { 3.0 } else { -3.0 }), modifiers: Modifiers::NONE, phase: egui::TouchPhase::Move, + source: MouseWheelSource::Wheel, }); format!("scroll {p:?}") } diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs index f2212a9d..9d23f8fe 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -18,11 +18,20 @@ use deckcraft_fonts::{FontDb, FontFace}; /// for the stock macOS CJK collections the font database picks: `AppleSDGothicNeo.ttc` (about /// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese). const MAX_FACE_BYTES: usize = 128 << 20; +/// Aggregate bytes of every borrowed UI face. `MAX_FACE_BYTES` alone bounded one face but left +/// `faces` unbounded; three full-size CJK collections are enough for the supported scripts. +const MAX_AGGREGATE_BYTES: usize = 3 * MAX_FACE_BYTES; fn accepts_face(byte_len: usize, color: bool) -> bool { !color && byte_len <= MAX_FACE_BYTES } +/// Accept a face only when checked addition stays within the aggregate interface-font byte +/// budget. +fn aggregate_accepts(total: usize, byte_len: usize) -> bool { + total.checked_add(byte_len).is_some_and(|n| n <= MAX_AGGREGATE_BYTES) +} + /// Faces borrowed for the interface, and the characters already looked at. #[derive(Default)] pub struct UiFonts { @@ -81,9 +90,23 @@ impl UiFonts { |c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())), ) }); - if !new.is_empty() { - log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::>().join(", ")); - self.faces.extend(new); + let mut total: usize = self.faces.iter().map(|f| f.data().len()).sum(); + let accepted: Vec<_> = new + .into_iter() + .filter(|f| { + let n = f.data().len(); + if aggregate_accepts(total, n) { + total += n; + true + } else { + log::debug!("interface fallback face {} skipped: aggregate font budget", f.family); + false + } + }) + .collect(); + if !accepted.is_empty() { + log::debug!("interface fallback fonts: {}", accepted.iter().map(|f| f.family.as_str()).collect::>().join(", ")); + self.faces.extend(accepted); self.changed = true; } } @@ -178,6 +201,16 @@ mod tests { FontDb::global().face(family, "Regular") } + /// The aggregate font budget accepts the exact cap and rejects overflow or over-budget + /// additions. + #[test] + fn aggregate_font_budget_is_bounded_and_checked() { + assert!(aggregate_accepts(0, MAX_FACE_BYTES), "the first face is accepted at the per-face limit"); + assert!(aggregate_accepts(2 * MAX_FACE_BYTES, MAX_FACE_BYTES), "three faces reach the aggregate limit"); + assert!(!aggregate_accepts(3 * MAX_FACE_BYTES, 1), "one byte over the aggregate limit is refused"); + assert!(!aggregate_accepts(usize::MAX, 1), "overflow is refused, not wrapped"); + } + #[test] fn interface_face_policy_rejects_colour_and_oversize_faces() { assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 67549ce3..5bfbf041 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -31,28 +31,44 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Respon big_button_ex(ui, icon, label, enabled, false, false).0 } -/// Large button; with `split`, the label row is a separate dropdown target (returns its response). +/// Width of the dropdown target at the right of a large split button. It is reserved outside the +/// whole label area, so no visible line of a one- or two-line label can open the menu. +const SPLIT_ARROW_W: f32 = 14.0; + +/// Return the chevron-only sub-rectangle at the right edge of a split button. +fn split_arrow_rect(rect: Rect) -> Rect { + Rect::from_min_max(pos2(rect.max.x - SPLIT_ARROW_W, rect.min.y + 38.0), rect.max) +} + +/// Width of a large button: padding plus, for a split button, the reserved arrow strip. +fn big_button_width(text_w: f32, split: bool) -> f32 { + (text_w + 10.0 + if split { SPLIT_ARROW_W } else { 0.0 }).max(40.0) +} + +/// Large button; with `split`, the label row ends in a separate dropdown target (returns its +/// response). Like Office's split buttons, the icon and the label text run the main command; only +/// the chevron at the end of the label row opens the menu. pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let font = theme::font(11.0); let lines: Vec<&str> = wrap_label(label); let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); - let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); + let w = big_button_width(text_w, split); + // The whole button runs the main command; the narrow strip at the end of the label row is the + // dropdown. A split button therefore has two named targets, not a third unnamed row target. let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). let spoken = label.replace('\n', " "); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); - let (main_rect, arrow_rect) = if split { - (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) - } else { - (rect, None) - }; - let main_resp = - if split { ui.interact(main_rect, resp.id.with("main"), if enabled { Sense::click() } else { Sense::hover() }) } else { resp.clone() }; + let arrow_rect = split.then(|| split_arrow_rect(rect)); + let main_resp = resp.clone(); let arrow_resp = arrow_rect.map(|r| ui.interact(r, resp.id.with("arrow"), if enabled { Sense::click() } else { Sense::hover() })); + if split && let Some(a) = &arrow_resp { + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{spoken} options"))); + } if let Some(c) = bg(ui, &main_resp, selected, &t) { - painter.rect_filled(if split { main_rect } else { rect }, CornerRadius::same(4), c); + painter.rect_filled(rect, CornerRadius::same(4), c); } if let (Some(ar), Some(a)) = (arrow_rect, &arrow_resp) && let Some(c) = bg(ui, a, false, &t) @@ -62,18 +78,19 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let icon_rect = Rect::from_center_size(pos2(rect.center().x, rect.min.y + 19.0), vec2(30.0, 30.0)); icons::paint(&painter, icon_rect, icon, t.text, !enabled); let col = if enabled { t.text } else { t.text_faint }; + let label_rect = if split { Rect::from_min_max(rect.min, pos2(rect.max.x - SPLIT_ARROW_W, rect.max.y)) } else { rect }; let mut y = rect.min.y + 39.0; - for (i, l) in lines.iter().enumerate() { - let last = i + 1 == lines.len(); + for l in &lines { let label_w = ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), col).size().x); - let extra = if split && last { 9.0 } else { 0.0 }; - let x = rect.center().x - (label_w + extra) / 2.0; + let x = label_rect.center().x - label_w / 2.0; painter.text(pos2(x, y), Align2::LEFT_TOP, l, font.clone(), col); - if split && last { - chevron(&painter, pos2(x + label_w + 5.0, y + 7.0), 3.0, col); - } y += 12.5; } + if split { + // The chevron sits in the reserved strip beside the label's last line, never on its text. + let chevron_y = rect.min.y + 39.0 + 12.5 * lines.len().saturating_sub(1) as f32 + 7.0; + chevron(&painter, pos2(rect.max.x - 7.0, chevron_y), 3.0, col); + } (if split { main_resp } else { resp }, arrow_resp) } @@ -113,15 +130,33 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte small_button_ex(ui, icon, label, enabled, selected, false).0 } +/// Draw a small named button, optionally with a separately named options chevron, and return +/// both click targets. pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option) { + small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split) +} + +/// A small button whose accessible name is independent of its visible label. Split arrows are +/// named after their own command, not a shared "More options", so assistive technology can tell +/// Bullets, Numbering and the shape-format controls apart. +pub fn small_button_named( + ui: &mut Ui, + icon: Icon, + label: &str, + name: &str, + enabled: bool, + selected: bool, + split: bool, +) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let font = theme::font(12.0); let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 }; let arrow_w = if split { 12.0 } else { 0.0 }; let w = 24.0 + tw + arrow_w; - let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled { Sense::click() } else { Sense::hover() }); - if !label.is_empty() { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + // As in `big_button_ex`, a split row is two targets and must not add a third named node. + let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), if enabled && !split { Sense::click() } else { Sense::hover() }); + if !name.is_empty() && !split { + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); } let painter = ui.painter_at(rect); let main_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - arrow_w, rect.max.y)); @@ -134,6 +169,12 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele if enabled { Sense::click() } else { Sense::hover() }, ) }); + if split { + main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); + if let Some(a) = &arrow_resp { + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options"))); + } + } if let Some(c) = bg(ui, &main_resp, selected, &t) { painter.rect_filled(main_rect, CornerRadius::same(4), c); } @@ -152,6 +193,54 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele (if split { main_resp } else { resp }, arrow_resp) } +/// Open one menu from either half of a split control that has no separate main action. +/// +/// Both halves keep their own names for assistive technology, but the popup has one stable id +/// and one anchor spanning the complete painted control. Clicking the icon/label or the chevron +/// therefore opens and closes the same menu, and neither named target is a dead end. +pub fn split_menu(main: &Response, arrow: &Response, add: impl FnOnce(&mut Ui)) { + split_popup(main, arrow).show(|ui| add(ui)); +} + +/// The shared popup behind [`split_menu`], for callers that adjust its width or close behavior. +pub fn split_popup<'a>(main: &'a Response, arrow: &'a Response) -> egui::Popup<'a> { + // Pointer clicks read Context input, so resolve them before taking the data lock below. + let main_clicked = main.clicked(); + let arrow_clicked = arrow.clicked(); + let toggle = (main_clicked || arrow_clicked).then_some(egui::SetOpenCommand::Toggle); + let id = split_popup_id(main); + // The popup's geometry and identity are shared, but keyboard entry belongs to the half that + // opened it. Remember that origin while focus moves into the menu (or the Eyedropper runs). + let anchor = main.ctx.data_mut(|data| { + let key = id.with("anchor"); + if arrow_clicked { + data.insert_temp(key, arrow.id); + } else if main_clicked { + data.insert_temp(key, main.id); + } + data.get_temp::(key).unwrap_or(main.id) + }); + egui::Popup::menu(main).id(id).anchor(main.rect.union(arrow.rect)).anchor_widget(anchor).open_memory(toggle) +} + +/// A dropdown-only split whose menu contains a colour grid, retaining the Eyedropper hand-back. +pub fn split_color_popup<'a>(main: &'a Response, arrow: &'a Response) -> egui::Popup<'a> { + let id = split_popup_id(main); + let mut popup = split_popup(main, arrow).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + if crate::eyedropper::result_waits_for(&main.ctx, id) { + popup = popup.open_memory(egui::SetOpenCommand::Bool(true)); + } + if popup.is_open() { + crate::eyedropper::note_open_menu(&main.ctx, id); + } + popup +} + +/// Derive the popup id from the main button response so a split menu follows that button. +fn split_popup_id(main: &Response) -> egui::Id { + main.id.with("split-menu") +} + /// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the /// [`small_button`] items around it. pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response { @@ -159,7 +248,7 @@ pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response let font = theme::font(12.0); let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0; let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, enabled, on, label)); let painter = ui.painter_at(rect); if let Some(c) = bg(ui, &resp, false, &t) { painter.rect_filled(rect, CornerRadius::same(4), c); @@ -181,7 +270,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) } icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); // Named for screen readers (and tests) by its tooltip. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); resp.on_hover_text(tip) } @@ -190,11 +279,13 @@ pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover()); let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); let arrow = ui.interact( Rect::from_min_max(pos2(rect.min.x + 23.0, rect.min.y), rect.max), resp.id.with("a"), if enabled { Sense::click() } else { Sense::hover() }, ); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{tip} options"))); let painter = ui.painter_at(rect); if let Some(c) = bg(ui, &main, false, &t) { painter.rect_filled(main.rect, CornerRadius::same(4), c); @@ -259,6 +350,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, if enabled { t.text } else { t.text_faint }, ); chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current })); let _ = id; egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items); @@ -370,7 +462,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - out = Some(None); } // "More Colors…": a hex field. - let id = ui.id().with("hex"); + let id = ui.scope_id().with("hex"); let mut hex: String = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); ui.horizontal(|ui| { ui.label("More Colors… #"); @@ -422,15 +514,16 @@ pub fn gallery( clicked } -/// A labelled number field with up/down steppers. Returns the new value when committed. -pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option { +/// A number field with up/down steppers, named by the visible label of its row. Returns the new +/// value when committed. +pub fn spinner(ui: &mut Ui, id: &str, label: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option { let t = Tokens::get(ui.ctx()); - let key = ui.id().with(id); + let key = ui.scope_id().with(id); let mut text: String = ui.data_mut(|d| d.get_temp::(key)).unwrap_or_else(|| fmt_num(value, suffix)); let mut out = None; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))); + let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))).accessible_name(format!("{label} value")); if r.has_focus() { ui.data_mut(|d| d.insert_temp(key, text.clone())); } else { @@ -444,7 +537,9 @@ pub fn spinner(ui: &mut Ui, id: &str, value: f64, step: f64, suffix: &str, width } let (rect, _) = ui.allocate_exact_size(vec2(12.0, 22.0), Sense::hover()); let up = ui.interact(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.center().y)), key.with("up"), Sense::click()); + up.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Increase {label}"))); let down = ui.interact(Rect::from_min_max(pos2(rect.min.x, rect.center().y), rect.max), key.with("dn"), Sense::click()); + down.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Decrease {label}"))); let p = ui.painter(); let c = rect.center(); p.line_segment([pos2(c.x - 3.0, c.y - 3.0), pos2(c.x, c.y - 6.0)], Stroke::new(1.0, t.text_dim)); @@ -495,7 +590,9 @@ pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { /// A button with an optional label and a painted dropdown chevron (the UI font has no ▾). pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); - let r = ui.add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size)); + let r = ui + .add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size)) + .on_hover_text(if label.is_empty() { "Options" } else { label }); let col = if enabled { t.text_dim } else { t.text_faint }; chevron(ui.painter(), egui::pos2(r.rect.max.x - 8.0, r.rect.center().y), 3.0, col); r @@ -511,6 +608,155 @@ mod tests { use egui::accesskit::Role; use egui_kittest::kittest::Queryable; + /// Office's large split button: the icon and the visible label text run the main command; + /// only the chevron at the end of the label row opens the options menu. The old geometry made + /// the whole label row the arrow target, so clicking "Paste" opened its menu instead of + /// pasting. + #[test] + fn a_large_split_buttons_label_runs_the_command_and_its_chevron_opens_the_options() { + use std::cell::Cell; + use std::rc::Rc; + + let main_clicks = Rc::new(Cell::new(0u32)); + let arrow_clicks = Rc::new(Cell::new(0u32)); + let (main_counter, arrow_counter) = (main_clicks.clone(), arrow_clicks.clone()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(200.0, 100.0)).build_ui(move |ui| { + theme::install_fonts(ui.ctx()); + let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", true, true, false); + if main.clicked() { + main_counter.set(main_counter.get() + 1); + } + if arrow.is_some_and(|a| a.clicked()) { + arrow_counter.set(arrow_counter.get() + 1); + } + }); + harness.run(); + let main_rect = harness.get_by_role_and_label(Role::Button, "Paste").rect(); + let arrow_rect = harness.get_by_role_and_label(Role::Button, "Paste options").rect(); + let label = egui::pos2(main_rect.center().x, main_rect.min.y + 45.0); + assert!(main_rect.contains(label) && !arrow_rect.contains(label), "the visible label is outside the chevron strip"); + + /// Nested large-split-button test helper: move and press/release the primary pointer at a + /// position. + fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) { + harness.input_mut().events.push(egui::Event::PointerMoved(pos)); + harness.run(); + for pressed in [true, false] { + harness.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + harness.run(); + } + } + click(&mut harness, label); + assert_eq!(main_clicks.get(), 1, "clicking the label runs the main command"); + assert_eq!(arrow_clicks.get(), 0, "clicking the label does not open the menu"); + + click(&mut harness, arrow_rect.center()); + assert_eq!(main_clicks.get(), 1, "clicking the chevron does not run the main command"); + assert_eq!(arrow_clicks.get(), 1, "clicking the chevron opens the options"); + } + + /// A two-line split label reserves the arrow strip outside *all* visible text. The click + /// lands on the last line's widest glyph pixels (the old overlap) and runs the main command; + /// only the named arrow opens the menu. + #[test] + fn a_two_line_split_labels_last_text_pixels_run_the_command() { + use std::cell::Cell; + use std::rc::Rc; + + let main_clicks = Rc::new(Cell::new(0u32)); + let arrow_clicks = Rc::new(Cell::new(0u32)); + let last_line = Rc::new(RefCell::new(None)); + let (main_counter, arrow_counter) = (main_clicks.clone(), arrow_clicks.clone()); + let (last_line_out, last_line_cell) = (last_line.clone(), last_line.clone()); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(220.0, 100.0)).build_ui(move |ui| { + theme::install_fonts(ui.ctx()); + let label = "Send\nBackward"; + let (main, arrow) = big_button_ex(ui, Icon::SendBackward, label, true, true, false); + let font = theme::font(11.0); + let col = Tokens::get(ui.ctx()).text; + let lines = wrap_label(label); + let rect = main.rect; + let label_rect = Rect::from_min_max(rect.min, pos2(rect.max.x - SPLIT_ARROW_W, rect.max.y)); + let last = lines.last().copied().unwrap_or_default(); + let last_w = ui.fonts_mut(|f| f.layout_no_wrap(last.to_string(), font.clone(), col).size().x); + let y = rect.min.y + 39.0 + 12.5 * lines.len().saturating_sub(1) as f32; + let x = label_rect.center().x - last_w / 2.0; + *last_line_cell.borrow_mut() = Some(Rect::from_min_size(pos2(x, y), vec2(last_w, 12.0))); + if main.clicked() { + main_counter.set(main_counter.get() + 1); + } + if arrow.is_some_and(|a| a.clicked()) { + arrow_counter.set(arrow_counter.get() + 1); + } + }); + harness.run(); + let arrow_rect = harness.get_by_role_and_label(Role::Button, "Send Backward options").rect(); + let text = last_line_out.borrow().expect("last line laid out"); + assert!(text.max.x < arrow_rect.min.x, "the reserved strip is outside all visible text: {text:?} vs {arrow_rect:?}"); + let label_pixels = pos2(text.max.x - 1.0, text.center().y); + assert!(!arrow_rect.contains(label_pixels)); + + /// Nested two-line-split-button test helper: move and press/release the primary pointer at a + /// position. + fn click(harness: &mut egui_kittest::Harness<'_, ()>, pos: egui::Pos2) { + harness.input_mut().events.push(egui::Event::PointerMoved(pos)); + harness.run(); + for pressed in [true, false] { + harness.input_mut().events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + harness.run(); + } + } + click(&mut harness, label_pixels); + assert_eq!(main_clicks.get(), 1, "the last line's real text pixels run the main command"); + assert_eq!(arrow_clicks.get(), 0, "the last line's text does not open the menu"); + + click(&mut harness, arrow_rect.center()); + assert_eq!(main_clicks.get(), 1, "the arrow does not run the main command"); + assert_eq!(arrow_clicks.get(), 1, "the arrow opens the options"); + } + + /// Small split arrows carry their own command name; bullets and numbering must not both be + /// announced as a generic "More options" target. + #[test] + fn small_split_arrows_are_named_for_their_own_commands() { + let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 100.0)).build_ui(|ui| { + theme::install_fonts(ui.ctx()); + let _ = small_button_named(ui, Icon::Bullets, "", "Bullets", true, false, true); + let _ = small_button_named(ui, Icon::Numbering, "", "Numbering", true, false, true); + }); + harness.run(); + for name in ["Bullets", "Bullets options", "Numbering", "Numbering options"] { + assert!(harness.get_by_role_and_label(Role::Button, name).rect().width() > 0.0, "{name} is a named target"); + } + assert!(harness.query_by_role_and_label(Role::Button, "More options").is_none(), "no shared arrow name remains"); + } + + /// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its + /// current value (0.37's strict unnamed-input check would otherwise reject it). + #[test] + fn the_dropdown_has_a_combo_box_role_and_a_name() { + let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + theme::install_fonts(ui.ctx()); + dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| { + ui.label("12 pt"); + ui.label("18 pt"); + }); + }); + harness.run(); + let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt"); + assert!(combo.rect().width() > 100.0); + } + /// #32: clicking the "More Colors" hex field closed the colour menu before anything could be /// typed. It stays open now; Enter picks the colour and closes it. #[test] @@ -561,7 +807,7 @@ mod tests { assert!(harness.query_by_label("Eyedropper").is_none(), "choosing it closes the menu"); assert!(picked.borrow().is_none(), "nothing is picked yet"); // The app starts the Eyedropper from this request; here the colour is taken at once. - assert!(harness.ctx.data_mut(|d| d.remove_temp::(egui::Id::new("deckcraft.eyedropper.request"))).unwrap_or(false)); + assert!(harness.ctx.data_mut(|d| d.remove_temp::(egui::Id::unique("deckcraft.eyedropper.request"))).unwrap_or(false)); let menu = crate::eyedropper::open_menu(&harness.ctx).expect("the open menu noted its button"); crate::eyedropper::store_result(&harness.ctx, menu, "00FF00"); harness.run(); diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 00000000..3dd89c98 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1496 @@ +# DeckCraft: egui 0.37 migration plan + +Current candidate font pin: `37827adc41fd165f9a9234ac40f94dbad353aa69` restores the complete prior Latin catalog alongside the migration fallbacks. All three workflows use that published revision. + +Status update (2026-10-25): the candidate includes main `80f13011d5a3c353369c6e38250df6e5a260f3e0`, preserving its table-cell selection, text AutoFormat, edit-point history, fill controls, status accessibility and GPU-test serialization. IME deletion's undo entry retains main's new point-type state. Status-control assertions locate the actual laid-out status row, avoiding the identically named ribbon control without assuming fixed UI zoom. Managed run `egui037-deckcraft-status-row-ci-20261025-0753` passed the focused status regression and all eight CI stages: 2,045 passing test executions, zero failures and six ignored across 61 summaries. These are execution counts, including repeated tests. Required font coverage was separately validated in run 0724; the merged tree retains it and its tests. Cargo dependency pins and lockfile are unchanged by this refresh. Native/browser GUI, native IME and paired application performance remain open. Publication requires separate explicit approval; human review and human merge are required. + +Review follow-up (2026-10-25): managed run `egui037-deck-review-green-ci-20261025-0810` passed the required-font regression and all eight CI stages, including WASM: 2,058 passing test executions, zero failures and six ignored across 61 summaries. Native builds now require BIZ UDPGothic Bold while web builds require Regular only; the missing-Bold regression failed with the old acceptance behavior and passed with the fix. Accessibility queries now distinguish combo-box purpose from selected value. Complexity refactors preserve the existing behavior. Long-paragraph IME deletion still scans a prefix/suffix; bounded-cost and paired performance qualification remain open. Remote SonarCloud and CodeRabbit must rerun on the eventual published commit. + +The original planning record below is historical. + +## Earlier qualification before the combined font input — 2026-10-25 + +The local candidate reconciles `main` through +`38d2ade2b77de39c0e06278ddfd44d1082ff80a8` with the migration base +`770f4653bff0700b33d8475181e168da90a7ad4f`. All five source files listed in the +historical review below belong in the review commit. The source passed full CI +job `egui037-deckcraft-regression-ci-20261025-0445`: all eight stages, with 1,991 +test passes, zero failures and six ignored tests across 60 test summaries. + +The final regression additions exercise the Notes accessibility toggle, no-document +Open/Actions/Preferences/File-menu shortcuts, ignored document-only shortcuts, and +typing/IME isolation. Earlier validation failures and source-completeness warnings +below describe retained history; the current review packet records the complete +commit and its parents. **The current review/approval packet is stale for publication +pending a combined font pin:** `b076417…` has no Latin manifest/Inter, so the pinned +input cannot supply the default document family. Native/browser workflows and paired +app performance remain open. No publication or merge is authorized by these results. + +## Historical planning and review records + +> **Status:** final current local review recorded, 2026-10-25. The reviewed source is the dirty +> migration union in the worktree below: local base `770f4653bff0700b33d8475181e168da90a7ad4f` +> plus tracked edits and five untracked source files. That base HEAD alone is **not** a qualified +> release; any eventual commit must include all five untracked files (see the final status section). +> Build, test, benchmark, commit and publication remain with the parent's managed `task.py` +> workflow and its separate publication approval. +> **Implementation checkout:** `/home/codex/work/CraftApps/egui037-migration-20261022/deckcraft-implementation` +> at local base `770f4653bff0700b33d8475181e168da90a7ad4f` (dirty union). The earlier dirty +> implementation snapshot `9aef21e627cb447dd2180cd804db85343e73e5cf` and the historical planning +> baseline `b47195feec0f0fe4220d8943ad1c8add5dad8072` are retained only as review history. +> **Target:** egui-family `0.37.0`. The stock crates.io release is a local comparison experiment, +> not a staged consumer release; production uses reviewed, published, immutable storytold full SHAs +> for the complete egui family and winit. Publication of any pin requires a separate, explicit +> human approval of the exact contents and validation. +> **Review:** a fresh adversarial review of this plan, then a separate adversarial review of each +> implementation PR, is required before landing. This revision resolves the independent review in +> `plan-reviews/deckcraft-v1.md`: the lifecycle phase model, screenshot ownership, font feature, +> E21b/PRIMARY gaps, production pin provenance and performance sampling have been corrected. The +> final current local review is `final-landing/deck-final-review0045.txt`; its disposition is +> recorded in the final status section. + +This plan is app-specific to DeckCraft. It is grounded in the baseline source and in the 0.37 +sources available in the migration workspace; it is not a generic template. The repository rules +in `AGENTS.md` (asset policy, clean room, never crash, command architecture, quality gates) apply +in full. The cross-app engineering decision guide +(`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/local-guidance/engineering-decisions.md`) +also applies: keep app-owned commands, document state, undo, persistence and native windows; prefer +an app-owned adapter over a generic compatibility layer; validate observable behavior at the exact +reviewed revision. + +--- + +Capture acceptance follows the suite contract: a queued-not-issued hidden request and +each settling frame must request `RequestPaintWhileHidden` from logic; only an issued +screenshot command forces paint itself. Callback arrival has no implicit wake, so the +live request owns the viewport waker. Record known issuance UI bounds and a geometry +epoch, reject/retry any intervening resize/DPI/zoom/surface change even if it changes +back, and derive pixel scale from returned dimensions only under an unchanged epoch. +Default limits are 4 requests, 128 MiB/image and 256 MiB aggregate. Count reserved +in-flight and completed images together. + + +## 1. Executive decision + +DeckCraft already uses the 0.36 `eframe::App::logic` / `App::ui` split and the root `Ui`; the +0.34–0.36 "more `Ui`, less `Context`" transition is **not** new work here. The 0.37 migration is +therefore mostly: + +1. a bounded API migration (`Role`, `Id::unique`, callback-based screenshots, `Event::MouseWheel` + `source`, new IME/clipboard events, kittest accessibility/glyph checks); +2. a lifecycle hardening pass so hidden and multi-pass frames do not duplicate or drop app side + effects; +3. a pinned suite fork and winit integration for bidi/language, IME, non-Latin shortcuts, + Wayland clipboard and the egui-wgpu surface fixes; +4. measured UI regressions and performance, with explicit acceptance thresholds. + +Do **not** adopt `craft-ui`, `craft-window`, `egui_dock` or a new generic compatibility layer as part +of this migration. DeckCraft does not depend on `craft-ui` today; `craft-ui` PR 199 and language +work #198 are useful precedent and the eventual shared home, not a reason to replace DeckCraft's +ribbon, panes, canvas, dialogs, commands or persistence. The only `craft-libs` dependencies in +DeckCraft remain `craft-pdf` and the `krilla` patch at their current immutable revision. + +--- + +## 2. Baseline inventory + +### 2.1 Exact revisions and dependency state + +| Item | Baseline | +|---|---| +| DeckCraft | baseline snapshot of this plan `9aef21e627cb447dd2180cd804db85343e73e5cf` (superseded dirty snapshot); historical planning baseline `b47195feec0f0fe4220d8943ad1c8add5dad8072`. Current reviewed local base is `770f4653bff0700b33d8475181e168da90a7ad4f` (dirty union, not a qualified commit) | +| `Cargo.lock` | format 4; `egui`/`eframe`/`egui-wgpu`/`egui-winit`/`egui_kittest`/`epaint`/`ecolor`/`emath`/`epaint_default_fonts` all `0.36.2` | +| winit | `0.30.13` from crates.io (`a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d`) | +| wgpu | `30.0.1` in this lock. Upstream egui `0.37.0` holds `30.0.0` (its `Cargo.toml` comment keeps that version for `wasm-bindgen-futures`); they are semver-compatible but a different lock, so the wasm dependency set must be re-verified after the bump | +| accesskit | `0.24.1`; `accesskit_consumer` 0.35.0, 0.36.0 and 0.38.0 both present; `kittest` 0.4.0 | +| craft-libs | `craft-pdf = "=0.1.0"` and `[patch.crates-io] krilla = "=0.8.2"`, both at rev `c3487320b2d1de657b860b3c8abb44e1f2b32bd8` | +| MSRV | workspace `rust-version = "1.95"`, same as egui 0.37.0 | +| Stock experiment target | upstream tag `0.37.0`, peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`. This is a local comparison experiment, never a staged consumer release | +| Candidate fork sources (comparison only) | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916`; `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`. These personal-fork SHAs are not production pins | +| Production pin requirement | reviewed, published, immutable **storytold** full SHAs for the complete egui family **and** winit, including W15P PRIMARY; one coherent atomically landed graph. No `aiongg`/personal fork in a production manifest and no stock-only intermediate release | +| Prior published 0.36.2 | storytold/egui `craft/0.36.2` at `767b1cad294120689a514bb4bd68c077e46679b9` | +| craft-libs reference | merged PR 199 base `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` (current shared-library implementation base). Historical plan-only source `8614e4a04fae516fba16c22b31ff1008cb6be10b`/README `1764bca` and main `49190884278f16c9a9f5823d7698a8ffb5deb062` are superseded. Do not use PR head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` or audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` as trees | + +The contract's `0.37.0` annotated tag object SHA is not the peeled commit SHA; all pins in this +plan use the peeled commit. A branch name is not a publication pin. The `aiongg` revisions above +are source-review inputs; production manifests must name reviewed storytold commits, and the +stock 0.37.0 stage exists only to isolate the API diff. + +### 2.2 Manifests and packages + +| Manifest/package | egui-family use | Notes | +|---|---|---| +| root `Cargo.toml` | workspace `egui = "0.36"` with no feature, `egui_extras = "0.36"` (unused), `eframe = "0.36"` (`default-features = false`), `egui_kittest = "0.36"`; `[patch.crates-io] krilla` | `egui_extras` has no consumer; do not add or patch it "for completeness" | +| `crates/ui-egui/Cargo.toml` (`deckcraft-ui-egui`) | `egui = { workspace = true }` with **no `default_fonts`**; dev `egui_kittest` with `wgpu, snapshot`; build `serde_json, toml` | Library and example `examples/icon_gallery.rs`. Add `features = ["default_fonts"]` here (or to a shared workspace feature) so standalone `cargo test -p deckcraft-ui-egui` does not depend on an app's feature unification | +| `crates/fonts/Cargo.toml` (`deckcraft-fonts`) | direct `epaint_default_fonts = "0.36"` | Must move to 0.37 and be patched to the same fork source when the fork is used, or a second `epaint_default_fonts` source appears | +| `apps/deckcraft/Cargo.toml` (`deckcraft`) | `eframe` with `default_fonts, wgpu, accesskit, links, wayland, x11`; `egui`; native `rfd, arboard, image, cpal`; macOS `fmv-macos-events`; Linux/BSD direct `winit 0.30` with X11 | `eframe` defaults are off, so the feature list is explicit; winit must be patched consistently with eframe on Linux/BSD | +| `apps/deckcraft-web/Cargo.toml` (`deckcraft-web`) | wasm-only `eframe` with `default_fonts, wgpu, accesskit`; `rfd`, `wasm-bindgen`, `web-sys` | WebGPU with WebGL2 fallback; trunk build | +| `apps/deckcraft-cli`, `crates/mcp`, engine and below | no egui | Unaffected except through `deckcraft-ui-egui` tests | +| `xtask/Cargo.toml` | no egui | CI/bench/parity tooling | +| `apps/deckcraft/build.rs` | Windows icon/VERSIONINFO only | No egui change | + +There are no `vendor/` directories, no nested Cargo workspaces, no `[patch]` sections outside the +root, and no `egui_extras`, `egui_plot`, `egui_dock`, `egui_tiles` or `egui_system_font_provider` +consumers. The only vendored/forked code relevant to this migration is the suite's egui/winit +forks and the existing `krilla` patch, which must be preserved. + +### 2.3 Entry points and target-specific code + +- `apps/deckcraft/src/main.rs:39-62` — `impl eframe::App for App`: `logic`, `raw_input_hook`, + `ui`, `on_exit`. `on_exit` saves preferences and, on macOS, exits before eframe/AccessKit tears + down the window (#279). +- `apps/deckcraft/src/main.rs:212-311` — `main`: `NativeOptions`, title bar/fullsize flags, + app id/icon, X11/Wayland choice, Windows DX12/FXC configuration, macOS Apple events, run_native, + `SlideApp::new`, preferences, AutoRecover open, control server, sample/open files, show start. +- `apps/deckcraft/src/control_server.rs` — localhost JSON-lines control server; passes a cloned + `egui::Context` to requests; has tests for timeouts/abandonment. +- `apps/deckcraft/src/display.rs` — Linux/BSD X11 vs Wayland choice; `with_x11` event-loop hook. +- `apps/deckcraft/src/graphics.rs` — Windows DX12-only wgpu instance and FXC compiler defaults. +- `apps/deckcraft/src/apple_events.rs` — Finder/quit Apple events; calls `ctx.request_repaint`. +- `apps/deckcraft/src/pasteboard.rs` — arboard HTML and image pasteboard. +- `apps/deckcraft-web/src/main.rs` and `web.rs:76-116` — wasm shell, `WebShell::{logic, raw_input_hook, ui}`; + browser drop-read and picker inbox; downloads. +- `crates/ui-egui/src/lib.rs` — `SlideApp` state (`:435-518`), `run` (`:656-674`), `run_ui` + (`:838...`), `logic` (`:1540-1602`), `raw_input_hook` (`:1652-1655`), `keyboard` + (`:1668-1800`), `ui` (`:1798-1872`), screenshot queue (`:1903-1972`), `now_ms` + (`:2001-2011`). `canvas::ime_output` (`:321-333`) constructs a custom `IMEOutput`. +- `crates/ui-egui/src/doc_window.rs:84-117` — `show_viewport_immediate` second document window. +- `crates/ui-egui/src/show.rs:1757-1885` — presenter `show_viewport_immediate`; `show::ui` + (`:521...`) full-screen show. +- `crates/ui-egui/src/panes.rs`, `status.rs`, `thumbs.rs`, `sorter.rs`, `backstage.rs`, + `ribbon.rs` — root-`Ui` panels and nested panel order. +- `crates/ui-egui/src/theme.rs` and `uifonts.rs` — custom `FontDefinitions`, font fallback, + `ctx.set_fonts`; `theme.rs` tests use `ctx.run_ui`. +- `crates/ui-egui/src/textures.rs` — `max_side`, `fit_texture`, `shrink_to`, + `editor_texture_options`, texture cache; important for the 0.37 surface/texture limits. + +### 2.4 Existing tests, examples, CI, performance + +- Extensive unit/integration tests live in `crates/ui-egui/src/*_tests.rs` and inline `#[cfg(test)]` + modules. Most UI tests build `egui_kittest::Harness` with + `build_ui_state(|ui, app| { app.logic(ui.ctx()); app.ui(ui); }, SlideApp::new(...))`. + `ui_soak_tests.rs` has an ignored long soak selected by `DECKCRAFT_UI_SOAK_STEPS`. +- There are no committed snapshot images or snapshot calls in the baseline UI tests; the + `snapshot` kittest feature is enabled but only a few tests call `h.render()` to `/tmp`. +- `crates/ui-egui/examples/icon_gallery.rs` renders the icon set; it is not a UI golden test. +- `cargo xtask ci` is the repository gate: `fmt --check`, `pdf-deps`, `clippy -D warnings`, + `cargo test --workspace`, asset attribution, layering, wasm32 checks. `cargo xtask bench` + wraps `deckcraft-cli bench` and compares against `docs/benchmarks/baseline.json`; **that file + does not exist in the baseline**, so the first implementation task is to create a baseline in + evidence, not to treat a missing file as a pass. +- GitHub CI is Ubuntu-only for gates; FreeBSD is a reusable workflow; Windows ARM64 and release + packaging are separate; macOS is release-only. The Windows build/GUI evidence must come from a + fresh `VMs/craft-windows` clone under the server workflow, not from Linux cross-compilation. +- Historical compiler diagnostics under + `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/deckcraft.errors` + are clues about the old source state, not acceptance for this checkout. They identify `Role` + renames, `UserData`/`Event::Screenshot`, and `Event::MouseWheel.source`. + +Historical errors and their intended target (recheck against the current tree; the audit +checkouts are not this checkout): + +| Historical diagnostic | Likely source anchor in the baseline | Intended target | +|---|---|---| +| `no variant ... TextEdit` for `Role` | `dialogs.rs:553,601,712` | `Role::TextInput` (and a real accessible name) | +| `no variant ... Other` for `Role` | `panes.rs:1077,1079` | `Role::Unknown` initially; then decide on a focusable `Role::Slider` | +| `no variant ... SelectableLabel` | `selpane.rs:160` | `Role::Button` with selected state | +| `no variant ... Checkbox` | `widgets.rs:162` | `Role::CheckBox` | +| `cannot find UserData in egui` | `lib.rs:1931` | `ScreenshotCallback`/`Context::request_screenshot` | +| `no variant ... Screenshot` in `Event` | `lib.rs:1951` | callback result channel, no event scan | +| missing `source` in `Event` initializer | `canvas.rs:1380`, `lib.rs:3300`, test wheel events | `MouseWheelSource::Wheel`/`Unknown` | + +--- + +## 3. What actually changes in egui 0.37 + +### 3.1 Breaking and behavior-changing items used by DeckCraft + +Verified against the stock `0.37.0` sources and the suite candidate: + +| Area | 0.36.2 baseline | 0.37.0 target | DeckCraft action | +|---|---|---|---| +| Widget roles | `egui::WidgetType` | deprecated alias of `accesskit::Role`; `TextEdit -> TextInput`, `Checkbox -> CheckBox`, `SelectableLabel -> Button`, `Other -> Unknown` | Replace imports and variants; keep `ComboBox`, `Button`; choose semantic roles for custom controls where safe | +| ID API | `Id::new`, `AsId`, `Ui::id` | `Id::unique`; `Ui::scope_id` (with `Ui::id` deprecated); `UiBuilder::scope_id`; `Ui::unique_id` is globally unique but order-dependent and explicitly unstable; `Panel` constructors take `impl AsIdSalt` | Replace `Id::new` with `Id::unique` and every `ui.id()`/`UiBuilder::id` with `scope_id` (38 `ui.id()` references in the baseline; `UiBuilder::id` is not used today). `Ui::scope_id` is the stable rename of the old `Ui::id`; use `Ui::unique_id` only for transient widget/accessibility relations, never persisted state. Deprecations are fatal under `-D warnings`; `Response::id` is not affected | +| Screenshot | `ViewportCommand::Screenshot(UserData)` + `Event::Screenshot { user_data, image }` | `ViewportCommand::Screenshot(ScreenshotCallback)`; `Context::request_screenshot`; `UserData` and the event are gone | Replace the queue with one bounded completion slot per admitted request (§4.3); preserve token/path/reply/deadline semantics | +| Wheel events | `Event::MouseWheel { unit, delta, phase, modifiers }` | adds `source: MouseWheelSource` | Add `MouseWheelSource::Wheel` for wheel tests/synthetic input; `Unknown` where the source is genuinely unknown | +| Clipboard | `Paste(String)`; image workaround in `keyboard` | adds `PasteImage(Arc)` and `MiddleClickPaste { pos, text }`; PRIMARY support in egui-winit | Handle `PasteImage` directly; keep the image workaround only as a compatibility fallback. Candidate E4 still routes PRIMARY through arboard; the required shared W15P winit/SCTK path is a separate prerequisite (§4.8, §5.2) | +| IME | 0.36.2 **already has** `ImeEvent::DeleteSurrounding { before_chars, after_chars }` and the deprecated `Enabled` variant | stock 0.37 keeps the same incoming events; the suite fork E21 adds `DeleteSurroundingBytes`; **new E21b** adds outgoing surrounding text to `IMEOutput` | DeckCraft silently ignores `DeleteSurrounding` today (`_ => Ok(Value::Null)`), so this is a pre-existing gap, not a new 0.37 API. Handle the stock and fork variants, and supply the E21b model-aware producer from `canvas::ime_output`; do not claim E21/E21b support without them | +| kittest | unnamed inputs accepted; missing glyphs draw tofu | unnamed visible inputs panic by default; missing glyphs panic by default (`MissingGlyphPolicy::Panic`), `allow_missing_glyphs()` opts out | Audit and label text fields; keep panic policy for curated UI screens; opt out only for arbitrary-script soak tests | +| Accessibility | `WidgetType`; fewer names/roles | `Role`; `Response::accessible_name`, `labelled_by`, `Ui::name_inputs`, panel resize handles, invisible widgets hidden; `Response::widget_info` **does** register an AccessKit role/label/value | Add names only where missing; keep and repair existing `widget_info` where it already supplies role/label/value. Do not treat `widget_info` as output-only and do not replace a working role with a bare name | +| Panels/root UI | panels on a root `Ui`, state keyed by the supplied `Id` | same model, but `Panel` stores `IdSalt` and keys by `parent_ui.scope_id().with_salt(self.id_salt)`; panel/Scene clip respects parent (#8562) | Keep the current panel order and nesting; unchanged strings still produce different effective IDs, so keep parent-salted panel identities; assert app-owned UiState dimensions survive save/reopen; no persisted egui panel reset exists (§4.5). Never call `root_ui` inside `App::ui` | +| Default fonts | egui's default features included `default_fonts` | `default_fonts` is no longer an `egui` default; eframe still enables it by default; only `apps/deckcraft` and `apps/deckcraft-web` enable it explicitly | Add `egui/default_fonts` to `deckcraft-ui-egui` itself or a shared workspace feature, keep the explicit app features, and test the library standalone; `theme::font_definitions` calls `FontDefinitions::default()`, which is empty without the feature | +| `AsId` / deprecated APIs | used | removed | Remove any remaining uses; `-D warnings` makes deprecations failures | +| root UI API | `Context::run` | `Context::run_ui`, `Context::run_pass`, `Context::root_ui`; `root_ui` panics if the root `Ui` is already borrowed (#8535) | Tests/headless use `run_ui`; app code never calls `root_ui` from inside `App::ui`/`run_ui` | +| hidden windows | eframe has `update` (logic+ui) and `update_logic_only` (logic only) | same contract | Both native and web call `App::logic` and `App::ui` inside `Context::run_ui` per pass. The minimized/occluded root uses `run_logic` only when no paint is wanted; a pending screenshot, `RequestPaintWhileHidden`, or a visible descendant forces a full UI pass. Never use visibility as a logic-only test | +| multi-pass | `request_discard` may call the UI callback more than once | same; later passes receive empty event queues after `RawInput::take` | Guard only genuinely once-per-layout-frame work. `end_pass` takes the output, so `current_pass_index()` is zero again after a completed pass; it is not a hidden/logic-only signal. Prefer consumed queues and monotonic deadlines for background work | +| app trait | `logic` + `ui` already used | unchanged shape; `on_exit(&mut self)` still used with wgpu (no glow feature) | Keep `App` wrapper; no wholesale `update -> ui` rewrite | + +### 3.2 Source-verified facts that drive the lifecycle design + +- Both native and web call `App::logic` then `App::ui` inside the `Context::run_ui` closure + (`eframe/src/native/epi_integration.rs::update`, `eframe/src/web/app_runner.rs::logic`). They can + call the pair once per layout pass, including discarded passes. The app's own `logic` is not a + once-per-frame native timer. +- `Context::run_logic` runs no pass and replaces only `RawInput::viewports` and `RawInput::focused`; + events, input time and layout remain those of the last UI pass. Eframe uses it for a hidden root + only when no paint is wanted (`update_logic_only`). +- A hidden root still gets a full UI pass when a screenshot is pending + (`ActionRequested::wants_paint`), when the app sent `ViewportCommand::RequestPaintWhileHidden`, or + when the root is hidden but a descendant viewport is visible. That full pass calls `App::logic` + **and** `App::ui` with current input. `visible() == Some(false)` therefore does not prove a + logic-only callback, and `unwrap_or(true)` cannot turn stale `run_logic` input into fresh input. +- `RawInput::take()` moves `events` and `dropped_files` out of the first pass, so later passes of + the same frame have empty event queues. That is a useful consequence, not a substitute for + explicit phase routing. +- `Context::current_pass_index()` is `output.num_completed_passes`: zero on the first pass, + incremented per discard, and zero again after a normally completed pass because `end_pass` + takes the output with `mem::take`. It can guard genuinely once-per-layout-pass work under this + pinned integration, but it cannot distinguish a fresh UI pass from `run_logic`, and it does not + stay nonzero after completion. +- `ViewportInfo::visible()` reflects minimized/occluded state; `None` means unknown. Use it only for + minimized/occluded presentation policy, never as an input-freshness test. +- `ViewportCommand::Screenshot` now carries a one-shot `ScreenshotCallback`; hidden windows still + paint for a pending screenshot. The callback does not request a repaint, so the app must wake its + own logic/viewport to deliver the result. +- `Context::root_ui` panics when called while the root `Ui` is borrowed (#8535). + `show_viewport_immediate` callbacks receive their own root `Ui`; do not call `ctx.root_ui` there. + +--- + +## 4. DeckCraft lifecycle and root-Ui design + +### 4.1 Frame phases + +Keep `SlideApp` as the owner of all app state. Route work by what consumes it, not by visibility: + +1. **Background work (`SlideApp::logic`, every call)** — work that must continue when nothing is + painted and that is safe to call more than once per frame: + - `drain_control` (take each request once), `poll_opening`, `drain_inbox` once fonts are ready; + - screenshot result collection, cancellation and monotonic deadlines; + - AutoRecover deadline checks; + - media player clock, owner cleanup and `session.media_status`; + - slide-show time advancement (transitions, triggered animations, auto-advance, media cues), + presenter timer and editor animation-preview clock, driven by a monotonic app clock; + - frame/pass bookkeeping and repaint scheduling. + These are consumed queues or monotonic-deadline work, not interpretations of UI events. A + first-pass guard may protect a genuinely once-per-layout-pass advancement, but it must not gate + fresh async results, control commands, screenshot results or other queue drains. +2. **Current UI phase (`SlideApp::ui`, on every real pass)** — work that consumes this pass's + input and must also run on a forced hidden paint: + - keyboard, clipboard, IME and pointer actions; + - native dropped-file handling; + - UI text/font observation (`note_ui_text`) from this pass's input and visible document; + - drawing the current slide-show/editor-animation state; + - commands triggered by widgets, and `drain_requests` after them (a queue drain, safe on replay). +3. **Render-adjacent helpers** (`textures`, `media` texture sync, `screenshots`) — called from `ui` + or from the app's result-drain path and never own document state. + +The `App` wrapper in `apps/deckcraft/src/main.rs` and `WebShell` in `apps/deckcraft-web/src/web.rs` +remain thin: Apple/drop events, then `app.logic(ctx)`, then `app.ui(ui)`. No generic context +bridge is added. + +### 4.2 Exactly-once rules + +- **Background work is drain- or deadline-driven.** Control requests, inbox events, opening + results and screenshot results are taken once (`std::mem::take`, `try_recv`, `retain`), so + repeated `logic` calls cannot replay them. Timers compare a monotonic deadline, not stale + `ctx.input(|i| i.time)`. +- **UI input is handled only in the known UI phase.** Keyboard, clipboard, IME and native drops + read `ui.input`/`ctx.input` in `ui`, where a forced hidden paint also arrives. Do not infer a + logic-only callback from `visible()` and do not suppress input because a pass index is nonzero. +- **Autosave, media and show advancement are not visibility-gated.** They run from `logic` on every + call (subject to a monotonic deadline or consumed queue) so a minimized window still writes + AutoRecover, ticks audio/media and advances a show. +- **A first-pass guard is only a per-layout-pass deduplication.** Under this pinned integration it + may protect work that would otherwise run twice in one discarded multi-pass frame. It is not a + hidden-window test and it must not skip fresh input or background results; each such guard gets + a regression proving a normal hidden call still performs the work. +- **Queues are drains:** a control request whose sender gave up (`ControlRequest::take`) is never + re-run after its reply timeout. +- **Undo:** only commands create undo entries; `logic` must not create empty transactions. + `Session`'s existing composition/undo rules stay; `text.compose` + `text.commit` remain one + composition undo step, and E21b's outgoing state does not edit the document. +- **No layout side effects in logic:** do not call `root_ui`, do not create panels, and do not + initialize fonts only to measure a widget from logic. Font installation may be scheduled from + logic, but observation and layout happen in `ui`. + +Concrete placement: + +- `crates/ui-egui/src/lib.rs:1540` `SlideApp::logic`: run `drain_control`, `poll_opening`, + `drain_inbox`, screenshot result/deadline work, the monotonic autosave check, media clock, + show/animation advancement and repaint scheduling. Do **not** guard these with `visible`. +- `crates/ui-egui/src/lib.rs:1607` `note_ui_text` and the native `dropped_files` loop + (`:1595-1604`) move into `ui` (or a method `ui` calls) and read the current pass only. +- `crates/ui-egui/src/lib.rs:1652` `raw_input_hook`: synthetic events are injected once per frame + by eframe before the pass. `logic` does not consume those events, but it must detect an admitted + batch in the live `InputState` and defer target-changing control/inbox/opening drains one pass: + after `begin_pass` egui has already derived `PointerState`/`keys_down`, so retracting only the + raw events cannot keep a click or key out of a document that same pass's logic opens. +- `crates/ui-egui/src/lib.rs:1668` `keyboard`: keep event-driven; called only from `ui`. +- `crates/ui-egui/src/lib.rs:1798` `ui`: keep panel order; run input handling once; call + `drain_requests` after commands. +- Tests that manually drive the app must mirror the real host: call `logic` then `ui` inside + `Context::run_ui` for real passes, and use `Context::run_logic` for hidden logic-only iterations. + A `#[cfg(test)]` `run_frame` helper is justified because dozens of tests need identical + semantics, but it must not become a production abstraction and must not pretend native calls + logic once per frame outside `run_ui`. + +### 4.3 Screenshots and control-channel replies + +This is the largest concrete refactor because `UserData`/`Event::Screenshot` is gone. Follow the +shared completion-slot contract in `craft-libs/docs/egui-037-suite-rollout.md` rather than inventing +a second global token map. + +**Target design in `crates/ui-egui/src/lib.rs`, `control.rs` and `media.rs`:** + +- Admit each `ui.screenshot` request into a `PendingShot` record: numeric token, optional path, + `Reply` sender, target `ViewportId`, issuance geometry/DPI plus geometry epoch, monotonic deadline, and one + completion slot. The slot holds `Option>` and a live wakeup handle (a cloned + `Context` is acceptable *in the slot*, which the app owns); the callback holds only a + `Weak` reference to the slot. +- The callback captures no strong `Context` and no owning app state. On completion it stores the + image at most once under the slot lock, drops the lock, and wakes the viewport. If the request + was cancelled, timed out or the window closed, the image is released and no file or reply is + produced. +- Admit at most 4 screenshot requests, 128 MiB image bytes per request and 256 MiB + aggregate reserved in-flight/completed bytes, independently of the control server's + 64-request bound. Checked width × height × 4 arithmetic must reject overflow and + oversized/over-budget admission with a JSON error; never evict an earlier request. + Reserve before issue; release on drain/cancel/timeout/close. Test equality and one-over + boundaries, concurrent requests and eventual zero retained bytes. Transient GPU/readback + and encoder RSS is a separate measured bound, not hidden inside the slot budget. +- Record logical content/screen rect from the target viewport's known UI phase, not + `ViewportInfo::inner_rect` (unavailable on Wayland). The callback has pixels only. + Track a geometry epoch across resize/DPI/zoom/surface changes; revalidate before issue + and save, rejecting/retrying any epoch change, including a change back. Derive x/y + image scale from returned pixel dimensions and the unchanged recorded logical bounds; + check aspect/rounding and drop stale/oversized images. Never label old pixels with + current geometry merely because completion happens in that frame. + +- `issue_screenshots(ctx)` uses the app's monotonic clock (not `now_ms`) for the settle delay + (for example 120 ms and at least 2 settled frames when there is a real pass) and calls + `ctx.request_screenshot(...)`/`ViewportCommand::Screenshot(...)` once per admitted request. +- A screenshot itself requests hidden paint, so a hidden/minimized window runs `App::ui` with + current input. Test screenshot-only hidden capture separately: the command itself forces + paint. For a synthetic-input-only gesture, remove RequestPaintWhileHidden and assert the + gesture stalls; restore it per step and assert one result. Geometry uses the epoch check above. +- `drain_screenshot_results` runs from logic, matches the token plus viewport/request identity, + calls `control::save_screenshot` on the app thread and sends exactly one reply. A late callback + after timeout/close does nothing. +- Keep the existing JSON shape (`path`, `width`, `height`, base64 when no path) and the 8 s + deadline error (`no frame was presented ... use ui.render`), measured on the monotonic clock. +- Remove `Event::Screenshot` scanning, `UserData` and `collect_screenshots`. +- Kittest 0.37 fulfills screenshot requests from viewport output but renders one image once and + completes every callback with that same image, in collection order + (`crates/egui_kittest/src/lib.rs:913-946`). It therefore cannot prove token/path correlation or + reverse-order completion. Correlation tests must capture the callbacks from viewport output and + complete them manually with distinct images in a small test harness. + +### 4.4 Commands, undo, save/reopen + +No command or persistence contract changes: + +- `SlideApp::run` remains the single app/UI command entry point; engine commands still go through + `Session::execute`; UI commands remain in `UI_COMMANDS`/`run_ui`. +- `control::handle` continues to call `app.run`, `app.save_all`, `app.open_path`, etc. +- `Session` history remains snapshot-based; do not add undo entries for `logic`, screenshots, + autosave, focus changes, copy/cut, or theme/font installation. +- `.deckcraft`, `.pptx`, recovery and settings formats are unchanged. `UiState`/`Prefs` serde + compatibility tests continue. +- A frame replay (Grid sizing pass) must produce the same document and one undo entry. Add a + mutation-check test that forces `request_discard` while clicking a mutating control and asserts + one undo entry, one dirty revision change and one status. + +### 4.5 Root `Ui`, panels, IDs, clipping + +Current order is intentional and should be preserved: + +- Root `Ui` in `SlideApp::ui`: `ribbon::title_bar` (top), `ribbon::show` (top), `status::status_bar` + (bottom), then a `CentralPanel` that contains the start screen/backstage or the editor. +- Inside the editor central panel: `panes::show` (right), `thumbs::show` (left) or + `sorter::outline_pane` (left), a nested `CentralPanel` for notes/canvas; `canvas::show`. +- Backstage: `Panel::left("backstage_nav")` and a `CentralPanel` inside the root central panel. +- Document window: `show_viewport_immediate`, `CentralPanel`, `Panel::top("doc_window_bar")`, + `CentralPanel`. +- Presenter: `show_viewport_immediate`, `CentralPanel`; no root panel additions. + +Actions: + +- Keep the panel salts (`title_bar`, `ribbon`, `status`, `task_pane`, `thumbs`, + `outline`, `backstage_nav`, `doc_window_bar`, `notes`). 0.37 changes effective panel + IDs to parent-salted values. DeckCraft has no eframe persistence feature, so there is + no saved egui PanelState blob to migrate or reset. App-owned `UiState` dimensions + (`pane_width`, `thumbs_width`, `notes_height`) remain the durable source of truth. + Outline width is session-local at baseline; preserve resizing within the session, + without inventing a new durable outline preference in this port. Test actual settings + save/reopen and 1×/2× geometry. `Ui::scope_id` is the old stable id; `unique_id` is + not a persisted identity. No optional remap/reset decision remains. + +- Do not move panels into `Context::root_ui`; the app already has the root `Ui`. The only + legitimate `root_ui` calls are in tests/headless code that only owns a `Context`. +- 0.37's parent-clip fix (#8562) can change nested panel/Scene clipping. Assert `canvasRect`, + `slideRect`, pane widths, and that no content draws over the title bar/status bar at 900×560 + and 1440×900, at 1× and 2× DPI. +- Never overwrite a saved preferred panel size with a clamped displayed size (craft-ui guidance); + keep `pane_width`/`thumbs_width`/`notes_height` semantics unchanged. + +### 4.6 Viewports and docking + +There is no docking library. The app's viewports are: + +- `view.newWindow`: immediate viewport `Id::from_hash_of("deckcraft_doc_window")`, title `name:2`, + 960×640, min 480×360; shows the live document texture, next/previous buttons, close request. +- Presenter view: immediate viewport `Id::from_hash_of("deckcraft_presenter")`, 1100×700, notes, + clock, controls. +- Full-screen show: root viewport enters/exits fullscreen; no child viewport. + +Actions: + +- Keep `show_viewport_immediate`; do not replace with `root_ui`/`run_pass`. +- In 0.37 the immediate callback already receives a `&mut Ui`; keep `CentralPanel` last within + each callback. Do not add panels after `CentralPanel`. +- Test: open document window, move its slide, confirm the editor selection is unchanged; close + the window, confirm the document stays open; start/stop show and presenter; verify fullscreen + command and no stuck fullscreen after exit/close. +- Test hidden parent: minimize/occlude the root (or run logic-only) and verify a visible immediate + child still receives passes when eframe's descendant-visible rule applies; record any failure + against upstream #8748 (closed and **not merged**; not in 0.37.0). A forced hidden paint is the + second case: `App::ui` must run and current input must be handled. Do not silently claim docking + behavior that the app does not have. + +### 4.7 Fonts, glyph coverage and accessibility + +**Font policy decision:** keep DeckCraft's custom `FontDefinitions` (`theme::font_definitions`), +keep the explicit `default_fonts` feature on **both the app and the UI library**, and keep +`UiFonts`' fallback selection. Do **not** enable eframe's `system_fonts` feature in this migration. +Reasons: + +- `UiFonts` already chooses installed faces for missing UI glyphs, with a per-face size limit and a + color-face rejection policy; enabling `egui_system_font_provider` would add a second, less + controlled fallback path and the upstream-reported system-font startup cost (#8512 comments). +- Screenshot/layout determinism across machines is easier with the explicit face order. +- Color emoji rendering is not a DeckCraft requirement for this migration; slide rendering uses + its own text pipeline. If color emoji is later required, make it a separate product decision + with startup, memory and golden-image evidence. + +Actions: + +- Add `features = ["default_fonts"]` to `deckcraft-ui-egui`'s `egui` dependency (or a shared + workspace feature consumed by both the library and apps). Today only `apps/deckcraft` and + `apps/deckcraft-web` enable it; `crates/ui-egui/Cargo.toml:21` does not, and in 0.37 + `FontDefinitions::default()` is empty without the feature. `theme::font_definitions` builds the + UI families from it, and `examples/icon_gallery.rs` is affected too. Test + `cargo test -p deckcraft-ui-egui` standalone with and without `CRAFT_FONTS_DIR`; do not rely on + app feature unification. +- Update `crates/fonts/Cargo.toml` `epaint_default_fonts` to `0.37.0` (or the same reviewed + storytold source). Its constants (`HACK_REGULAR`, `UBUNTU_LIGHT`) remain the last-resort face. +- Keep `FontData::from_static`/`from_owned`, `FontDefinitions`, `FontFamily::Name("bold")`, + `data.tweak.hinting` usage; verify against 0.37 source. `ctx.set_fonts` applies immediately + when no text is laid out; `UiFonts::apply` must still request a repaint and must not install + fonts on every frame. +- **Bound `UiFonts` aggregate growth.** `MAX_FACE_BYTES` is 128 MiB *per face*; `faces` is an + unbounded vector and every newly found face is appended, so "bounded" must not be claimed + without an aggregate byte/family budget or a deliberate reword. Add an aggregate budget and test + adversarial multi-script input, duplicate faces and replacement behaviour. +- Production missing-glyph policy: egui's default `Tofu` is acceptable (never panic in + production); do not set `Panic` outside tests. In tests, keep kittest's default `Panic` for + curated DeckCraft screens so a literal UI glyph that no interface face draws is caught. Use + `HarnessBuilder::allow_missing_glyphs()` only in the soak test that deliberately types + arbitrary scripts. +- Inventory literal UI glyphs (`→`, `↘`, `↓`, `↙`, `←`, `↖`, `↑`, `↗`, `•`, `○`, `■`, `□`, `◆`, + `➢`, `✓`, `★`, `◀`, `▶`, `◉`, `⟳`, `–`, etc.) on the ribbon, symbol dialog, animation pane and + show controls. Replace a glyph with a code-drawn icon where it is decorative and not covered; + do not add a new font file without an `ATTRIBUTION.md` row and licence review. If a glyph is + intentionally unsupported in a test, isolate that test and document why. +- Add an accessibility name to every visible input. Use visible labels with `Response::labelled_by` + where a label exists; use `Response::accessible_name` for unlabelled compact fields + (`widgets::spinner`, the color hex field, the scheme name, search fields whose hint is not a + real name, etc.). `hint_text` counts as a kittest fallback name, but a visible label is better. +- **Keep and repair `widget_info`.** In 0.37 `Response::widget_info` fills/registers the AccessKit + node role, label, value and selected/toggled state; the premise that `WidgetInfo` events are not + an accessibility tree label is false. Existing custom controls that already have usable + role/label/value through `widget_info` should stay and be fixed, not be replaced wholesale with + `accessible_name` (which may lose role/value). Use `labelled_by`/`accessible_name` only for + genuinely unnamed inputs, and verify both through kittest. +- Role mapping: + - `dialogs.rs:553,601,712` `WidgetType::TextEdit` -> `Role::TextInput`; + - `panes.rs:1077,1079` `WidgetType::Other` -> `Role::Unknown` for compile parity, then decide + whether the animation timing body/edge should become a focusable `Role::Slider` with + `WidgetInfo::slider` and Left/Right keyboard adjustment. Do not present a slider role without + keyboard operation; either implement that interaction or keep it as an explicitly named + custom control. + - `selpane.rs:160` `SelectableLabel` -> `Role::Button` (upstream's table); keep selected state. + - `widgets.rs:162` `Checkbox` -> `Role::CheckBox`. + - `selpane.rs`/`sorter.rs` imports: `WidgetType` -> `Role`; all `WidgetType::Button`, + `ComboBox` etc. become `Role::Button`, `Role::ComboBox`. +- Use `Ui::label_inputs_by`/`name_inputs` for grouped editors, `Area::role` for the toast/alert if + it is announced, and leave `AccessKit` enabled on native/web as today. +- Add tests that query roles and names: + - every visible text input has a label, including the library's own example/test harness; + - `Role::CheckBox` toggled state and a custom `widget_info` control's role/label/value; + - row selection uses `Role::Button` with selected state (or the chosen list role); + - dialog OK/Cancel/Close names; File/Back names; panel/area names; + - invisible widgets are not in the tree (0.37 #8621); + - a deliberate missing-name mutation makes the accessibility test fail. + +### 4.8 Input, IME, RTL and clipboard + +- **Paste image:** handle `egui::Event::PasteImage(Arc)` in `keyboard`: convert to RGBA + bytes and call `insert.picture` with a PNG name, as the existing clipboard-image fallback does. + Keep the existing `Event::Paste` empty-text + `services.clipboard_image` fallback for + integrations that do not emit `PasteImage`; ensure the same paste does not insert twice when + both arrive. +- **PRIMARY:** `Event::MiddleClickPaste { pos, text }` is handled by egui's focused `TextEdit` + (notes, dialogs). DeckCraft's custom canvas does not consume it. Test middle-click on notes and + on the canvas; the canvas must not insert text implicitly. Keep regular clipboard copy/cut + behavior (`ctx.copy_text`, HTML/image services) unchanged. +- **PRIMARY backend is not solved by E4.** Candidate E4 routes PRIMARY through arboard + (`egui-winit/src/clipboard.rs`, `arboard::LinuxClipboardKind::Primary`) even though its ledger + says "through arboard meanwhile". The shared contract requires a winit-owned W15P path on the + existing Wayland connection/event queue using SCTK 0.19.2's per-seat PRIMARY objects, landed and + reviewed with a published storytold winit revision. Treat arboard PRIMARY as an unresolved fork + gap, not a solved dependency feature. Test keyboard-only focus, serial freshness, owner/seat + changes, empty/absent offers, self-paste, password exclusion, no unchanged-selection reclaim and + canceled/slow transfers on wlroots/KWin/Mutter; ordinary paste is not PRIMARY acceptance. +- **IME composition (incoming):** `ImeEvent::DeleteSurrounding` is **not** new 0.37 API; 0.36.2 + already has it and DeckCraft ignores it in `ime_event` (`_ => Ok(Value::Null)`). This is a + pre-existing gap. Keep `Preedit`/`Commit` mapping to `Session::compose_text`/`commit_text`, + handle `DeleteSurrounding { before_chars, after_chars }` (stock) and the fork's + `DeleteSurroundingBytes` when the fork is pinned, and add one narrow engine range-delete API + that deletes a character/byte range around the composition caret as part of the same composition + undo transaction (validate the range before mutating; never panic on hostile indices). + When the application settles a composition itself (click/key/command), reject the pre-edits and + commits already queued for it in that input batch, then retire the tombstone in the pass whose + `should_interrupt_composition` is actually published: that publication is the backend's + disable/re-enable reset boundary. Do not keep an owner-keyed tombstone past it, or legitimate + later commit-only input (punctuation/full-width characters, direct IME commits) is lost forever. + Gate that retirement on the actual output descriptor being absent: a focused stock `TextEdit` + can publish its own `o.ime` while the canvas has no pending rect, and that pass is not a reset. + `egui::ImeEvent` carries no generation, so an OS-queued event crossing the reset cannot be + distinguished from a legitimate new composition; only same-batch events are deterministically + stale. A discarded pass publishes nothing and must neither consume the interrupt nor end the + tombstone. +- **E21b outgoing surrounding text is a new prerequisite, not existing capability.** The candidate + egui-winit never calls `WindowExtWayland::set_ime_surrounding_text`, and `IMEOutput` has no + logical text. Extend `IMEOutput` with an optional bounded payload: logical committed text, + UTF-8 byte cursor/anchor and owning widget identity. The constructor must extract at most 4,000 + bytes at valid UTF-8 boundaries without cloning a large document; if a selection cannot fit, + emit explicit `Unavailable` rather than misleading truncation; an available empty document + is distinct and owner/context generation exists in both states. Exclude preedit, + remap indices after delete/commit/cursor changes, and never supply text for password input. + `PlatformOutput::append` must use the latest completed pass's IME value including None, + replacing earlier descriptor/payload. W27 clears cached/protocol state through a committed + disable/enable boundary on owner/unavailable transitions and clears old pending batches. + Current-epoch done serial mismatch applies incoming edits once but defers outgoing state + until a match; stale focus epochs cannot edit a new owner. Test text-to-non-text/password + multipass clearing and ordinary updates preserving composition. DeckCraft's custom canvas producer (`crates/ui-egui/src/canvas.rs:321-333`) must supply + logical model text and convert the slide-text selection/caret to byte indices; setting the new + field to `None` is not acceptance. Test outgoing text immediately after delete+commit and + deletion-only batches, preedit exclusion, reversed selection, supplementary/combining/RTL text, + cursor movement, long lines/over-limit selections, field/window switching, password/focus/reset + clears and multi-pass final output. Measure bytes allocated on large documents. E21b is not + claimed as working E21/W27 behavior. +- **RTL/bidi:** UI labels, titles, file names and notes can be Arabic/Hebrew/CJK even though UI + strings are English. The fork's E5/E12/E13/E14/E15/E16/E20 and winit W22/W27 provide bidi + shaping, visual rows, grapheme caret/delete, line breaking and fallback baselines. Test: + Arabic/Hebrew title in the title bar and slide thumbnail; caret/selection in notes; Delete/ + Backspace at cluster boundaries; mixed-direction labels; no glyph panic; screenshots reviewed. +- **Non-Latin shortcuts:** E7/E19 and winit W23/W25 map Latin-layout letters on non-Latin + layouts. Test Ctrl+S/Ctrl+Z/Ctrl+C with a Russian/Arabic/Hebrew layout event sequence (where + the integration can produce it), and a letter that has no Latin mapping must not fire a + shortcut. +- **UI language:** DeckCraft is English-only (`docs/localization-parity.md`). Do not set a global + `Context::set_language` or `Frame::set_ui_language` in this migration unless the UI actually + localizes; an English root tag on Arabic document titles would be wrong. The fork's E17/E18 + hooks remain available for a later localization task. Per-widget language tagging is an open + product question, not an API to invent here. + +### 4.9 GPU, textures and rendering + +- DeckCraft's lock holds wgpu `30.0.1`; upstream egui 0.37.0's lock holds `30.0.0` (held back for + `wasm-bindgen-futures`). They are semver-compatible, but do not claim they are the same version: + verify `egui::wgpu` re-exports, `eframe::wgpu`/`egui_wgpu::WgpuSetup` paths and the wasm + dependency set after the bump. The Windows `Dx12Compiler::Fxc` setup remains valid in shape. +- Keep `textures::max_side`/`fit_texture`/`shrink_to` and `TextureOptions` (nearest magnification, + linear minification, clamp) because the slide canvas is device-pixel aligned (#184). Do not let + the 0.37 texture/`ColorImage` or predictable-filtering changes silently switch filtering. +- E2+E3 (`egui-wgpu` surface fits `max_texture_dimension_2d` and setup errors are returned) are + relevant: DeckCraft already bounds its own textures but relies on eframe/egui-wgpu for the + surface. Test a window larger than the adapter limit, a zero-sized/minimized surface, and a + bad adapter/device creation path; assert no panic and a logged/returned error. +- Windows DX12 FXC: 0.37's `wrap_texel_coord` makes `Renderer::new` slower on FXC (upstream #8736). + Measure startup on the Windows clone with 20+ fresh processes. If the regression exceeds the + threshold, profile and either take an upstream fix or add a fork patch that preserves all wrap + modes (not a clamp-only shortcut). Keep color-glyph/texture-wrap semantics. +- Web: 0.37's `glyph_rasterizer`/canvas path and WebGL2 fallback are new behavior; test WebGPU + and `?webgl` in a real browser, including CJK/emoji glyphs, canvas resize, screenshot/download + and hidden-tab logic. A `cargo check --target wasm32` is compile-only, not browser acceptance. + +### 4.10 Media, autosave and timers + +- Add one app-owned monotonic clock: `std::time::Instant` on native and `web_time::Instant` + (`performance.now()`) on wasm. Use it for screenshot settle/deadline, AutoRecover, media, + animation preview and show timing. Keep `now_ms()` only for display/formatted timestamps; + `SystemTime`/`js_sys::Date::now()` are wall clocks and can move backwards. +- `MediaHost::tick` currently reads `ctx.input(|i| i.time)` and is called only from `SlideApp::ui`. + Split it: + - `MediaHost::tick_clock(now)` from logic on every call: player tick, end/rewind, ownership + cleanup, `session.media_status`, request repaint while playing. `now` is the monotonic app + clock, so hidden logic advances it. + - `MediaHost::sync_video_textures(ctx)` from `ui`: upload/replace frames for visible video. + A hidden window accumulates no texture uploads; the next visible frame catches up from the + player clock. + - Do not tick the audio player twice in a multi-pass frame; deduplicate with the per-layout-pass + guard only where genuinely needed. +- `SlideApp::tick_media` (owner/slide cleanup) moves to logic; it only reads document state and + stops editor-owned media when the slide is left. Keep it idempotent. +- AutoRecover: replace `ctx.input(|i| i.time)`/`last_recovery` with the monotonic clock. Run the + deadline check from logic on every call (visible or hidden). `file.recovery.save` stays a + command; errors go to status. Schedule `request_repaint_after` for the next monotonic + recovery deadline so an otherwise idle hidden app wakes; reschedule/cancel when dirty state + changes. Test with all other timers disabled. Do not save on every pass. +- Slide-show time advancement moves out of `show::ui` into a `show::advance(app, show, doc, now)` + helper called from `SlideApp::logic`. It advances the presenter timer, triggered animations, + transitions, auto-advance and media cues even when no frame is painted, and carries the existing + `request_repaint_after` scheduling so a hidden show wakes to advance. `show::ui` only applies + pointer/keyboard actions and draws the resulting state. Audio owned by `media` continues through + `tick_clock`. A first-pass guard may be kept for one advancement per layout pass, but it must not + be visibility-based; test hidden auto-advance and one-step-per-time behaviour. If the product + decision is that a minimized show must pause, make that an explicit app state with a test, not + an accidental consequence of paint-driven code. +- `squiggles` already runs a worker and requests repaint; keep it. `opening` polls a channel and + requests repaint; keep it in logic. `toast`/status timers use `now_ms` today; move them to the + monotonic clock and keep them idempotent. +- `perf.fps`/`frame_ms`: compute on the first pass only so a Grid replay does not distort frame + statistics. Add raw counters (`frames`, `passes`, `screenshots`) behind `ui.inspect` if needed + for performance tests; do not add production churn. + +--- + +## 5. Dependency, fork and publication plan + +### 5.1 Dependency migration: stock experiment, then reviewed production pins + +**Stage A — stock egui 0.37.0 experiment (not a staged release).** Change workspace versions to +`0.37`, update `crates/fonts` to `epaint_default_fonts = "0.37"`, and produce a lock that resolves +one registry egui family. This stage exists to isolate the API/behavior diff against public 0.37 +and to run the stock-only comparison benchmarks. No fork patches. It is a local experiment and +must not be published as an intermediate consumer release; the production graph is the fork below. + +**Stage B — reviewed storytold production pins.** Add a root `[patch.crates-io]` block pinning +every used egui-family crate and winit to reviewed, published, immutable **storytold** full SHAs, +landed as one coherent set. Cargo does not propagate the fork workspace's winit patch into +DeckCraft. The shape is: + +```toml +[patch.crates-io] +winit = { git = "https://github.com/storytold/winit", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } +``` + +The `aiongg/egui` `a4f9a17f` and `aiongg/winit` `8b35388b` revisions are source-review inputs, not +publishable pins. Production waits until the reviewed series (including W15P PRIMARY and E21b) is +published under storytold with immutable full SHAs. `egui_extras` is unused today; do not add it +to the patch list unless a real consumer appears. Do not patch `egui_system_font_provider` because +`system_fonts` stays off; if enabled later, patch it to the same revision. Do not patch +`egui_plot`, `egui_dock` or `egui_glow`; they are not in DeckCraft's graph. Keep the `krilla` +patch and its `cargo xtask pdf-deps` check. + +**Publication rules.** No machine-local path overrides may be committed. Only public git URLs and +immutable full SHAs are publishable. A branch name is not acceptable, and an `aiongg` personal-fork +SHAs must not appear in a production manifest or release. The `deckcraft` app is `publish = false`, +so its root patch and lock are not crates.io publication artifacts, but releases and CI must +still resolve the public storytold revisions. No stage is pushed or published without fresh human +approval of the exact contents and validation. + +**Font pin gate (before publication, not local work; stale pending a combined pin).** The three +workflow pins currently name `b076417b5880bd4154bd22f00695b1d28c6bea83`, whose tree carries the +reviewed Chinese manifest but has no `fonts/latin-manifest.txt` or Inter. The earlier +`43913056ce82a3fdad0ef69f6b88d1dc922a8104` tree carried the Latin manifest/Inter but not the +Chinese input. The parent is restoring the licensed Inter faces in the central +`craft-fonts-implementation` checkout; that work is unpublished and must not be committed from the +DeckCraft checkout. Therefore this approval packet is **stale**: do not approve or publish it until +the reviewed Latin manifest/faces and Chinese manifest are committed together in `craft-fonts`, an +immutable full SHA is obtained, and **all three** pins move together: +`.github/workflows/ci.yml` (`CRAFT_FONTS_REF`), `.github/workflows/freebsd.yml` and +`.github/workflows/release.yml` (`craft-fonts-ref`). Do not invent or pre-commit a future SHA, and do +not advance only one workflow. `crates/fonts/build.rs` now validates required builds after exclusion +and, for wasm, the web filter: Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic +Regular must actually be embedded, plus BIZ UDPGothic Bold natively for the weight pair; wasm keeps +Regular only for its size budget. A partial pin fails instead of silently losing the default +document/UI face. + +The required-input gate is explicit, but CI and FreeBSD qualification remain partial: + +- `ci.yml` runs the `Font pin gate` as a local preflight job before the shared gate. It checks the + Chinese manifest but not the Latin manifest, so the build script's required-mode validation is + the guard for Inter. The shared `gate.yml` job does not accept a `CRAFT_FONTS_REQUIRED` input or + another workflow's exported environment, so the CI job verifies the pin but cannot yet force + the required-font environment into `cargo xtask ci`; release and FreeBSD notes below apply. +- The shared release workflow already sets `CRAFT_FONTS_REQUIRED=1` in every packaging step, so a + release on a pin without the Chinese manifest or the required Inter/BIZ faces fails instead of + shipping without them. +- The shared FreeBSD workflow passes `CRAFT_FONTS_DIR` to `cargo test` but currently exposes no + `CRAFT_FONTS_REQUIRED` input. Its required-font gate therefore remains **pending** until + `craft-common-actions` adds one (or the caller can otherwise set that environment); do not cite + FreeBSD as required-font evidence before then. + +The font assets stay in the central `storytold/craft-fonts` repository; no font file is added to +this checkout. + +### 5.2 Fork patch ledger and DeckCraft decisions + +The candidate fork's `PATCHES.md` (base upstream 0.37.0) lists E1–E21; E21b is the new, separately +reviewed addition. DeckCraft decisions: + +| Patch | Provenance / behavior | DeckCraft decision | Test / removal | +|---|---|---|---| +| E1 `8f3abf06` eframe `viewport_hooks` | per-viewport native windows and raw input | Not used by DeckCraft; harmless fork API. Do not build craft-tablet behavior on it here. | Remove only with the fork; no app code depends on it | +| E2+E3 `9dd799ab` egui-wgpu | fit surface to texture limit; surface/renderer errors | Keep. Relevant on Windows/Linux/WebGPU and to DeckCraft's large-window texture policy. | Test oversized/minimized surface, error path; remove when upstream release contains and stock passes | +| E4 `66bacf23` egui-winit | Wayland regular clipboard via winit data device; **candidate PRIMARY still goes through arboard** | Keep the regular clipboard path. PRIMARY is an **unresolved fork gap**: require the shared W15P winit/SCTK per-seat path on the existing connection/event queue before production. | Regular copy/cut/paste, own copy, file-drop-then-paste; PRIMARY lifecycle tests on wlroots/KWin/Mutter. Do not claim #8657/PRIMARY from arboard alone | +| E5/E12/E13/E14/E15/E16/E20 | bidi shaping/rows, direction, grapheme caret/delete, fallback baseline, line breaker, visual cursor, UI text language | Keep. Relevant to titles, notes, dialogs, RTL/CJK UI text. DeckCraft's slide renderer is separate and unchanged. | Arabic/Hebrew/CJK/Thai/Vietnamese tests; grapheme Delete; screenshots; remove when upstream equivalent lands | +| E6 `0abfabcf` egui focus/IME | `request_focus` on focused widget does not interrupt IME | Keep. Relevant to notes/dialog TextEdits. | IME composition focus test; remove on upstream fix | +| E7 `0670c58a` egui-winit | Windows letter keys from virtual-key code on non-A–Z layouts | Keep. Relevant to shortcuts. | Russian/Arabic/Hebrew layout shortcut test; remove on upstream | +| E8 `1b03c10f` egui TextEdit | no clipping of glyphs above/below row | Keep. Relevant to notes/dialog text. | Thai/Vietnamese/Arabic marks; remove on upstream | +| E9 `b2f5cafc...` egui RTL style | mirrors menus, scroll bars, headers, title bars, combo boxes, popups, resize grips | Keep. No app code sets `right_to_left`, so LTR must not change. | LTR geometry golden and RTL review; remove on upstream | +| E10 `dc90c13e...` eframe web | named keys not text; `code`-based letters; `lang` on hidden input/speech | Keep. Relevant to `deckcraft-web`. | Browser keyboard/IME test; wasm compile; remove on upstream | +| E11 `92e61afd` epaint | glyphs rasterized on single-threaded vello_cpu; no multithreading panic | Keep for egui text. DeckCraft's `deckcraft-render` uses its own vello_cpu and already has a filters/threads regression test. | UI text with filters/emoji; slide renderer tests; remove on upstream | +| E17 `520435df` egui language | `Context::set_language`, `UiBuilder::language`, AccessKit language | Keep but do not enable an app locale yet. | AccessKit language test only if the app sets it; otherwise compile-only | +| E18 `89de8f3c` eframe | `Frame::set_ui_language`; web fallback | Keep available; DeckCraft has no localized UI. | No app call in this migration; revisit with localization | +| E19 `d64047bf...` egui/winit | one Latin letter-key rule; `keyboard_layout_is_latin`, `latin_layout_chars` | Keep. Relevant to shortcuts. | Shortcut tests; remove with winit API | +| E21 `a70470b0` egui/winit | `ImeEvent::DeleteSurroundingBytes` | Keep; handle it in the custom slide-text path. `DeleteSurrounding` itself already exists in 0.36.2 and is currently ignored, so this is a pre-existing gap plus the byte-range variant, not a new 0.37 API. | macOS press-and-hold/dictation, Wayland text-input; remove with upstream equivalent | +| **E21b** (new) | `IMEOutput` bounded logical surrounding-text payload, TextEdit producer and Wayland delivery through W27 | **Required new prerequisite**, not existing E21 capability. DeckCraft must implement the model-aware producer for `canvas::ime_output`, with UTF-8 byte cursor/anchor, preedit exclusion, password exclusion and a 4,000-byte bounded extraction. | Outgoing text after delete+commit/deletion-only, preedit/selection/focus/password/long-selection/two-window/multi-pass tests; native Wayland IME trace; measure allocation/time | + +Winit W1–W27 are inherited through the reviewed winit series, plus the new **W15P** PRIMARY support. +DeckCraft-relevant ones are W1/W11 (DPI/X11 scaling), W2/W3/W5–W10/W12/W13/W18–W27 (IME, clipboard, +drag, shortcuts, hidden windows), W15 (regular Wayland clipboard, needed by E4) and W15P (PRIMARY). +Do not remove any of them independently; removal is tied to an upstream release and the tests +named in the craft-libs ledger. W11 can change `pixels_per_point`, so every geometry/perf run +records the exact winit SHA. + +### 5.3 craft-libs PR 199 and language PR 198 + +- PR 199 updates `craft-ui` (and its dev dependencies) to 0.37 and applies the same `WidgetType` + -> `Role`, `UiBuilder::id` -> `scope_id`, `Id::new` -> `Id::unique` mapping. It is useful + precedent and should remain the shared UI library's migration, but DeckCraft does not consume + `craft-ui`; do not add it just to align versions. +- **Use the reconciled shared-library base.** The implementation builds on merged PR 199 at + `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; the reviewed 0.37 API changes are already part of + that merge. Never merge or cherry-pick PR head `b68de8c`/audit merge `ec8840a` as trees. The + older `8614e4a`/`1764bca`/`4919088` references were plan-only and are superseded. Preserve + unrelated vendors and current-main fixes; regenerate the lock with the final reviewed + storytold set. +- PR 199 does not add a fork pin or winit pin and does not test hidden `App::logic`. DeckCraft's + root patch and lifecycle tests must stand on their own. +- Language work (#198) and the fork's E5/E12/E13/E17/E18/E19/E20 are the relevant source for + RTL/IME/grapheme behavior. DeckCraft's slide text renderer already has its own bidi/line + breaking; this migration only changes the egui UI/text-edit layer. Do not move slide layout + policy into `craft-ui` or an egui compatibility layer. +- `craft-pdf`/`krilla` stay at their existing revision. They do not depend on egui; do not update + them in this migration. + +### 5.4 Lockfile, compatibility and rollback checks + +Before merging any dependency stage: + +- `cargo metadata --locked` and `cargo check --locked` must resolve without adding a second + `egui`, `egui-winit`, `egui-wgpu`, `epaint`, `ecolor`, `emath`, `epaint_default_fonts` or + `winit` source. Use `cargo tree -d` and `cargo tree -i egui@0.37.0`, + `cargo tree -i winit@0.30.13`, `cargo tree -e features -i eframe`. +- Inspect `Cargo.lock` git entries: every egui-family git entry must have the exact full **storytold** + rev and no `branch =` or path; there must be no `aiongg` source. The `krilla` entry and + `craft-pdf` source stay unchanged. +- Check the feature graph: native `default_fonts`, `clipboard`, `wgpu`, `accesskit`, `links`, `wayland`, + `x11`; web `default_fonts`, `wgpu`, `accesskit`; no accidental `system_fonts`, `glow`, + `persistence`, `inspection` or `experimental`. The library's own `egui` dependency must carry + `default_fonts`, not rely on an app root. +- Confirm accesskit/accesskit_consumer/kittest versions are the 0.37-compatible set; remove old + duplicate `accesskit_consumer` versions only if the resolver does so naturally. Do not force + one for its own sake. +- Run `cargo xtask pdf-deps` to prove the pinned PDF writer still matches. +- Keep a rollback commit at baseline `b47195f`, and preserve the baseline `Cargo.lock` in + evidence. Rollback of a stage is `git revert` of that stage plus restoring the old lock; no + document/settings migration is required because the formats do not change. +- Do not use untargeted `cargo update`; update only the egui-family/winit packages, then review + the lock diff. Do not commit a lock generated with local path overrides. + +--- + +## 6. Reviewable implementation stages + +Dependent PRs, in order. Every stage gets its own adversarial review and its own validation; no +stage is published without fresh human approval of contents and validation. + +### D0 — Baseline evidence and before/after harness (no production changes) + +- The before/after harness exists on the **baseline before production**: record `b47195f` source + identity, `Cargo.lock`, `rustc -Vv`, `cargo -V`, OS, GPU/adapter, DPI, fonts + (`CRAFT_FONTS_REF` or embedded face list), window size and feature flags, then capture the paired + baseline samples and raw data that every later stage compares against. +- Capture native screenshots (light/dark, 1×/2×) of: sample editor, ribbon tabs, Format Shape + pane, Animation Pane, Backstage, start screen, slide sorter, doc window, presenter, symbol + dialog, theme-colors dialog, notes/IME. +- Build the deterministic text-tessellation benchmark (plain ASCII labels, unchanged fixture) and + the frame/startup measurement scripts before production changes; save raw samples and the + `docs/benchmarks/baseline.json` candidate under evidence; do not commit machine-specific timings. +- Run the baseline gates (`cargo xtask ci`, optional Windows/wasm smoke) through `task.py` so the + starting tree is known green. +- Output: evidence and harness only; no source change. + +### D1 — atomic final-fork 0.37 compatibility PR + +Dependencies: D0 and final reviewed/published storytold egui/winit including P2b, P7, P7b/E21b, W15P/W27 and P7c. Files: root `Cargo.toml`, `Cargo.lock`, `crates/fonts/Cargo.toml`, +`crates/ui-egui/Cargo.toml` and `src/*`, `apps/deckcraft*` if the App trait/feature graph requires +it, tests. Stock 0.37 remains an isolated comparison workspace, never a production stage. + +- Bump `egui/eframe/egui_kittest` to 0.37; `epaint_default_fonts` to 0.37. Pin the complete immutable final egui-family/winit patches; preserve krilla. +- Explicitly enable native `eframe/clipboard` and verify cross-process copy/cut/paste. +- Include the compatible canvas E21b producer, incoming E21 byte handling and owner/clear/final-pass contract. +- Add `default_fonts` to `deckcraft-ui-egui`'s `egui` dependency (or a shared workspace feature), + not just the apps; verify `cargo test -p deckcraft-ui-egui` standalone. +- Replace `WidgetType` imports/variants with `Role`; `TextEdit -> TextInput`, + `Checkbox -> CheckBox`, `SelectableLabel -> Button`, `Other -> Unknown` initially. +- Replace egui `Id::new` with `Id::unique`; replace every `ui.id()` and `UiBuilder::id` with + `ui.scope_id()`/`UiBuilder::scope_id` (38 `ui.id()` references); remove `AsId` uses. Run + `cargo clippy -D warnings` to catch every remaining deprecated call. +- Add `source: MouseWheelSource` to synthetic wheel events in `canvas.rs`, `huge_deck_tests.rs`, + `lib.rs` soak tests. +- Refactor screenshots to the bounded completion-slot design in §4.3; remove `UserData` and + `Event::Screenshot`. +- Fix kittest fallout in this first green PR: required accessible names for genuinely unnamed + inputs (keep working `widget_info`), missing-glyph/glyph-coverage replacements, the + `default_fonts` feature fix for `deckcraft-ui-egui`, any `ATTRIBUTION.md` rows for changed + assets, and all gate updates. Do not defer names/glyph/feature/attribution/gate work to a later + stage, and verify app-owned settings sizes without an invented PanelState migration. +- Gate: workspace `cargo xtask ci`; UI kittest suite; wasm check; native smoke; review every + changed golden/geometry; standalone library tests with and without `CRAFT_FONTS_DIR`. This is the first publishable coherent compatibility stage. + +### D2 — Lifecycle, hidden logic, multi-pass and screenshots + +Dependencies: D1. Files: `crates/ui-egui/src/lib.rs`, `control.rs`, `media.rs`, `opening.rs`, +`uifonts.rs`, `textures.rs`, tests; `apps/deckcraft/src/main.rs`, `apps/deckcraft-web/src/web.rs`. + +- Add the monotonic app clock; move autosave deadlines, media clock and show/animation advancement + into `logic` on every call; split video texture sync into `ui`. +- Move native dropped-file handling and `note_ui_text` into the known UI phase; remove visibility + gating from background work. +- Make screenshot scheduling hidden-window-safe with the weak slot design; drain results in + logic; preserve deadlines and control JSON; bound pending memory. +- Add tests for hidden `run_logic` with a stale drop, hidden autosave/media/show advance, a forced + hidden paint (`RequestPaintWhileHidden`/pending screenshot) with current input, a two-pass Grid + discard, manual screenshot callback correlation and `ui.screenshot` while hidden/minimized. +- Gate: new tests, `cargo xtask ci`, native control-channel smoke (`ui.screenshot`, `ui.resize`, + `ui.inspect`), Windows VM smoke. + +### D3 — native dependency qualification + +Dependencies: D1 and D2. Use the already pinned final dependency unit; no second production +pinning stage. Exercise E2/E3 surface behavior, bounded E4/W15P transfer cancellation and +responsiveness, E19 held-key release pairing, E21/E21b IME and native child focus/close. +Run graph checks, Linux X11/Wayland, Windows clone and browser workflows with exact evidence. +No fork internals are copied into DeckCraft. Record unqualified native rows as gates. + +### D4 — Language/IME/clipboard app integration + +Dependencies: D2 + D3. Files: `crates/ui-egui/src/lib.rs` (`keyboard`, `ime_event`), +`crates/ui-egui/src/canvas.rs` (E21b producer), `crates/engine/src/keys.rs`/`cmd/text.rs` for +range deletion, tests, screenshots. + +- Handle `PasteImage` without double-pasting; handle `MiddleClickPaste` expectations. +- Handle pre-existing `DeleteSurrounding` + fork `DeleteSurroundingBytes` in the custom text path, + or record an explicit blocking gap. +- Implement the E21b `IMEOutput` producer for `canvas::ime_output`: logical model text, UTF-8 + byte cursor/anchor, preedit exclusion, password exclusion, bounded 4,000-byte extraction and + field/window identity; test outgoing state after delete/commit, deletion-only, focus/password + clears and multi-pass. +- Add bidi/RTL/CJK/Thai/Vietnamese tests, non-Latin shortcut tests and the #8763 selection-action + regression; decide/implement the animation timing accessibility role. +- Gate: native Linux/Windows IME and clipboard tests; W15P PRIMARY on available compositors; web + keyboard/IME smoke; no duplicate undo. + +### D5 — Performance, UI regressions, docs + +Dependencies: D1–D4. Files: tests/snapshots/benchmark scripts, `docs/`, `ROADMAP.md`, +`docs/gaps.md`, `docs/ui-parity.md`, `docs/architecture.md` if the lifecycle/architecture changed. + +- Add curated snapshot/geometry/a11y tests and mutation checks. +- Run matched baseline/candidate performance with the counts in §8 (1,000 frames for p99, 20 fresh + startup processes, 30 operation samples, 10 warmups); accept only within thresholds or with an + explicit, reviewed exception. +- Update progress docs and parity rows; add a short “egui 0.37” note to architecture/roadmap. +- Preserve evidence outside the disposable build target; clean only task-owned build artifacts + after all checks, per AGENTS.md. + +Rollback restores the complete coherent `b47195f`/0.36.2 source, lock and executable, +including its app-owned settings. Stock 0.37 is only a comparison control, never a rollback +or production handoff. Later behavior/test stages may revert independently only while the +remaining tree still passes its required compatibility and data-preservation gates. + +--- + +## 7. Regression and acceptance tests + +### 7.1 Unit / kittest (fast, deterministic) + +Use `egui_kittest::Harness::build_ui_state` (which drives `Context::run_ui`, not `Context::run`) +with the shared `run_frame` helper. Keep `with_size(1200, 800)` and step dt where existing tests +depend on it. The helper must mirror the real host: `logic` then `ui` inside `run_ui`, with a +separate `Context::run_logic` path for hidden iterations. + +Required new/updated tests: + +1. **Commands/undo:** insert shape, type text, move, undo/redo; assert document state and exactly + one undo entry per user action. Include a Grid `request_discard` pass and assert no duplicate + command/undo. +2. **Save/reopen:** build sample deck, save `.deckcraft` to a temp path via `Services::write`, + close, reopen, assert slide count, shape text, selection-independent content; repeat for a + `.pptx` round trip where existing format tests apply. +3. **Screenshot correlation:** capture the `ScreenshotCallback`s from viewport output and complete + them manually with distinct images in reverse order (kittest reuses one image for all callbacks, + so it cannot prove this). Assert token/path/size/base64 independence; assert one deadline error + for a missing frame; assert a late callback after timeout/close writes no file and sends no + reply; assert the bounded admission/aggregate-memory rejection. +4. **Hidden logic:** visible pass with a dropped file, then two `Context::run_logic` calls with + empty input; assert one insertion, not two; assert autosave/media/show clocks advanced by the + monotonic clock; assert a stale event/time is not consumed twice. +5. **Hidden forced paint:** with the root hidden, send `RequestPaintWhileHidden` (or issue a + pending `ui.screenshot`) and assert `App::ui` runs, current input/drop is handled once, the + screenshot reply arrives with capture-time geometry, and no reply is duplicated. This is the + valid hidden-input negative test. +6. **Accessibility:** every visible input has a name; role/name/value/selected checks for File/Back, + checkbox, selectable row, dialog fields, toast and a custom `widget_info` control; invisible + widgets absent. +7. **Geometry/panels:** 900×560 and 1440×900, 1×/2×; `canvasRect`/`slideRect` inside the central + area; title/ribbon/status do not overlap; pane widths persisted; clipped content not drawn + over neighboring panels; app-owned settings dimensions survive save/reopen without an egui + PanelState migration, and session-local outline resizing works. +8. **Fonts/glyphs:** standalone `cargo test -p deckcraft-ui-egui` renders with the library's own + `default_fonts`, with and without `CRAFT_FONTS_DIR`; curated screens use kittest's default + missing-glyph panic; fallback faces added once and `UiFonts` enforces its per-face and aggregate + budget under adversarial multi-script input; soak test uses `allow_missing_glyphs()` only for + arbitrary input. +9. **Selection:** select a subrange in the real notes TextEdit and use its context-menu + Copy/Cut, then the equivalent keyboard path; verify the copied/cut logical range and + intended draft edit/undo behavior. Do not use ribbon Bold as a proxy: it edits document + text, not the notes draft. Fork P7c owns the selected #8763 fix and tests; #8764 is + open/unmerged. D1 consumes that completed revision; this is not an optional fix choice. + +10. **Input/IME/clipboard:** `PasteImage` inserts once; regular paste still HTML/image-aware; + middle-click PRIMARY on notes works and on canvas does not insert; IME preedit/commit one + undo; pre-existing `DeleteSurrounding` and fork `DeleteSurroundingBytes`; E21b outgoing text + after delete/commit, deletion-only, preedit exclusion, selection/focus/password/long-selection/ + two-window/multi-pass; RTL caret/selection/Delete at grapheme boundaries; non-Latin shortcut + does not fire unmapped letters. +11. **Textures/GPU:** oversized canvas/window fits `max_texture_side`; zero-size/minimize does not + panic; texture filter/`editor_texture_options` preserved; screenshot size matches capture-time + DPI. +12. **Control protocol:** `ui.inspect`, `ui.resize`, `ui.screenshot`, `app.save`, `app.quit` + replies remain well-formed; timed-out requests do not run late. + +### 7.2 Screenshots and visual regression + +- Curated kittest snapshots at 1440×900 (and 2× pixels-per-point), light/dark: editor, ribbon tabs, + Format Shape, Animation Pane, Backstage, sorter, symbol gallery, theme-colors dialog, presenter + document window (embedded), start screen. +- Native screenshots from the Linux host and the Windows clone of the same fixture and window + size. Do not compare across OS/DPI/renderer. +- Predefine pixel policy: channel tolerance ≤2/255, changed-pixel fraction ≤0.10%, and no + geometry shift >1 device pixel for unchanged regions. 0.37's intentional style changes + (roomier buttons/corner radius) require an explained baseline update; do not silently accept a + changed golden. Review the `.diff`/`.new` images. +- Screenshots of DeckCraft are contributor-original; store them under evidence, not as untracked + repo assets. Do not use PowerPoint screenshots; the private `plan/powerpoint/screenshots/` + policy remains. + +### 7.3 Mutation checks (prove the tests detect failures) + +Run each as a temporary local patch, never commit, and record the failure output in evidence: + +- Remove one `labelled_by`/`accessible_name` from a visible unnamed field → accessibility test fails. +- Remove a working `widget_info` role/label → accessibility role/value test fails. +- Remove the per-layout-pass guard around a mutating action → the Grid discard test reports two + undo entries. +- Move dropped-file handling back into visibility-gated logic → the hidden forced-paint test + reports a missed drop or a stale-input replay. +- Gate autosave/media/show on visibility → the hidden logic test reports a stopped clock or no + AutoRecover. +- Let a late screenshot callback write/reply after timeout → the cancellation test fails. +- Reuse a screenshot token for two requests → screenshot correlation test reports crossed images. +- Remove `UiFonts` fallback for a CJK title → missing-glyph panic (curated screen) or tofu + screenshot; exceed the aggregate face budget → the budget test fails. +- Change panel order, set a panel to the wrong side or claim panel-internal state survives the + ID change → geometry/upgrade test fails. +- Change `editor_texture_options` filtering → texture/pixel test fails. + +### 7.4 Platform gates + +- **Linux native:** X11 and Wayland (Hyprland/KWin if available), 1×/2×, `deckcraft --sample + --control`; `ui.screenshot`, `ui.resize`, input/IME, regular clipboard, W15P PRIMARY, viewports. +- **Windows native:** fresh clone from `VMs/craft-windows`, new ports, separate source/target, + `Build-DesignCraft` equivalent for DeckCraft (`cargo build --release -p deckcraft` with + `CRAFT_FONTS_DIR`), DX12/FXC, Windows IME/layout, screenshot/GUI via `vm.py`/`guest-exec.py`, + `ui.screenshot`, doc window, save/reopen. +- **Web:** `trunk build --release` in `apps/deckcraft-web`; Chrome/Chromium with WebGPU and + `?webgl`; load sample, open/save/download, keyboard/IME, CJK/emoji rendering, hidden-tab + `run_logic`, no console panic. A wasm compile is not browser acceptance. +- **macOS:** release CI or real hardware only; AccessKit/on_exit, traffic lights, Apple events, + IME. If no real evidence is available, mark explicitly unresolved rather than passing. +- **FreeBSD:** existing CI/VM if available; no egui-specific claim otherwise. +- Do not report Linux cross-compilation as Windows/macOS validation. + +### 7.5 Required repository gates + +For each implementation stage: + +``` +cargo xtask ci +cargo xtask bench # after a baseline exists; evidence copy +cargo test -p deckcraft-ui-egui # with the reviewed CRAFT_FONTS_DIR +cargo test -p deckcraft-ui-egui # without CRAFT_FONTS_DIR, separate job +cargo test -p deckcraft +cargo check -p deckcraft-web --target wasm32-unknown-unknown +trunk build --release # apps/deckcraft-web, when web behavior changed +``` + +Run through `task.py run/status/finish` with the stage's unique task and evidence; do not run a +build/test directly outside that workflow. Use the repository's `CRAFT_FONTS_DIR` and the same +font revision for baseline and candidate where the comparison requires fonts. + +--- + +General performance reports include 0.36, an isolated stock-0.37 control and the final fork +for frame/startup/canvas/thumbnail/capture/save-reopen workloads where comparable. Record each +source/lock and use a bounded test adapter where fork-only APIs differ; explain unavailable +equivalence instead of attributing all changes to the fork. This applies to the first report +and investigated regressions, not only the #8731 microbenchmark. + +## 8. Performance plan + +### 8.1 Environment and fixtures + +Record for every run: exact source SHA and lock digest, feature set, `CRAFT_FONTS_DIR`/font SHA, +fixture (sample deck, a 20-slide business deck, a 200-slide and 800-slide synthetic deck), OS and +kernel, CPU/GPU/adapter/driver, renderer (native wgpu DX12/Vulkan/GL vs Microsoft Basic Render +Driver software), DPI (`pixels_per_point`), viewport size, window state (visible/fullscreen/ +hidden), release profile, and warmup/repetition counts. The harness and the 0.36.2 baseline raw +samples are captured in D0 before any production change. + +### 8.2 Operations + +- Headless (`cargo xtask bench` / `deckcraft-cli bench --runs N --json`): open `.deckcraft`, + open `.pptx`, render all slides at 1×, save `.deckcraft`, save `.pptx`, export PDF; peak RSS. +- UI native via control channel: + - cold start to first `ui.inspect` (process start → reply), 20+ fresh processes; + - idle 60 s: CPU time, `perf.fps`, repaint count if exposed; + - `ui.screenshot` latency and PNG size (root visible); + - `ui.resize` geometry settle at 900×560, 1440×900, 2560×1440, 2×; + - canvas render ms (`ui.inspect.perf.renderMs`) after edit, undo, slide change; + - thumbnail render count/budget on the 800-slide deck (existing `textures.thumb_count` and + `canvas_renders` can be exposed for tests); + - typing/IME/keyboard latency via command start → document reply; + - open/save/reopen through control; + - show start/end and presenter window. +- **Targeted text-tessellation benchmark (#8731):** a deterministic fixture of plain ASCII labels + through `Context::tessellate`, unchanged across 0.36.2, stock 0.37 and the selected fork. + Record instructions (or a stable per-operation latency) with a predeclared threshold; general + frame timing can hide a large text-tessellation regression. Compile time is not UI performance. +- Headless kittest frame timing: at least 1,000 frames per curated screen distribution; report + median/p95/p99 and allocations if a test-only counting allocator is added. Aggregate raw + samples across runs so p99 has a real sample count. +- GPU: separate software renderer and real GPU; do not mix. Windows VM results are software + DX12 and must be labelled as such. + +### 8.3 Sampling and noise policy + +- Warm up at least 10 iterations (or the documented time-based equivalent) before measuring. +- For each operation, collect at least 30 paired repetitions; for frame distributions collect at + least 1,000 frames per distribution, and report the actual sample count. Do not estimate p95/p99 + from 20–50 samples. +- Startup uses at least 20 fresh processes, alternating baseline/candidate. +- Report raw JSON, median, p95, p99 and max; discard only the warmup interval. +- If coefficient of variation >3% for a median, rerun with more samples or on a quieter host; + never compare a baseline and candidate from different machines/renderers/DPI. +- Paired A/B on identical environment, alternating baseline/candidate runs to reduce drift. + +### 8.4 Predefined thresholds + +Investigate and block acceptance when any holds: + +- UI frame **median >5% slower** or **p95 >10% slower**; with at least 1,000 frames, also + investigate p99 >15% slower; +- targeted text-tessellation operations beyond their predeclared threshold; +- canvas/thumbnail render median >5% slower; +- cold start >5% slower over 20+ fresh processes (the #8736 DX12/FXC shader cost must be reported + separately, not hidden in the aggregate); +- peak RSS >5% higher or >50 MiB higher; +- idle CPU >0.5 percentage points absolute, or idle repaint rate materially higher; +- screenshot latency >10% slower or PNG size unexpectedly changed; +- any pixel/geometry/accessibility regression, regardless of performance. + +These percentage thresholds are the gate; do not add a larger absolute budget that would make a +known percentage regression acceptable. Do not auto-accept changed goldens or claim a performance +pass from compile duration. A regression that is an upstream issue (#8731 text tessellation, +#8736 DX12 FXC) still needs a measured baseline/candidate report and an explicit decision; prefer +an upstream/fork fix that preserves color glyph and texture wrap/filter semantics over a +clamp-only shortcut. + +--- + +## 9. Evidence, documentation and cleanup + +- Evidence root: `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/deckcraft/` + (or the per-task evidence path returned by `task.py`). Keep baseline/candidate locks, source + diffs, raw performance JSON, screenshots, test logs, mutation-check logs and exact font/fixture + identities. +- Durable plan: this file. Update `docs/architecture.md` only for the lifecycle/fork facts that + become true; update `ROADMAP.md` progress log, `docs/ui-parity.md`, `docs/gaps.md` and + `docs/localization-parity.md` only for actual landed behavior. Do not claim a parity row or + close a gap from a plan. +- No new assets: screenshots are evidence, not repo assets; if a font/glyph replacement adds an + asset, follow `ATTRIBUTION.md`/`craft-fonts` rules. Do not add Microsoft/Adobe/Avid/Autodesk + material. +- Cleanup: after all stages and reviews, remove only the task's disposable Cargo target and + rebuildable outputs (see AGENTS.md), preserving source, evidence, screenshots, locks and any + unique files. Do not clean other sessions, VMs, baselines or checkouts. +- No agent/model/tool attribution in any publishable PR/release text. +- Independent-review disposition: H1-H9, M10-M11 and L12-L17 were checked against source and are + resolved in this revision. One sub-claim was rejected: the review said the `UserData` anchor was + around `lib.rs:1952`; the actual `egui::UserData::new` call is at `lib.rs:1931` (the plan's + cited line), while `Event::Screenshot` is at `:1951`. Only `logic` (`1539 -> 1540`) and + `keyboard` (`1667 -> 1668`) needed anchor correction. + +--- + +## 10. Blocking unknowns and explicit non-claims + +1. **Production pins.** Production requires reviewed, published, immutable **storytold** full + SHAs for the complete egui family and winit, including W15P PRIMARY and E21b. The `aiongg` + `a4f9a17f`/`8b35388b` revisions are review inputs only; a branch, personal-fork SHA or + stock-only intermediate release is not a production pin. +2. **macOS/native hardware evidence.** The plan cannot pass macOS AccessKit/IME/traffic-light/ + Apple-event behavior without a real macOS build/GUI run. Mark unresolved if unavailable. +3. **Upstream 0.37 regressions.** #8731 (text tessellation ~+60% instructions) and #8736 (DX12 + FXC pipeline ~+40 ms startup) are open reports, not this task's results; measure them with the + targeted benchmark and 20+ fresh startup processes. #8763 (TextEdit click-away selection loss) + is open; require the fork-owned tested fix and the DeckCraft selection-action regression. + The separate `upstream-8764-fresh.json` snapshot verifies #8764 as open/unmerged at + `6eb460a2a6d537d312a80579cb801895509deaed`; it is a proposal, not a released fix. #8748 is **closed and not merged**, and is not in + 0.37.0; do not claim it fixes hidden-parent/deferred-child behavior. #8512 is a closed + PR/comment thread; its system-font discovery startup cost is motivation for keeping + `system_fonts` off, not a fresh DeckCraft defect or an acceptance result. +4. **Accessibility inventory.** Many `TextEdit::` references and custom `interact` controls need + names/roles; the actual kittest failures are unknown until the stock 0.37 run. Keep and repair + working `widget_info` role/label/value rather than replacing it wholesale. Do not disable the + check globally to hide failures. +5. **Glyph coverage and `UiFonts` bounds.** The literal symbol set and installed-font coverage + differ by OS. A curated panic-policy screen test may require replacing decorative glyphs or + adding an allowed fallback font; do not introduce tofu in production silently. The current + 128 MiB limit is per face; add and test an aggregate budget before calling `UiFonts` bounded. +6. **IME delete-surrounding and outgoing text (historical plan gap).** `DeleteSurrounding` already + existed in 0.36.2 and was ignored at plan time; the custom slide-text path had no range-delete + API then. E21b was a new prerequisite: the plan-time candidate had no logical surrounding text + in `IMEOutput` and no Wayland delivery. **Final local status (2026-10-25):** the reviewed source + now publishes `surrounding_text: update.surrounding_text` (`crates/ui-egui/src/lib.rs:2751`) and + handles both `DeleteSurrounding` forms (`crates/ui-egui/src/lib.rs:2540-2549`), so that source + gap is closed. Native/Wayland delivery and all IME qualification remain unproven; do not claim + E21/E21b acceptance from source presence alone. +7. **`ui.screenshot` on a hidden/minimized window.** A pending screenshot/`RequestPaintWhileHidden` + forces a UI pass; the weak-slot design needs a real native test (and Windows clone) for capture + geometry, no late file/reply, bounded memory and input handling. Offscreen kittest is not proof + of native focus/occlusion behavior. +8. **Wayland PRIMARY.** Candidate E4 uses arboard for PRIMARY; the required W15P winit/SCTK path + must be reviewed and published before production. Native compositor evidence remains a gate. +9. **PR 199 / #198 merge state.** They are external work; this plan does not merge, rebase or + publish them. The implementation is based on merged PR 199 at `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` + (the older `8614e4a`/`1764bca`/`4919088` references are superseded); never merge + `b68de8c`/`ec8840a` as trees. DeckCraft does not depend on `craft-ui`; if that changes, + re-plan the DAG. +10. **Nix/flake.** The repository has a `nix.yml` workflow but no local `flake.nix`/`nix` files in + this checkout; do not claim Nix packaging validation from this plan. +11. **No tests or builds were run for this plan.** The historical compiler errors are clues, not + evidence that the current checkout compiles or that the migration passes. The before/after + harness must exist on the baseline before production and all validation uses the managed + `task.py` workflow. + +--- + +## 11. Acceptance checklist + +- [ ] Stock 0.37.0 experiment resolves and all workspace tests pass; wasm checks pass; it is not + published as an intermediate release. +- [ ] Production uses exactly one reviewed storytold egui-family source and one reviewed storytold + winit source, including W15P PRIMARY and E21b; no branch/path/personal-fork; `krilla` + preserved; `cargo xtask pdf-deps` green. +- [ ] `App::logic`/`App::ui` root-`Ui` semantics documented and tested on the real call order; + `run_logic` stale input/time is never treated as fresh; no `root_ui` panic path. +- [ ] Hidden logic does not duplicate events/drops/autosave; autosave, audio/media and show clocks + advance on a monotonic clock; no pass required for control/screenshot replies; forced hidden + paint handles current input. +- [ ] Multi-pass/Grid discard produces one command/undo and no duplicated side effects. +- [ ] Screenshot completion slots are bounded/cancelable/weak, use capture-time geometry, never + hold a Context reference cycle, produce no late file/reply, and pass correlation/hidden/ + timeout/overflow tests. +- [ ] Commands, undo, save/reopen and control protocol unchanged in observable behavior. +- [ ] Panel order/clipping/geometry and doc/presenter viewports tested at 1×/2× and narrow size; + app-owned `UiState` dimensions survive save/reopen and tests use parent-salted + in-memory keys; no persisted egui panel reset is invented. +- [ ] Every visible input has an accessible name; working `widget_info` roles/values retained; + roles/selected states asserted; missing-name mutation check fails as expected. +- [ ] Curated glyph coverage passes with kittest panic policy; `UiFonts` per-face and aggregate + budgets tested; standalone `deckcraft-ui-egui` tests pass with and without `CRAFT_FONTS_DIR`. +- [ ] PasteImage, W15P PRIMARY, pre-existing DeleteSurrounding + E21b outgoing text, RTL/grapheme/ + non-Latin shortcuts and the #8763 selection-action regression tested. +- [ ] GPU surface/texture limits, filtering, DX12 FXC and #8731 text tessellation measured; no panic + on resize/minimize. +- [ ] Matched baseline/candidate performance with 1,000-frame distributions, 30 operation samples, + 10 warmups and 20+ fresh startup processes; investigate >5% median / >10% p95; no looser + absolute budget; or an explicitly reviewed exception with raw evidence. +- [ ] Windows clone, Linux native, web browser and (if available) macOS/FreeBSD gates recorded; + unresolved gates named honestly. +- [ ] Evidence preserved, task build artifacts cleaned, no unrelated worktrees/VMs touched. +- [ ] Fresh human approval obtained before any push/new PR; no publication from this plan. + +--- + +## 12. Primary sources consulted + +These are source references, not fresh validation results. `0.37.0` is the peeled release commit; +the fork is the reviewed candidate tip. The issue/discussion snapshots are the migration +workspace's read-only copy. + +- egui `0.37.0` changelog and source: `https://github.com/emilk/egui/tree/0.37.0` (peeled + `841a328ba2dd8f6996beb7189ebb7fc00e668a2a`), especially + `crates/egui/src/context.rs`, `crates/egui/src/data/input/event.rs`, + `crates/egui/src/data/screenshot_callback.rs`, `crates/egui/src/viewport.rs`, + `crates/egui/src/response.rs`, `crates/egui/src/lib.rs` (Role migration table), + `crates/eframe/src/epi.rs`, `crates/eframe/src/native/epi_integration.rs`, + `crates/eframe/src/native/wgpu_integration.rs`, `crates/egui_kittest/src/lib.rs`, + `crates/egui_kittest/src/accessibility.rs` and `crates/egui_kittest/src/builder.rs`. +- Callback screenshots: emilk/egui PR #8489, + `https://github.com/emilk/egui/pull/8489`. +- Root `Ui`/`run_pass`/`root_ui` panic semantics: emilk/egui PR #8535, + `https://github.com/emilk/egui/pull/8535`. +- `Role` replacing `WidgetType`: emilk/egui PR #8555, + `https://github.com/emilk/egui/pull/8555`; `default_fonts` no longer default: PR #8583, + `https://github.com/emilk/egui/pull/8583`; `Panel` `AsIdSalt`: PR #8592, + `https://github.com/emilk/egui/pull/8592`. +- Accessibility behavior: #8581 (popup/menu/modal/panel trees), #8578 (panel resize handles), + #8604/#8608/#8612 (accessible names/roles), #8621 (invisible widgets), #8589/#8611 (kittest + unnamed input check), #8601 (missing glyph policy). Use the PR pages under + `https://github.com/emilk/egui/pull/`. +- PRIMARY/clipboard: #8428 (`https://github.com/emilk/egui/pull/8428`), #8472 + (`PasteImage`), #8657 (Wayland clipboard/PRIMARY interaction, + `https://github.com/emilk/egui/pull/8657`). +- Bidi/language: #8577 (`https://github.com/emilk/egui/pull/8577`) and the suite's E5/E12/E13/ + E17/E18 ledger. +- Performance reports: #8731 (text tessellation, + `https://github.com/emilk/egui/issues/8731`), #8736 (DX12 FXC shader compile, + `https://github.com/emilk/egui/issues/8736`), #8512 comments (system font startup, + `https://github.com/emilk/egui/pull/8512`). +- TextEdit click-away selection regression: #8763 + (`https://github.com/emilk/egui/issues/8763`, open). The separately verified open/unmerged + [#8764](https://github.com/emilk/egui/pull/8764) is a proposed fix to review, not release behavior. +- Hidden-parent/deferred-children behavior: #8748 + (`https://github.com/emilk/egui/pull/8748`), closed and **not merged**; not in 0.37.0. +- Lifecycle source audit and IME gap evidence (local, read-only): + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md` and + `.../ime-surrounding-gap.md`. +- Suite fork provenance and patch ledger (review input): + `https://github.com/aiongg/egui/blob/a4f9a17f64a6bae61350fb85804c9df0d9865916/PATCHES.md` + and `https://github.com/aiongg/egui/tree/a4f9a17f64a6bae61350fb85804c9df0d9865916`. +- Winit fork (review input): `https://github.com/aiongg/winit/tree/8b35388bd719e8cc86a95971ba0bc026c9a8f058`, + with the W1–W27/W15P ledger recorded in the craft-libs migration planning note and the local + `PATCHES.md` provenance. +- craft-libs PR 199 and language work #198 are external work under + `https://github.com/storytold/craft-libs/pull/199` and + `https://github.com/storytold/craft-libs/pull/198`; their merge state must be rechecked before + any consumer relies on them. This implementation uses merged PR 199 at + `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; the older `8614e4a`/`1764bca`/`4919088` + references are superseded. + + +## 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. + +Canonical `storytold/deckcraft` `main` now resolves to `0f4fffcf6c71ffa16029d07ac23752fedd783547`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +No migration API redesign required by this delta; preserve the merged packaging, test and chrono changes where present. + +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 (v17 review findings) + +The v16 review found four concrete defects still open. Implementation here stays app-owned: +DeckCraft has no `craft-ui` dependency, and the reviewed `craft-ui` PR is not part of this +checkout, so no generic compatibility component is added or copied. The existing `capture.rs` +bounded registry remains the owner of screenshot admission, memory reservation and completion. + +1. **Large split-button geometry.** A two-line label used `text_w + 10` while the 14 px arrow + strip occupied the last 14 px of the button, leaving up to 4.5 px of the visible final line + over the arrow target. The button now reserves the arrow strip *and* the label centre in the + remaining main area, so no visible line reaches the strip. A regression uses a real pointer on + the last line's widest glyph pixels and separately on the arrow. +2. **Small split-button names.** Arrows now use their own command name (`"Bullets options"`, + `"Shape Fill options"`, …) instead of a repeated `"More options"`. Empty-label bullets and + numbering expose their real names through a named variant, while the old + `small_button_ex` signature keeps its default for ordinary labels. +3. **Screenshot completion bounds and wake.** `CaptureSlot::complete` checks the callback + image's checked byte size against the reservation for the issued capture geometry *before* + storing the image. An oversized frame becomes a retryable slot result, releases no stale + reservation twice and still fails on the normal request deadline. The one-shot callback + upgrades its weak slot, releases the slot lock, then requests a repaint of the exact issued + viewport. Queued/settling requests force the bounded `RequestPaintWhileHidden` passes; issued + requests rely on the screenshot command plus this wake, with a deadline wake for a missed + frame. No per-frame 16 ms repaint timer remains. +4. **Hidden slideshow clock.** A hidden/minimized show is not treated as paused: hidden logic + anchors the last visible egui time to a monotonic instant, advances the show clocks and + automatic steps from logic alone, and requests a bounded logic wake while the show is active. + Drawing stays separate, so a hidden forced paint cannot advance the clocks a second time and + no paused-show product policy is invented. + +The four v16 repairs (first-font requeue, stale-batch separation, IME no-op/unavailable receipts, +large split-button main/arrow hit targets) are retained. Verification remains with the parent's +managed `task.py` tests; this amendment claims no build, native GUI or performance result. + +## Local implementation repair — 2026-10-24 (v20 review findings) + +Scope is limited to the two findings in +`egui037-deck-bounded-final-review-20261024-root-v20.md`. Capture admission, reservation, wake, +encoding and persistence code are unchanged. + +1. **Visible logic-only pass no longer re-anchors a hidden show.** `SlideApp::tick_hidden_show` + now returns without touching `hidden_anchor` when the viewport is visible. Only a full visible + UI pass in `show::ui` records `(InputState::time, monotonic instant)`; a `run_logic` pass for a + visible window carries the previous full frame's egui time and can therefore no longer move + the hidden clock backwards before the window is minimized. The regression drives a full + visible pass, an over-due visible `run_logic` pass whose `InputState::time` is still stale, and + then a hidden `run_logic` pass whose 2.5 s automatic advance proves the original anchor was + preserved. +2. **All 14 small split callers now have live named halves.** `widgets::split_menu` / + `split_popup` give a dropdown-only split one stable popup id and one anchor spanning both + named halves, so the label/icon and the chevron open and close the same menu. This covers + Layout, Section, Add Shape, Layouts, Convert, Change Colors, SmartArt Styles, Shape Effects on + Home and Shape Format, Text Fill, Text Outline, and Compress Pictures. The colour variants use + `split_color_popup` to retain the Eyedropper hand-back; Compress Pictures retains its + close-on-click-outside behavior and width. Group and Rotate remain real split controls: their + main halves execute `arrange.group` and `arrange.rotateRight`, and only their named options + arrows open the existing menus. Main and arrow accessible names are unchanged. + Pointer/accessibility regressions exercise the actual Home Layout caller, Shape Effects on + both tabs, Shape Format Text Fill, Group, and Rotate. + +Remaining limitations and scope: + +- No Cargo, rustc, test, build, fmt, clippy, metadata, native-window or GUI command was run for + this repair. Compilation and test outcomes remain with the parent's managed `task.py` workflow. +- The hidden-clock regression reproduces stale input time through `Context::run_logic`; it does + not schedule an actual eframe `PassMode::LogicOnly` callback. Native overdue-redraw timing still + needs the managed runtime check. +- The new pointer regressions cover representative callers rather than every dropdown-only call + site; the remaining callers share the same helper and retain their accessible names. +- Split controls with a real main command other than Group/Rotate were already live and are not + redesigned. No new generic UI component or compatibility shim was added. + + +## Cleared-preedit terminal ownership repair — 2026-10-24 + +An accepted empty preedit restores the committed model text, but is not delivery of the separate +terminal commit/delete. The UI now retains that composition owner/generation and a small expected +selection/revision record. Pointer/caret work, target replacement or another application revision +invalidates the pending record before it can authorize a later mutation. Its cancelled owner +survives idle passes and an outgoing interrupt until an incoming terminal commit (or platform +enable/disable) is observed. A refused terminal commit releases the record immediately, permitting +a new preedit later in the same raw batch. Accepted IME deletion refreshes the expected selection +so its own caret/revision shift does not reject its paired commit. Immutable target identity, +model composition/undo, existing active-composition reset policy and direct commits remain intact. +The event payload still carries no epoch: if an old terminal is never delivered, the first +ambiguous new composition after an unobserved reset can be rejected. No timer guesses provenance. + +Five new actual-app tests cover11 scenarios: separate-batch pointer/caret and target interruption, +both deletion forms with paired or split terminal delivery, unchanged pending empty-preedit +replacement, direct commits, and fresh composition after the rejected terminal boundary. They use +real RawInput pointer/key/IME events and production target-selection commands; original tests and +assertions remain unchanged. These new bytes await parent-managed formatting, focused/full CI and +fresh independent review. No new native IME or performance acceptance is claimed. + +The CI font-pin preflight's CRAFT_FONTS_REF is corrected to the same reviewed b076417b5880bd4154bd22f00695b1d28c6bea83 +used by gate/FreeBSD/release; the old43913056 value remains only the historical pending comparator. +This is a separate one-line workflow correction, not a dependency/lock or font-asset change. + +## Final current local review status — 2026-10-25 + +This section records the final local state after +`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/final-landing/deck-final-review0045.txt`. +Earlier sections remain the historical plan and implementation amendments; where they describe an +earlier snapshot or an open plan-time gap, this section is the current local status. + +### Current review follow-up — 2026-10-25 + +The candidate workflows now use `37827adc41fd165f9a9234ac40f94dbad353aa69`, combining +all prior Latin presentation fonts with the migration's core and Chinese fallbacks. +This commit must be published before these workflow pins can be fetched remotely. +The reusable CI gate explicitly sets `require-craft-fonts: true`; its current default +also enables required mode. The earlier statement that this cannot be passed to the +shared gate is obsolete. + +The local base for this follow-up is `8372d51552c3ed943ffeb283f26a482e94a2c12f`. +The five source files listed in the earlier dirty-union review are already tracked in +that commit. The follow-up also includes `crates/fonts/tests/fixtures/latin-families.txt`; +its 51-row family/style inventory comes from the prior `76ba5a0` catalog, whose font +bytes are preserved by the combined candidate. Include that fixture with the code. +Full follow-up checks and the final local review commit are recorded in the next +publication packet; old dirty-union hashes below are historical evidence only. + +### Final pins + +| Dependency | Final local pin | Note | +|---|---|---| +| egui family (`egui`, `eframe`, `egui-winit`, `egui-wgpu`, `epaint`, `ecolor`, `emath`, `epaint_default_fonts`, `egui_kittest`, …) | `storytold/egui` `6d518fc799681571963842f95b55a6b68431b15a` | `Cargo.toml` patch entries and `Cargo.lock` agree | +| winit | `storytold/winit` `a517b3846ee8457d84ee4975110bfda6797c44dc` | `Cargo.toml` patch entry | +| craft-media | `storytold/craft-libs` `f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4` | intentionally separate craft-libs pin | +| craft-pdf / krilla patch | `storytold/craft-libs` `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef` | intentionally separate craft-libs pin | + +The craft-media `f0ad6c1e…` and craft-pdf `9f75d3dc…` pins are intentionally separate from the +egui/winit pins; they are **not** required to move to `25da1314…`. The font inputs bundled by +`epaint_default_fonts` at `6d518fc7…` are byte-identical to those at the superseded `173cc737…` +reviewed source (all files in `crates/epaint_default_fonts/fonts/` hash the same), so +`ATTRIBUTION.md` now cites the exact current source URL without changing provenance. + +### Review disposition + +- **P1 (source completeness, blocking publication):** the five untracked files above are required + source. The dirty worktree can build while a clean checkout of `770f4653` cannot. Not fixed by + this documentation edit and not claimed as qualified. +- **P2 (documentation/attribution):** header/base identity, the stale plan-time IME gap, the final + pins and the `ATTRIBUTION.md` font source URL are reconciled in this revision. +- **P2 (IME completion race):** an unproven race risk where an old `ImeResetCompleted` could clear + `Awaiting` for a changed destination before the finalizer. No failing reproduction exists. No + production or test code was changed speculatively; the risk stays unqualified. +- **P3 (`thumbs_scroll` contract):** the comment advertises the value for the control channel while + `#[serde(skip)]` keeps it out of `ui.inspect`; only tests read it directly. Recorded as a + comment/dead-contract defect, not fixed here. +- **P3 (unreachable IME fixture):** the `Suspended { request: Some(…) }` test state is not produced + by any production constructor, so it gives limited assurance. Not fixed here. +- The `ScrollArea::clamped_offset()` and `IMEOutput` Option handling reviews found no concrete + production defect; thumbnail behaviour coverage (opening at a non-zero slide, wheel/drag, + smooth scrolling) remains incomplete. + +### Validation actually performed + +- Retained full CI run 2325 reached fmt, `pdf-deps` and clippy PASS, then exhausted the 8 GiB + reservation during test compilation. It does not establish that the current tests compile or + pass. +- Full CI, test execution, WASM, native/browser IME, real GUI behaviour, clean-checkout build and + performance were **not** passed. +- No Cargo, build, test, fmt, clippy, VM or resource command was run for this documentation edit. +- Publication, commit, push, PR, review comment and merge remain outstanding and require separate + explicit human approval. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index f1cf8047..a88f73cb 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft's interface is English only today: strings are literals in the UI and engine code, with no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 @@ -25,7 +25,7 @@ support partly there), **55–90 h** to parity plus native-speaker review. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full | |---|---|---|---|---|---|---|---|---| | English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) | +| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | native candidate optionally embeds LXGW WenKai Lite via craft-fonts, with existing per-character fallback; other builds use system CJK fonts (Noto CJK remains excluded); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) | | Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 | | Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 | | Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) | @@ -58,6 +58,7 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. | | 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret | | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) | | 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle | diff --git a/packaging/common.sh b/packaging/common.sh index 369a74a5..c76be3f7 100644 --- a/packaging/common.sh +++ b/packaging/common.sh @@ -50,7 +50,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml index 85abe9a7..574dbc86 100644 --- a/packaging/linux/flatpak/ai.storyteller.deckcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.deckcraft.yml @@ -47,6 +47,7 @@ modules: build-commands: - cargo --offline build --release --locked -p deckcraft - install -Dm755 target/release/deckcraft /app/bin/deckcraft + - install -Dm644 NOTICE /app/share/doc/deckcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.deckcraft.desktop /app/share/applications/ai.storyteller.deckcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.deckcraft.mime.xml /app/share/mime/packages/ai.storyteller.deckcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/windows/deckcraft.wxs b/packaging/windows/deckcraft.wxs index 43d256fe..cf07e388 100644 --- a/packaging/windows/deckcraft.wxs +++ b/packaging/windows/deckcraft.wxs @@ -11,6 +11,9 @@ UpgradeCode is fixed for DeckCraft forever (playbook §6); never change it. --> + + + + + + + + + + + +