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"])), ] {