From 0ea84e8603271d08c0e89aea2eb0628bd37f702d Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sat, 24 Oct 2026 10:01:47 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 35 +- .github/workflows/freebsd.yml | 6 +- .github/workflows/release.yml | 5 +- ATTRIBUTION.md | 21 +- Cargo.lock | 421 ++- Cargo.toml | 28 +- NOTICE | 8 +- apps/deckcraft-web/src/web.rs | 17 +- apps/deckcraft/src/main.rs | 4 +- crates/anim/src/path.rs | 2 +- crates/av1/src/tile.rs | 6 +- crates/engine/src/cmd/insert.rs | 62 +- crates/engine/src/cmd/slide.rs | 4 +- crates/engine/src/cmd/text.rs | 543 +++- crates/engine/src/guard.rs | 2 +- crates/engine/src/icons.rs | 2 +- crates/engine/src/keys.rs | 12 +- crates/engine/src/lib.rs | 31 +- crates/engine/src/recovery.rs | 4 +- crates/engine/src/tests.rs | 30 +- crates/engine/src/tools.rs | 2 +- crates/fonts/Cargo.toml | 2 +- crates/fonts/build.rs | 182 +- crates/fonts/src/fontdb.rs | 55 +- crates/fonts/tests/build_input.rs | 4 + crates/format/src/lib.rs | 52 +- crates/media/src/player.rs | 2 +- crates/metafile/src/emfplus.rs | 2 +- crates/metafile/src/tests.rs | 2 +- crates/model/src/chart.rs | 2968 +++++++++--------- crates/model/src/lib.rs | 6 +- crates/model/src/style.rs | 2 +- crates/model/src/text.rs | 4 +- crates/model/src/trendline.rs | 161 +- crates/opus/src/celt/bands.rs | 2 +- crates/opus/src/celt/mod.rs | 2 +- crates/opus/src/decoder.rs | 2 +- crates/pdf/Cargo.toml | 2 +- crates/pdf/src/lib.rs | 50 +- crates/render/src/images.rs | 2 +- crates/text/src/lib.rs | 4 +- crates/ui-egui/Cargo.toml | 9 +- crates/ui-egui/src/backstage.rs | 99 +- crates/ui-egui/src/canvas.rs | 45 +- crates/ui-egui/src/capture.rs | 983 ++++++ crates/ui-egui/src/control.rs | 78 +- crates/ui-egui/src/credits.rs | 7 +- crates/ui-egui/src/dialogs.rs | 145 +- crates/ui-egui/src/eyedropper.rs | 2 +- crates/ui-egui/src/eyedropper_tests.rs | 2 +- crates/ui-egui/src/fillui.rs | 19 +- crates/ui-egui/src/huge_deck_tests.rs | 2 + crates/ui-egui/src/ime_tests.rs | 857 ++++- crates/ui-egui/src/last_color_tests.rs | 4 +- crates/ui-egui/src/lib.rs | 1870 ++++++++++- crates/ui-egui/src/media.rs | 59 +- crates/ui-egui/src/opening.rs | 19 +- crates/ui-egui/src/panes.rs | 92 +- crates/ui-egui/src/paragraph_dialog_tests.rs | 17 +- crates/ui-egui/src/replacefonts_tests.rs | 24 +- crates/ui-egui/src/ribbon.rs | 405 ++- crates/ui-egui/src/selpane.rs | 25 +- crates/ui-egui/src/show.rs | 286 +- crates/ui-egui/src/sorter.rs | 34 +- crates/ui-egui/src/split_button_tests.rs | 129 + crates/ui-egui/src/squiggles.rs | 19 +- crates/ui-egui/src/status.rs | 16 +- crates/ui-egui/src/theme.rs | 178 +- crates/ui-egui/src/toast.rs | 4 +- crates/ui-egui/src/ui_soak_tests.rs | 3 +- crates/ui-egui/src/uifonts.rs | 35 +- crates/ui-egui/src/widgets.rs | 293 +- docs/egui-037-migration-plan.md | 1386 ++++++++ docs/localization-parity.md | 5 +- packaging/linux/package.sh | 14 +- packaging/windows/deckcraft.wxs | 11 + packaging/windows/package.ps1 | 14 + xtask/src/assets.rs | 2 +- xtask/src/layers.rs | 2 +- xtask/src/pdf_deps.rs | 21 +- 80 files changed, 9367 insertions(+), 2599 deletions(-) create mode 100644 crates/fonts/tests/build_input.rs 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..9b342bca 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: + # The migration's font pin gate stays a local preflight. A reusable-workflow job cannot run + # custom steps or export CRAFT_FONTS_REQUIRED into gate.yml, so the check is its own job and + # the shared gate waits for it. Keep this pin identical to the `craft-fonts-ref` below, + # freebsd.yml and release.yml. + font-pin: + runs-on: ubuntu-latest + env: + # Same craft-fonts commit as release.yml. + CRAFT_FONTS_REF: b076417b5880bd4154bd22f00695b1d28c6bea83 + 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: b076417b5880bd4154bd22f00695b1d28c6bea83 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..02655ac5 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: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c402be8..fc960c04 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: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3299a639..11516570 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -41,7 +41,7 @@ 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 | +| 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, 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 | ## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`) @@ -58,9 +58,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 +80,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 38a55619..61ac0ff8 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" @@ -816,7 +788,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "krilla", "thiserror 2.0.21", @@ -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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[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,18 +2391,18 @@ 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]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "base64", "bumpalo", @@ -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=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" 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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 6f0f9e73..4cddf338 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,16 +60,17 @@ vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } i_overlay = "9" 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 } @@ -103,4 +104,19 @@ codegen-units = 16 debug = 0 [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/NOTICE b/NOTICE index 79a0babb..d7cf14ac 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 diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index dabad8af..9d9852df 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -75,10 +75,13 @@ struct WebShell { impl eframe::App for WebShell { 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); + } + + 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 +100,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..00a1462c 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -42,8 +42,8 @@ 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); + 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); diff --git a/crates/anim/src/path.rs b/crates/anim/src/path.rs index af61bd58..ec31628c 100644 --- a/crates/anim/src/path.rs +++ b/crates/anim/src/path.rs @@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec { n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) } { i += 2; - while cs.get(i).is_some_and(|d| d.is_ascii_digit()) { + while cs.get(i).is_some_and(char::is_ascii_digit) { i += 1; } break; diff --git a/crates/av1/src/tile.rs b/crates/av1/src/tile.rs index 5d73688b..c8446795 100644 --- a/crates/av1/src/tile.rs +++ b/crates/av1/src/tile.rs @@ -2222,18 +2222,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 { if diff <= 2 * r { if diff & 1 == 1 { return r + ((diff + 1) >> 1); - } else { - return r - (diff >> 1); } + return r - (diff >> 1); } diff } else { if diff <= 2 * (max - r - 1) { if diff & 1 == 1 { return r + ((diff + 1) >> 1); - } else { - return r - (diff >> 1); } + return r - (diff >> 1); } max - (diff + 1) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 149957df..4f28ddef 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { }) } +fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option>, bin_size: Option>) { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } +} + +fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) { + if let Some(t) = subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { + c.up_down_bars = b; + c.raw = None; + } + if let Some(q) = p.get("quartiles").and_then(Value::as_str) { + c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); + c.raw = None; + } +} + fn chart_options(s: &mut Session, p: &Value) -> Result { // Histogram bins: a count or a width (setting one clears the other; null for automatic). let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); @@ -1772,33 +1805,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { let subtotals: Option> = p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { - if let Some(n) = bin_count { - c.bin_count = n; - if n.is_some() { - c.bin_size = None; - } - } - if let Some(w) = bin_size { - c.bin_size = w; - if w.is_some() { - c.bin_count = None; - } - } - if let Some(t) = &subtotals { - let n = c.series.first().map_or(0, |s| s.values.len()); - let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); - t.sort_unstable(); - t.dedup(); - c.subtotals = t; - } - if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { - c.up_down_bars = b; - c.raw = None; - } - if let Some(q) = p.get("quartiles").and_then(Value::as_str) { - c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); - c.raw = None; - } + apply_bin_options(c, bin_count, bin_size); + apply_subtotals_and_bars(c, p, subtotals.as_deref()); if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { c.raw = None; } diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index 777c479c..8ab3b3c3 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result { if doc.sections.is_empty() { // The slides before `at` go into a "Default Section". if at > 0 { - doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() }); + doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() }); } - doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() }); + doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() }); } else { let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) }; let si = doc.section_of(start).unwrap_or(0); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c681ac76..daa6c4c2 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -10,7 +10,7 @@ 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}; pub fn specs() -> Vec { vec![ @@ -66,6 +66,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 +135,235 @@ 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)) +} + +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 walk is what bounds the surrounding query: it never looks further than `limit`, so a +/// megabyte body costs no more than a short one. +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 never visits the rest of the body. +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))?; @@ -841,7 +1094,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, @@ -1060,6 +1315,89 @@ 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)) +} + +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(); + // Where the deletion before `start` begins decides how the selection moves: within the start + // paragraph its characters shift; from an earlier paragraph those paragraphs merge, so later + // paragraph numbers shift too. + let (from_para, from_char) = from; + let remap = move |pos: (usize, usize)| -> (usize, usize) { + if before_chars == 0 || pos < start { + return pos; + } + if from_para == 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_para + (pos.0 - start.0), if pos.0 == start.0 { from_char + (pos.1 - start.1) } else { pos.1 }) + } + }; + 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(t.anchor); + t.caret = remap(t.caret); + 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_para == 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 }); + } + Ok(json!({"deleted": deleted})) +} + fn delete(s: &mut Session, p: &Value) -> Result { delete_dir(s, str_param(p, "dir").unwrap_or("backward")) } @@ -1475,6 +1813,207 @@ mod rtl_promote_tests { } } +#[cfg(test)] +mod ime_surrounding_tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use serde_json::json; + + use crate::Session; + + 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 + } + + 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() + } + + 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/guard.rs b/crates/engine/src/guard.rs index 87d1fa7c..1c2ceac9 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session}; pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { payload .downcast_ref::<&str>() - .map(|s| s.to_string()) + .map(ToString::to_string) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "unknown panic".into()) } diff --git a/crates/engine/src/icons.rs b/crates/engine/src/icons.rs index 89c72180..bdd46289 100644 --- a/crates/engine/src/icons.rs +++ b/crates/engine/src/icons.rs @@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 { } fn parsed() -> &'static HashMap<&'static str, BezPath> { - static CACHE: OnceLock> = OnceLock::new(); + static CACHE: OnceLock> = OnceLock::new(); CACHE.get_or_init(|| { let mut m = HashMap::new(); for icon in ICONS { diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index 38b5aa71..9ed1bc9f 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); @@ -247,6 +247,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 54380bd1..3aa837c6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -482,6 +482,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 { @@ -513,6 +515,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`]); @@ -590,7 +593,7 @@ impl Session { 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()); @@ -660,6 +663,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 Some(st) = self.active_mut() else { return }; let Some(c) = st.composition.take() else { return }; @@ -668,6 +674,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()?; @@ -683,7 +710,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/recovery.rs b/crates/engine/src/recovery.rs index 646a1434..ac35cb13 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -152,13 +152,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> { let Ok(rd) = std::fs::read_dir(dir) else { return out }; for e in rd.flatten() { let p = e.path(); - if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") { + if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") { continue; } if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) { continue; } - let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::().ok()) else { continue }; + let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::().ok()) else { continue }; // Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none. let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES) .and_then(|t| serde_json::from_slice::(&t).ok()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 35b0ab39..293f08db 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3233,6 +3233,32 @@ 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; + assert!(metric_pair || option_env!("CRAFT_FONTS_REQUIRED").is_none(), "required BIZ Regular/Bold pair is absent"); + 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(); @@ -3257,7 +3283,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 aa72b128..9db71ac9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -451,7 +451,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..b9689941 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -1,27 +1,39 @@ //! 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 +//! `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 and BIZ UDPGothic Regular, to stay within the web size budget. 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"]; + +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") +} 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,22 +43,54 @@ fn main() { } /// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { +fn input_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(String::new()), + } +} + +fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> 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, &latin, wasm)?); + } + out.push_str(&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) => out.push_str(&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())); + } } - out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?); Ok(out) } -fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result { +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(); 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(); @@ -54,7 +98,7 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result Result 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", + ) + .unwrap(); + Self(path) + } + + fn optional(&self, text: &str) { + std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap(); + } + } + + impl Drop for InputFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[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(), ""); + } + + #[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()); + } + + #[test] + fn native_opt_in_does_not_change_wasm_embedded_bytes() { + let fixture = InputFixture::new(); + let before = input_fonts(Some(&fixture.0), true, true).unwrap(); + fixture.optional("LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid"); + 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); + } + + #[test] + fn required_native_input_requires_the_chinese_manifest() { + let fixture = InputFixture::new(); + 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()); + } + + #[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(); + let mut rows = String::new(); + 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"); + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 03267384..751f36ca 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace); impl FaceRef { /// The handle for a loaded face (memoised per face). pub fn of(face: &Arc) -> FaceRef { - static LEAKED: RwLock>> = RwLock::new(Vec::new()); + static LEAKED: RwLock>> = RwLock::new(Vec::new()); let id = face.id as usize; - if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { + if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) { return FaceRef(f); } - let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner()); + let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner); if w.len() <= id { w.resize(id + 1, None); } @@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str { } fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect() } /// Weight implied by a style name. @@ -807,7 +807,7 @@ impl FontDb { pub fn set_system_fallback(&self, on: bool) { #[cfg(not(target_arch = "wasm32"))] { - self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on; + self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on; } #[cfg(target_arch = "wasm32")] let _ = on; @@ -822,14 +822,14 @@ impl FontDb { } pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { - self.faces.read().unwrap_or_else(|e| e.into_inner()) + self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner) } /// The system font catalog, scanned first if it hasn't been yet. #[cfg(not(target_arch = "wasm32"))] fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec> { self.ensure_catalog(); - self.catalog.read().unwrap_or_else(|e| e.into_inner()) + self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner) } /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. @@ -885,7 +885,7 @@ impl FontDb { } } } - let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner); let mut added = 0; for f in new { // Checked again under the lock: another thread may have added the same face. @@ -923,7 +923,7 @@ impl FontDb { #[cfg(not(target_arch = "wasm32"))] pub fn load_system_fonts(&self) -> usize { // Families load again from the new catalog when next looked up. - self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear(); + self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear(); // The first scan, or a rescan once it is done (never both at once). let mut first = None; self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); @@ -959,7 +959,7 @@ impl FontDb { } continue; } - let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); + let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase); if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } @@ -975,7 +975,7 @@ impl FontDb { } let n = found.len(); log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + *self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found; n } @@ -983,7 +983,7 @@ impl FontDb { /// it is loaded. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { - let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner); self.load_family(family, &loading) } @@ -1047,7 +1047,7 @@ impl FontDb { // them rather than finding none and falling back to another family. #[cfg(not(target_arch = "wasm32"))] { - let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()); + let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let key = family.to_lowercase(); if !loaded.contains(&key) { self.load_cataloged(family); @@ -1112,7 +1112,7 @@ impl FontDb { } let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?; let f = Arc::new(f); - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone()); + self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone()); Some(f) } @@ -1214,20 +1214,20 @@ impl FontDb { } let seen = self.read_faces().len(); { - let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !sys.enabled || sys.misses.contains(&c) { return false; } } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); - let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); + let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner); // Another thread may have loaded a face for `c` (faces are only ever added), or found // there is none, while this one waited. if self.read_faces().len() != seen && covered(self) { return true; } - if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) { + if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) { return false; } for fam in SYSTEM_FALLBACKS { @@ -1238,7 +1238,8 @@ impl FontDb { } } } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = + self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect(); paths.sort(); paths.dedup(); for p in paths { @@ -1253,7 +1254,7 @@ impl FontDb { } } if remember_miss { - self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c); } false } @@ -1326,10 +1327,10 @@ impl FontDb { if c.is_control() || c.is_whitespace() { return false; } - if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled { + if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled { return false; } - if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) { + if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) { return false; } let mut paths: Vec = self.read_catalog().iter().map(|e| e.path.clone()).collect(); @@ -1347,7 +1348,7 @@ impl FontDb { return true; } } - MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c); + MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c); false } @@ -1359,12 +1360,12 @@ impl FontDb { return false; } { - let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if !sys.enabled { return false; } } - if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) { + if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) { return false; } let mut paths: Vec = self.read_catalog().iter().map(|e| e.path.clone()).collect(); @@ -1382,14 +1383,14 @@ impl FontDb { return true; } } - MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c); + MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c); false } /// Glyph outline in font units, y-down (flipped), cached per (face, glyph). pub fn outline(&self, face: &FontFace, gid: u32) -> Arc { let key = (face.id, gid); - if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { return p.clone(); } let mut pen = FlipPen(BezPath::new()); @@ -1399,7 +1400,7 @@ impl FontDb { let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen); } let p = Arc::new(pen.0); - let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner()); + let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if cache.len() >= OUTLINE_CACHE_MAX { cache.clear(); } 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/format/src/lib.rs b/crates/format/src/lib.rs index 4512b445..73712d28 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1070,33 +1070,33 @@ mod tests { assert_eq!(md_list_item(not), None, "{not:?}"); } } -} -/// #814: slides_to_outline includes table cell text, which it used to skip. -#[test] -fn outline_includes_table_text() { - use deckcraft_model::table::{Cell, Row, Table}; - use deckcraft_model::{Shape, ShapeKind, Xfrm}; - let mut p = defaults::new_presentation(None); - let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); - let mut s = defaults::new_slide(&mut p, layout).expect("slide"); - s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); - let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; - body.text = Some(TextBody::from_text("Text box body")); - s.shapes.push(body); + /// #814: slides_to_outline includes table cell text, which it used to skip. + #[test] + fn outline_includes_table_text() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; + body.text = Some(TextBody::from_text("Text box body")); + s.shapes.push(body); - let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; - for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { - let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); - table.rows.push(Row { height: 30.0, cells }); + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + assert!(out.contains("Text box body"), "{out}"); + assert!(out.contains("Cell A1\tCell B1"), "{out}"); + assert!(out.contains("Cell A2\tCell B2"), "{out}"); } - let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; - tbl.kind = ShapeKind::Table(table); - s.shapes.push(tbl); - p.slides = vec![std::sync::Arc::new(s)]; - - let out = slides_to_outline(&p); - assert!(out.contains("Text box body"), "{out}"); - assert!(out.contains("Cell A1\tCell B1"), "{out}"); - assert!(out.contains("Cell A2\tCell B2"), "{out}"); } diff --git a/crates/media/src/player.rs b/crates/media/src/player.rs index dbb9d2c0..3c972f2c 100644 --- a/crates/media/src/player.rs +++ b/crates/media/src/player.rs @@ -254,7 +254,7 @@ impl Player { } fn with(&self, f: impl FnOnce(&mut Mixer) -> R) -> R { - let mut g = self.mixer.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = self.mixer.lock().unwrap_or_else(std::sync::PoisonError::into_inner); f(&mut g) } diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 550c2d18..3324f5f9 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -1187,7 +1187,7 @@ fn image(r: Bytes) -> Option { fn metafile(r: Bytes) -> Option { let size = r.u32(12)? as usize; let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?; - let depth = NESTING.with(|n| n.get()); + let depth = NESTING.with(std::cell::Cell::get); if depth >= MAX_NESTING { return None; } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index fb7dfe36..09e76413 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1716,7 +1716,7 @@ fn plus_pic(recs: Vec>) -> Picture { } fn u16s(s: &str) -> Vec { - s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() + s.encode_utf16().flat_map(u16::to_le_bytes).collect() } /// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white. diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5ff72cf4..cf31e5b6 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -1,1469 +1,1499 @@ -//! 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 { - /// The sample data a new chart starts with (our own numbers). - pub fn sample(kind: ChartType) -> Self { - 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(|s| s.to_string()).collect(), ..Default::default() }; - if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { - // A two-level hierarchy: months inside quarters. - c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); - c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; - 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.vary_colors = true; - return c; - } - if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - // These plot raw observations: a histogram bins one series, a box chart summarises - // each category's points. - 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 }; - c.categories = if kind == ChartType::BoxWhisker { - (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() - } else { - vec![] - }; - 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.gridlines = true; - c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); - return c; - } - if kind == ChartType::Stock { - // High-low-close over five days, the series in the order PowerPoint expects. - c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); - 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() }); - } - return c; - } - 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 n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); - let mut percent = false; - for i in 0..n { - 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); - } - } - 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()) - } - - /// Value range across series (stacked sums for stacked types). - pub fn value_range(&self) -> (f64, f64) { - let mut lo: f64 = 0.0; - let mut hi: f64 = 0.0; - if self.kind == ChartType::Waterfall { - for (a, b) in self.waterfall_bars() { - lo = lo.min(a.min(b)); - hi = hi.max(a.max(b)); - } - return (lo, hi); - } - let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); - if self.kind.stacked() { - 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); - } - } else { - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - hi = hi.max(*v); - lo = lo.min(*v); - } - } - } - } - 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; - 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); - if hi - lo < 1e-12 { - // 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(1.0) }, - (Some(_), None) if lo < 0.0 => hi = 0.0, - _ => hi = lo + lo.abs().max(1.0), - } - } - 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()); - 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 span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; - 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; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; - (min, max, step) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[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))); - } - - #[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 n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0); + let mut percent = false; + for i in 0..n { + 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); + } + } + 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()) + } + + 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; + 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); + if hi - lo < 1e-12 { + // 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(1.0) }, + (Some(_), None) if lo < 0.0 => hi = 0.0, + _ => hi = lo + lo.abs().max(1.0), + } + } + 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()); + 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 span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; + 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; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() < 1e-12 { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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))); + } + + #[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/model/src/lib.rs b/crates/model/src/lib.rs index dd5d54e1..1289e47a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -1066,13 +1066,13 @@ impl Presentation { self.slides.iter().position(|s| s.id == id) } pub fn slide(&self, id: SlideId) -> Option<&Slide> { - self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref()) + self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref) } pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> { self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut) } pub fn slide_at(&self, i: usize) -> Option<&Slide> { - self.slides.get(i).map(|s| s.as_ref()) + self.slides.get(i).map(AsRef::as_ref) } /// Master and layout for a layout id. pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> { @@ -1086,7 +1086,7 @@ impl Presentation { self.masters.first().map(|m| (m.as_ref(), m.layouts.first())) } pub fn first_master(&self) -> Option<&Master> { - self.masters.first().map(|m| m.as_ref()) + self.masters.first().map(AsRef::as_ref) } pub fn media(&self, id: MediaId) -> Option<&MediaItem> { self.media.iter().find(|m| m.id == id) diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index b067c904..83c494bd 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -275,7 +275,7 @@ pub enum Dash { } impl Dash { - pub const PRESETS: [&'static str; 11] = + pub const PRESETS: [&str; 11] = ["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"]; /// Dash/space lengths in multiples of the line width. pub fn pattern(&self) -> Vec { diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 4129be4b..4b814129 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -530,7 +530,7 @@ impl Paragraph { true } pub fn text(&self) -> String { - self.runs.iter().map(|r| r.content()).collect() + self.runs.iter().map(Run::content).collect() } pub fn char_len(&self) -> usize { self.runs.iter().map(Run::char_len).sum() @@ -576,7 +576,7 @@ pub struct ListStyle { impl ListStyle { pub fn level(&self, l: u8) -> Option<&LevelStyle> { - self.levels.get(l as usize).and_then(|x| x.as_ref()) + self.levels.get(l as usize).and_then(Option::as_ref) } pub fn set(&mut self, l: u8, s: LevelStyle) { let i = (l as usize).min(8); diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs index 05d792da..5ba71ff4 100644 --- a/crates/model/src/trendline.rs +++ b/crates/model/src/trendline.rs @@ -57,22 +57,7 @@ impl Trendline { let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); (Model::Poly(coeff), ys, yhat) } - TrendlineType::Exp => { - let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect(); - if pairs.len() < 2 { - return None; - } - let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); - let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); - let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; - let c = ln_c.exp(); - if !c.is_finite() || c <= 0.0 { - return None; - } - let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); - let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); - (Model::Exp { c, b }, ys, yhat) - } + TrendlineType::Exp => fit_exp(&pairs, intercept)?, TrendlineType::Log => { let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); if pairs.len() < 2 { @@ -83,43 +68,10 @@ impl Trendline { let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); (Model::Log { b, c }, ys, yhat) } - TrendlineType::Power => { - let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); - if pairs.len() < 2 { - return None; - } - let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); - let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); - let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; - let c = ln_c.exp(); - if !c.is_finite() || c <= 0.0 { - return None; - } - let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); - let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); - (Model::Power { c, b }, ys, yhat) - } + TrendlineType::Power => fit_power(&pairs, intercept)?, TrendlineType::MovingAvg => { let period = self.period.unwrap_or(2).clamp(2, 255) as usize; - if pairs.len() <= period { - return None; - } - let mut pts = Vec::new(); - let mut ys = Vec::new(); - let mut yhat = Vec::new(); - for i in period - 1..pairs.len() { - let window = &pairs[i + 1 - period..=i]; - let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; - if avg.is_finite() { - pts.push((pairs[i].0, avg)); - ys.push(pairs[i].1); - yhat.push(avg); - } - } - if pts.len() < 2 { - return None; - } - (Model::Moving(pts), ys, yhat) + fit_moving_avg(&pairs, period)? } }; if yhat.iter().any(|v| !v.is_finite()) { @@ -325,6 +277,89 @@ fn poly_y(coeff: &[f64], x: f64) -> f64 { acc } +fn fit_exp(pairs: &[(f64, f64)], intercept: Option) -> Option<(Model, Vec, Vec)> { + let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + Some((Model::Exp { c, b }, ys, yhat)) +} + +fn fit_power(pairs: &[(f64, f64)], intercept: Option) -> Option<(Model, Vec, Vec)> { + let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + Some((Model::Power { c, b }, ys, yhat)) +} + +fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec, Vec)> { + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + Some((Model::Moving(pts), ys, yhat)) +} + +#[allow(clippy::needless_range_loop)] +fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option> { + let m = order; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (x, y) in xs.iter().zip(ys) { + let dy = y - b0; + for i in 0..m { + let xi = x.powi((i + 1) as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * x.powi((j + 1) as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let solved = solve(ata, atb)?; + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(solved); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + /// Polynomial through the normal equations. `x` is centred when the intercept is free, then /// expanded back to powers of `x`, so order 6 on category numbers stays solvable. #[allow(clippy::needless_range_loop)] // the index is the power in the normal equations @@ -334,29 +369,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Opti return None; } if let Some(b0) = intercept { - let m = order; - let mut ata = vec![vec![0.0; m]; m]; - let mut atb = vec![0.0; m]; - for (x, y) in xs.iter().zip(ys) { - let dy = y - b0; - for i in 0..m { - let xi = x.powi((i + 1) as i32); - atb[i] += dy * xi; - for j in i..m { - ata[i][j] += xi * x.powi((j + 1) as i32); - } - } - } - for i in 0..m { - for j in 0..i { - ata[i][j] = ata[j][i]; - } - } - let solved = solve(ata, atb)?; - let mut coeff = Vec::with_capacity(order + 1); - coeff.push(b0); - coeff.extend(solved); - return coeff.iter().all(|v| v.is_finite()).then_some(coeff); + return polyfit_with_intercept(xs, ys, order, b0); } let xm = xs.iter().sum::() / n as f64; let us: Vec = xs.iter().map(|x| x - xm).collect(); diff --git a/crates/opus/src/celt/bands.rs b/crates/opus/src/celt/bands.rs index 74f19092..6d0bd3f0 100644 --- a/crates/opus/src/celt/bands.rs +++ b/crates/opus/src/celt/bands.rs @@ -71,7 +71,7 @@ fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) { x[i + stride] = c * x2 + s * x1; x[i] = c * x1 + ms * x2; } - if len >= 2 * stride + 1 { + if len > 2 * stride { for i in (0..=len - 2 * stride - 1).rev() { let x1 = x[i]; let x2 = x[i + stride]; diff --git a/crates/opus/src/celt/mod.rs b/crates/opus/src/celt/mod.rs index 7110f5fb..3f68641c 100644 --- a/crates/opus/src/celt/mod.rs +++ b/crates/opus/src/celt/mod.rs @@ -578,7 +578,7 @@ fn tf_decode(start: usize, end: usize, is_transient: bool, tf_res: &mut [i32; NB let mut budget = dec.storage() as i32 * 8; let mut tell = dec.tell(); let mut logp: u32 = if is_transient { 2 } else { 4 }; - let tf_select_rsv = lm > 0 && tell + logp as i32 + 1 <= budget; + let tf_select_rsv = lm > 0 && tell + (logp as i32) < budget; budget -= tf_select_rsv as i32; let mut tf_changed = 0; let mut curr = 0; diff --git a/crates/opus/src/decoder.rs b/crates/opus/src/decoder.rs index 573d5366..a9cdee29 100644 --- a/crates/opus/src/decoder.rs +++ b/crates/opus/src/decoder.rs @@ -217,7 +217,7 @@ impl StreamDecoder { } } } - let data_len = data.map_or(0, |d| d.len()); + let data_len = data.map_or(0, <[_]>::len); let mut dec_storage; let mut dec: Option<&mut RangeDecoder> = match data { Some(d) => { diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 03a373ac..604fd511 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -29,4 +29,4 @@ wasm-bindgen-test = "0.3" # slide renders (the same bytes, in parallel). The root patch selects the same pinned # backend as craft-pdf; this dependency only enables its native compression feature. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -krilla = { version = "=0.8.2", features = ["rayon"] } +krilla = { version = "=0.8.2", default-features = false, features = ["rayon"] } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 066df561..40d09c71 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -131,6 +131,33 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { write::guard(|| export_inner(pres, opts)) } +fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec> { + match opts.layout { + PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), + PageLayout::Notes => { + let b = notes_frames(pres, opts.paper).0; + slides.iter().map(|&i| vec![(i, b)]).collect() + } + PageLayout::Handouts { per_page } => { + let boxes = handout_boxes(per_page, opts.paper, sw / sh); + slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect() + } + } +} + +fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline { + let mut outline = Outline::new(); + for (p, v) in pages.iter().enumerate() { + for (i, _) in v { + let t = pres.slides[*i].title(); + let t = t.lines().next().unwrap_or("").trim().to_string(); + let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) }; + outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0)))); + } + } + outline +} + fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let slides: Vec = match &opts.slides { Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(), @@ -153,29 +180,10 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; // Page plan: per page, the slides on it with their boxes (page points). - let pages: Vec> = match opts.layout { - PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), - PageLayout::Notes => { - let b = notes_frames(pres, opts.paper).0; - slides.iter().map(|&i| vec![(i, b)]).collect() - } - PageLayout::Handouts { per_page } => { - let boxes = handout_boxes(per_page, opts.paper, sw / sh); - slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect() - } - }; + let pages = plan_pages(pres, opts, &slides, sw, sh); // Bookmarks and slide → page lookup for internal links. let page_of: HashMap = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect(); - let mut outline = Outline::new(); - for (p, v) in pages.iter().enumerate() { - for (i, _) in v { - let t = pres.slides[*i].title(); - let t = t.lines().next().unwrap_or("").trim().to_string(); - let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) }; - outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0)))); - } - } - pdf.set_outline(outline); + pdf.set_outline(build_outline(pres, &pages)); for v in &pages { let (pw, ph) = match opts.layout { diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 9bd6c421..135603b6 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -76,7 +76,7 @@ impl Anim { static CACHE: Mutex> = Mutex::new(None); fn with(f: impl FnOnce(&mut Cache) -> R) -> R { - let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); f(g.get_or_insert_with(Cache::default)) } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b78b96ea..6b17d004 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1090,7 +1090,7 @@ fn shape_item( for g in &glyphs { let Some(&h) = char_at_byte.get(&g.cluster) else { continue }; let Some(cell) = cells.get_mut(h) else { continue }; - if matches!(cell.ch, '\t' | '\u{b}' | '\n') { + if matches!(cell.ch, '\t'..='\u{b}') { continue; } let s = scale_of(cell); @@ -1738,7 +1738,7 @@ fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) { let mut w = 0.0; let mut dec = None; for c in cells.iter().skip(i + 1) { - if matches!(c.ch, '\t' | '\u{b}' | '\n') { + if matches!(c.ch, '\t'..='\u{b}') { break; } if c.ch == '.' && dec.is_none() { 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 999900d3..ba0e4207 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,7 @@ pub struct Backstage { } impl Backstage { - pub fn new(page: Page, docs: (usize, Option)) -> Backstage { + pub fn new(page: Page, docs: (usize, Option)) -> Backstage { Backstage { page, docs, @@ -336,7 +336,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); @@ -439,7 +439,7 @@ fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> 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() { @@ -493,7 +493,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); @@ -597,7 +597,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); @@ -623,7 +623,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); } @@ -641,7 +641,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); @@ -656,7 +656,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); @@ -934,13 +934,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))); @@ -1055,7 +1060,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")] { @@ -1065,6 +1070,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:"); @@ -1077,7 +1083,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; @@ -1085,27 +1091,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() { @@ -1231,7 +1248,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ 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); } @@ -1278,7 +1295,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| { @@ -1288,6 +1305,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})); @@ -1299,7 +1317,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); @@ -1349,13 +1367,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/canvas.rs b/crates/ui-egui/src/canvas.rs index 06cd1910..b597158f 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; @@ -144,7 +150,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 +166,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 +247,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 +460,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 +490,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 +657,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 +725,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), @@ -1513,7 +1522,13 @@ mod tests { } 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..523d13f7 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,983 @@ +//! 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 { + 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 + } + + 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, + } + } + + 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 { + pub fn is_full(&self) -> bool { + self.requests.len() >= MAX_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 { + 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::*; + + fn image(w: usize, h: usize) -> Arc { + Arc::new(ColorImage::new([w, h], vec![egui::Color32::RED; w * h])) + } + + 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) + } + + #[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"); + } + + #[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(_))); + } + + #[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"); + } + + #[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); + } + + #[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()); + } + + #[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); + } + + #[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); + } + + #[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..39de9a5b 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; @@ -173,8 +174,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 +198,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 +214,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 +222,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 +236,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 +246,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 +347,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, @@ -411,9 +412,34 @@ 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 +458,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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 831da01f..99288d58 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -80,7 +80,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { 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)) @@ -183,8 +183,8 @@ fn restore_preferences(app: &mut SlideApp, d: &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); } @@ -286,7 +286,7 @@ 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)); + opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label)); } const ALBUM_THEMES: &[&str] = &["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]; @@ -565,7 +565,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 }); @@ -613,7 +613,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 }); @@ -671,7 +671,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 }); @@ -724,7 +724,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 }); @@ -959,8 +959,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(); } @@ -1054,9 +1054,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()); @@ -1065,6 +1065,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)); @@ -1183,9 +1184,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()); } @@ -1195,6 +1196,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"); @@ -1273,7 +1275,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() { @@ -1342,8 +1344,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 { @@ -1352,6 +1354,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() { @@ -1431,7 +1434,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 = [ @@ -1447,13 +1451,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); @@ -1517,7 +1522,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"); @@ -1529,7 +1534,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 { @@ -1574,8 +1579,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)", @@ -1602,6 +1607,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() { @@ -1658,12 +1664,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| { @@ -1727,7 +1734,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() { @@ -1946,7 +1953,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() { @@ -2020,14 +2030,14 @@ 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(); } }); ui.horizontal(|ui| { if ui.button("+ Row").clicked() { - let n = grid.first().map(|r| r.len()).unwrap_or(2); + let n = grid.first().map(Vec::len).unwrap_or(2); grid.push(vec![String::new(); n]); } if ui.button("+ Series").clicked() { @@ -2085,7 +2095,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 { @@ -2126,10 +2136,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(); @@ -2173,7 +2183,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, scale as f64, "Interface size")); if r.is_pointer_button_down_on() { if r.changed() || pending.is_some() { d.fields.insert("uiScale".into(), scale.to_string()); @@ -2198,8 +2211,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 { @@ -2424,8 +2439,8 @@ fn size_position_value(d: &Dialog) -> Result { 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); } @@ -2528,7 +2543,7 @@ 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)); + opened.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, lang_label)); }); ui.label( egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") @@ -2742,16 +2757,17 @@ 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)]) { +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 { @@ -2776,10 +2792,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() { @@ -2787,8 +2804,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); @@ -2802,6 +2819,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")], ); @@ -2810,21 +2828,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", @@ -2841,6 +2859,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); @@ -2943,7 +2962,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value { let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0); let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", ""); let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", ""); - let v = json!({ + json!({ "align": d.get("align", "left"), "indentLeft": before, "indentRight": after, @@ -2957,8 +2976,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value { "lineValue": line_value, "tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])), "defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)), - }); - v + }) } fn line_preset_label(kind: &str, value: &str) -> &'static str { @@ -2984,6 +3002,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui, d, "paragraph_align", + "Alignment", "align", &[ ("left", "Left"), @@ -3005,7 +3024,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"); @@ -3020,10 +3039,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")), @@ -3036,6 +3055,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); }); @@ -3162,9 +3182,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"); @@ -3322,14 +3342,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..650c0fc3 100644 --- a/crates/ui-egui/src/eyedropper_tests.rs +++ b/crates/ui-egui/src/eyedropper_tests.rs @@ -27,7 +27,7 @@ fn harness() -> Harness<'static, SlideApp> { } 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 584d309a..a2aaa107 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -106,7 +106,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. @@ -167,8 +167,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 { @@ -185,6 +185,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")); @@ -220,15 +221,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)]; @@ -303,10 +306,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)); } 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..703eb875 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -45,7 +45,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 +61,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 +94,214 @@ 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"); + + // A real new composition after the published interrupt is accepted. + h.event(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"); + + // A direct commit with no pre-edit (a real IME's punctuation/full-width character path) + // arrives after the boundary and is accepted. + h.event(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; + 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 +327,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 +337,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()))); @@ -183,18 +386,69 @@ 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] { + 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 initially painted {text:?} as boxes"); + } + } + } 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; } @@ -204,14 +458,583 @@ fn cjk_text_in_the_interface_is_drawn_with_real_glyphs() { } 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() +} + +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) +} + +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"); +} + +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 } + }) +} + +#[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 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())), preedit("Y")]); + h.run_steps(1); + // Old terminal is refused, then the new composition in that same batch is valid. + 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)); + } + } +} + +#[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); + 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.event(Event::Ime(ImeEvent::Commit("Y".into()))); + h.run_steps(1); + assert_eq!(title_text(h.state()), "abcYd", "the later direct commit is new input"); + } +} + +#[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(3); + 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.event(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"); + } +} + +#[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()); + } +} + +#[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"); +} 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 1410c7f8..c1d3540a 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}; @@ -434,6 +435,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, @@ -465,7 +500,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. @@ -484,14 +549,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, @@ -521,6 +585,70 @@ 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, +} + +/// 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, } fn grid_options_touched(p: &Value) -> bool { @@ -559,6 +687,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, @@ -569,9 +709,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, @@ -591,6 +729,16 @@ 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, } } @@ -682,8 +830,70 @@ impl SlideApp { r } - pub(crate) fn docs_key(&self) -> (usize, Option) { - (self.session.documents().len(), self.session.active_index()) + 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 @@ -1101,7 +1311,10 @@ impl SlideApp { 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); } } @@ -1568,42 +1781,73 @@ 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.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); @@ -1612,8 +1856,42 @@ 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); + } + + /// 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!({})) @@ -1621,17 +1899,41 @@ 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)); + } + } + + /// Record the settled UI geometry and issue due screenshots. Call once at the end of a real + /// pass, after layout, not from `logic`/`run_logic`. + fn ui_end(&mut self, ctx: &egui::Context) { + self.observe_ime(ctx); + 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 @@ -1660,40 +1962,577 @@ impl SlideApp { self.ui_fonts.apply(ctx); } + /// 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 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); + } + } + + /// 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; + } + } + + /// 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) { + let settles = self.session.composition_settles(); + let owner = self.ime_target().map(|t| t.owner); + let state = self.session.active().and_then(|st| st.selection.text.as_ref().map(|t| (st.revision, t.anchor, t.caret))); + let pending_interrupted = self + .ime_terminal_pending + .as_ref() + .is_some_and(|p| !p.cancelled && (owner != Some(p.owner) || state != Some((p.revision, p.anchor, p.caret)))); + if settles == self.ime_settles_seen && !pending_interrupted { + return; + } + self.ime_settles_seen = settles; + if let Some(pending) = &mut self.ime_terminal_pending { + pending.cancelled = true; + } + self.ime_interrupt_pending = true; + 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. - fn ime_event(&mut self, e: &egui::ImeEvent) { + /// + /// 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(); + 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 { .. }); + // 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. + 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 { + 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; + } + 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; + } + 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()); + return; + } + let before = if accepted { self.ime_receipt_stamp() } else { None }; 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), + // 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::Disabled | egui::ImeEvent::Enabled => { + // The platform ended (or is about to restart) its composition itself. self.session.end_composition(); Ok(Value::Null) } - _ => Ok(Value::Null), }; - if let Err(e) = r { + 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, r.is_ok(), 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 }); + } + } + _ => { + self.ime_composition = None; + self.ime_terminal_pending = None; + } + } + } + _ => {} + } + 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); } } - /// Inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { + /// Observe the canvas IME owner once per pass and publish its descriptor. + /// + /// No canvas target, a focused egui field, a modal, the palette or the Backstage view 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) { + let viewport = self.ime_frame_viewport.unwrap_or_else(|| ctx.viewport_id()); + self.note_settled_composition(); + // 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); + } + self.ime_composition = None; + self.ime_cancelled = None; return; } + 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; + }; + let Some(st) = self.session.active() else { + self.ime_interrupt_pending = false; + self.ime_tracker.observe(viewport, None); + return; + }; + let Some(target) = self.ime_target() else { + self.ime_interrupt_pending = false; + self.ime_cancelled = None; + self.ime_tracker.observe(viewport, None); + return; + }; + if self.ime_viewport != Some(viewport) + && let Some(old) = self.ime_viewport.replace(viewport) + { + self.ime_tracker.end_viewport(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 Some(update) = self.ime_tracker.observe(viewport, Some(observation)) else { return }; + // 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; + } + } + 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, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + } + + /// 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; } /// Keyboard and clipboard for the slide (when no text field has focus). @@ -1703,8 +2542,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. @@ -1748,14 +2592,27 @@ impl SlideApp { } for e in events { match e { + 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) => { @@ -1764,7 +2621,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 @@ -1780,6 +2640,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 { @@ -1797,10 +2660,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); + } } _ => {} } @@ -1829,13 +2696,20 @@ impl SlideApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - if !self.fonts_ready { - ctx.request_repaint(); + 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(); + self.observe_ime(&ctx); 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() { @@ -1843,6 +2717,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(); @@ -1900,11 +2775,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() { @@ -1925,7 +2802,10 @@ 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) { @@ -1940,69 +2820,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. @@ -2030,16 +2877,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`, @@ -2070,11 +2941,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)] @@ -2096,6 +2962,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; @@ -3080,7 +3948,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)))); @@ -3337,6 +4205,7 @@ mod tests { unit: egui::MouseWheelUnit::Point, delta: step, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Trackpad, modifiers: egui::Modifiers::NONE, }); } @@ -3360,6 +4229,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, }); } @@ -3478,6 +4348,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); @@ -3610,4 +4481,727 @@ 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() { + 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() { + 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..3f5c41f3 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)); @@ -184,7 +197,7 @@ mod tests { 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 bd712bb9..ba5f7ff8 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(); @@ -327,7 +327,7 @@ 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| { @@ -355,10 +355,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})); } @@ -480,13 +480,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { None => ("shape.fill".into(), json!({"none": true})), }); ui.horizontal(|ui| { - 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 = solid_fill_transparency_percent(sh.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})); @@ -550,15 +550,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); }); } @@ -622,11 +623,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 { @@ -653,9 +654,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}})); } @@ -695,24 +696,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; @@ -747,23 +749,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}})); } @@ -970,8 +973,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"), @@ -988,6 +991,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", @@ -996,14 +1000,15 @@ 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); }); } @@ -1020,7 +1025,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(); @@ -1094,17 +1099,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, @@ -1310,7 +1315,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() { @@ -1335,7 +1340,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)); @@ -1367,7 +1372,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() { @@ -1448,11 +1453,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)); @@ -1553,9 +1558,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:?}"); } } diff --git a/crates/ui-egui/src/paragraph_dialog_tests.rs b/crates/ui-egui/src/paragraph_dialog_tests.rs index fb8e796b..9b42cc73 100644 --- a/crates/ui-egui/src/paragraph_dialog_tests.rs +++ b/crates/ui-egui/src/paragraph_dialog_tests.rs @@ -66,21 +66,30 @@ fn tabs_from_paragraph_applies_a_stop_and_returns() { 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/replacefonts_tests.rs b/crates/ui-egui/src/replacefonts_tests.rs index 205c65aa..ad707773 100644 --- a/crates/ui-egui/src/replacefonts_tests.rs +++ b/crates/ui-egui/src/replacefonts_tests.rs @@ -15,7 +15,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()); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.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); @@ -38,10 +41,27 @@ 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); + } +} + #[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 c963b663..f3c93313 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,99 @@ 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() +} + 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 +330,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!({})); @@ -447,18 +508,22 @@ fn slides_group(app: &mut SlideApp, ui: &mut 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| { + if ui.button("Add Section").clicked() { + run(app, "section.add", json!({})); + } + if ui.button("Remove All Sections").clicked() { + run(app, "section.removeAll", json!({})); + } + }); + } }); }); } @@ -657,7 +722,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; @@ -692,7 +757,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!({})); } @@ -714,7 +779,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!({})); } @@ -949,9 +1014,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)); } }); }); @@ -1321,7 +1386,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() }; @@ -1349,11 +1414,43 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { if resp.clicked() && hover.0 > 0 { Some(hover) } else { None } } +fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) { + let mut cats: Vec<&str> = Vec::new(); + for icon in icons { + if !cats.contains(&icon.category) { + cats.push(icon.category); + } + } + for cat in cats { + ui.label(egui::RichText::new(cat).small().strong()); + ui.horizontal_wrapped(|ui| { + for icon in icons.iter().filter(|i| i.category == cat) { + icon_cell(app, ui, icon, color, hover); + } + }); + } +} + +fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) { + let mut n = 0; + ui.horizontal_wrapped(|ui| { + for icon in icons { + if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { + icon_cell(app, ui, icon, color, hover); + n += 1; + } + } + }); + if n == 0 { + ui.label("No icons match."); + } +} + /// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline. 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(); @@ -1361,33 +1458,9 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) { let icons = deckcraft_engine::icons::icons(); egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| { if q.is_empty() { - let mut cats: Vec<&str> = Vec::new(); - for icon in icons { - if !cats.contains(&icon.category) { - cats.push(icon.category); - } - } - for cat in cats { - ui.label(egui::RichText::new(cat).small().strong()); - ui.horizontal_wrapped(|ui| { - for icon in icons.iter().filter(|i| i.category == cat) { - icon_cell(app, ui, icon, color, hover); - } - }); - } + render_icon_categories(app, ui, icons, color, hover); } else { - let mut n = 0; - ui.horizontal_wrapped(|ui| { - for icon in icons { - if icon.name.contains(&q) || icon.label.to_lowercase().contains(&q) { - icon_cell(app, ui, icon, color, hover); - n += 1; - } - } - }); - if n == 0 { - ui.label("No icons match."); - } + render_filtered_icons(app, ui, icons, &q, color, hover); } }); } @@ -1537,7 +1610,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| { @@ -1564,7 +1637,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() { @@ -1578,7 +1651,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() { @@ -1718,14 +1791,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})); @@ -1754,6 +1827,7 @@ 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| { @@ -2472,17 +2546,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)})); } @@ -2492,9 +2566,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))})); } @@ -2548,17 +2622,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!({})); @@ -2681,9 +2761,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)); } }); }); @@ -3102,21 +3185,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})); } @@ -3133,47 +3218,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); } @@ -3539,16 +3632,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; @@ -3565,7 +3658,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")] { @@ -3622,7 +3715,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..4f46cfe6 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,13 @@ 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)) } 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); } @@ -157,7 +157,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 +170,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 +188,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 +219,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 90504d3c..d7315761 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,46 @@ 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 { 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 +357,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) + } + /// Pen and laser are different pointers: turning one on puts the other away. fn set_pen(&mut self, on: bool) { self.pen = on; @@ -674,10 +832,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; @@ -788,28 +967,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) { @@ -1083,7 +1243,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![]); @@ -2008,7 +2171,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) @@ -2034,7 +2197,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 @@ -2583,6 +2747,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); @@ -2712,4 +2904,48 @@ 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); } + + #[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..f52805d5 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; @@ -74,8 +74,8 @@ 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 +88,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 +200,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 +401,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 +453,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 +494,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 +520,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..084dc67b --- /dev/null +++ b/crates/ui-egui/src/split_button_tests.rs @@ -0,0 +1,129 @@ +//! 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) +} + +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"); + } +} + +#[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"); + } + } +} + +#[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…"); +} + +#[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… #"); +} + +#[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"); +} + +#[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..eebd9599 100644 --- a/crates/ui-egui/src/squiggles.rs +++ b/crates/ui-egui/src/squiggles.rs @@ -65,15 +65,7 @@ struct Done { } 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 +243,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 d5d03cdf..96d7f68f 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -22,9 +22,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); // 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 lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); - if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() { + if ui.interact(lr.expand(3.0), ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() { app.dialog = Some(crate::dialogs::Dialog::new("language")); } // Transient status message. @@ -52,7 +52,15 @@ 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 lz = z.ln(); - let s = right.add_sized(vec2(110.0, 18.0), egui::Slider::new(&mut lz, (0.1f32).ln()..=(4.0f32).ln()).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 lz, (0.1f32).ln()..=(4.0f32).ln()).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. + let enabled = s.enabled(); + s.widget_info(|| egui::WidgetInfo::slider(enabled, z as f64, "Zoom")); if s.changed() { z = lz.exp(); app.ui.zoom = Some(z); @@ -125,7 +133,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; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 1847d47f..e8f4f06a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -172,7 +172,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; } @@ -190,6 +197,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; @@ -234,10 +250,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(); @@ -245,7 +266,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)); } @@ -257,6 +282,155 @@ mod tests { out } + #[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")); + } + + #[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"); + } + } + + #[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"); + } + } + + #[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 thin_punctuation_is_drawn_in_interface_text() { // En and em dashes (thin bars) were hinted down to nothing in the Japanese interface face, diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs index 0dcfb057..59245b20 100644 --- a/crates/ui-egui/src/toast.rs +++ b/crates/ui-egui/src/toast.rs @@ -48,7 +48,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 +60,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..2cf551e3 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}; @@ -128,6 +128,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..afa494d0 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -18,11 +18,18 @@ 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 } +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 +88,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 +199,14 @@ mod tests { FontDb::global().face(family, "Regular") } + #[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..ea2aaad6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -31,28 +31,43 @@ 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; + +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 +77,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) } @@ -114,14 +130,30 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte } 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 +166,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 +190,53 @@ 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 +} + +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 +244,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 +266,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 +275,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 +346,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 +458,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… #"); @@ -425,12 +513,12 @@ pub fn gallery( /// 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 { 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!("{id} value")); if r.has_focus() { ui.data_mut(|d| d.insert_temp(key, text.clone())); } else { @@ -444,7 +532,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 {id}"))); 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 {id}"))); 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 +585,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 +603,151 @@ 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"); + + 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)); + + 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 +798,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..f57d8252 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1386 @@ +# DeckCraft: egui 0.37 migration plan + +> **Status:** implementation in progress, 2026-10-24. Source edits are in the dirty migration +> worktree; 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 `9aef21e627cb447dd2180cd804db85343e73e5cf` (dirty migration worktree). The historical +> planning baseline `b47195feec0f0fe4220d8943ad1c8add5dad8072` is 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. + +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 | implementation checkout `9aef21e627cb447dd2180cd804db85343e73e5cf` (dirty); historical planning baseline `b47195feec0f0fe4220d8943ad1c8add5dad8072` | +| `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).** The native Simplified-Chinese fallback is +currently qualified against a local, dirty `craft-fonts-implementation` worktree: its +`fonts/simplified-chinese-manifest.txt`, `fonts/lxgw-wenkai-lite/` files and `fonts/manifest.txt` +row are uncommitted. That is expected unpublished work and must not be committed or published from +the DeckCraft checkout; it does not block local builds and tests, which read that worktree through +`CRAFT_FONTS_DIR`. Before any publication or release claims the Chinese migration, commit the +reviewed manifest and face in `craft-fonts`, obtain an immutable full SHA, and update **all three** +pins 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: the +current shared pin remains `43913056ce82a3fdad0ef69f6b88d1dc922a8104`, whose tree has no Chinese +manifest. + +The required-input gate is explicit but cannot fire until that pin advances: + +- `ci.yml` runs the `Font pin gate` as a local preflight job before the shared gate. While the + pin is the known pending `43913056…` commit it emits a warning; any *other* pin without + `fonts/simplified-chinese-manifest.txt` fails the job, so advancing the pin cannot silently + keep the gate off. 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 manifest fails instead of shipping without Chinese fonts. +- 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.** `DeleteSurrounding` already exists in 0.36.2 and + is currently ignored; the custom slide-text path has no range-delete API today. E21b is a new + prerequisite: the candidate has no logical surrounding text in `IMEOutput` and no Wayland + delivery. Either implement and test both (including DeckCraft's `canvas::ime_output` model + adapter), or record the gap as blocking; do not claim E21/E21b support from event translation + 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. 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/linux/package.sh b/packaging/linux/package.sh index 7166924e..de047039 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -19,6 +19,12 @@ set -euo pipefail HERE="$ROOT/packaging/linux" APP_ID=ai.storyteller.deckcraft +verify_sha256() { + local expected="$1" + local file="$2" + printf '%s %s\n' "$expected" "$file" | sha256sum -c - +} + SKIP_BUILD=0 FORMATS="appimage deb rpm tar" while [[ $# -gt 0 ]]; do @@ -137,21 +143,21 @@ if has appimage; then download="$TOOL.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$download" chmod 755 "$download" mv "$download" "$TOOL" fi - printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c - + verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL" fi RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" if [[ ! -f "$RUNTIME" ]]; then download="$RUNTIME.download" curl --proto '=https' -fsSL -o "$download" \ "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download" mv "$download" "$RUNTIME" fi - printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c - + verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be # relative). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" diff --git a/packaging/windows/deckcraft.wxs b/packaging/windows/deckcraft.wxs index 43d256fe..e44b520c 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. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index f15f00e8..7b907aab 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,11 +90,25 @@ Copy-Item (Join-Path $Bin 'deckcraft.exe'), (Join-Path $Bin 'deckcraft-cli.exe') & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'deckcraft.exe') (Join-Path $Stage 'deckcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "deckcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'deckcraft.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\deckcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. diff --git a/xtask/src/assets.rs b/xtask/src/assets.rs index 6b37e557..a7169ee6 100644 --- a/xtask/src/assets.rs +++ b/xtask/src/assets.rs @@ -51,7 +51,7 @@ const ASSET_EXT: &[&str] = &[ const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "docs/brand/", "examples/"]; pub fn is_asset(path: &str) -> bool { - let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default(); + let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str()) } diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index b9bb409b..9921f9d7 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -207,7 +207,7 @@ pub fn check(crates: &[Crate]) -> Vec { } } } - out.sort_by_key(|v| v.to_string()); + out.sort_by_key(ToString::to_string); out.dedup(); out } diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 6680f240..d25bb0f4 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -6,7 +6,7 @@ use std::process::Command; use serde_json::Value; -const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8"; +const SHARED_REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"; const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; pub fn run(root: &Path) -> Result<(), String> { @@ -72,8 +72,8 @@ fn validate(metadata: &Value) -> Result<(), String> { }; let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); for (name, version, expected_source, expected_features) in [ - ("craft-pdf", "0.1.0", source.as_str(), vec!["write"]), - ("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]), + ("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]), + ("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]), ("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]), ] { let package = one_package(name)?; @@ -119,7 +119,7 @@ fn validate(metadata: &Value) -> Result<(), String> { } fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> { - let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]); + let mut expected = BTreeSet::from(["raster-images", "simple-text"]); if parallel { expected.insert("rayon"); } @@ -153,8 +153,8 @@ mod tests { ], "resolve":{"nodes":[ {"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]}, - {"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]}, - {"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]}, + {"id":"writer", "features":["write","write-simple-text"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]}, + {"id":"backend", "features":["raster-images","rayon","simple-text"], "deps":[]}, {"id":"parser", "features":["std"], "deps":[]} ]} }) @@ -162,8 +162,8 @@ mod tests { #[test] fn parallel_compression_is_required_only_on_native_targets() { - let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n"; - let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n"; + let serial = "krilla v0.8.2 (pinned)|raster-images,simple-text\n"; + let parallel = "krilla v0.8.2 (pinned)|raster-images,rayon,simple-text\n"; assert!(validate_target_features(serial, false).is_ok()); assert!(validate_target_features(parallel, true).is_ok()); assert!(validate_target_features(serial, true).is_err()); @@ -189,8 +189,9 @@ mod tests { fn rejects_import_features_and_missing_text_shaping() { for (node, features) in [ (1, json!([])), - (1, json!(["write", "write-pdf"])), - (2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])), + (1, json!(["write"])), + (1, json!(["write", "write-simple-text", "write-pdf"])), + (2, json!(["raster-images", "rayon", "simple-text", "pdf"])), (2, json!(["raster-images"])), (3, json!(["std", "images"])), ] {