From a9312e94317068e2aa34121f3c9574f93bdd0086 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Tue, 8 Sep 2026 23:36:38 -0400 Subject: [PATCH] iris is the framework alone; the app is one crate in app-rust/ Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2438 +++++++++---- Cargo.toml | 137 +- benches/fling_spline_reference.py | 156 + benches/message_list.rs | 493 +++ benches/report_to_touch.py | 96 + benches/velocity_reference.py | 298 ++ core/Cargo.toml | 11 +- core/assets/fonts/NERD_FONTS_LICENSE.txt | 21 + core/assets/fonts/nerd_icons.ttf | Bin 0 -> 992 bytes core/build-icon-font.sh | 62 + core/src/attr.rs | 2 +- core/src/event/manager.rs | 15 + core/src/event/mod.rs | 14 + core/src/icon.rs | 39 + core/src/lib.rs | 4 +- core/src/num.rs | 10 +- core/src/orientation/align.rs | 2 +- core/src/orientation/axis.rs | 6 +- core/src/orientation/len.rs | 94 +- core/src/orientation/pos.rs | 6 +- core/src/primitive/color.rs | 13 +- core/src/primitive/layer.rs | 24 +- core/src/primitive/text.rs | 864 ++++- core/src/primitive/texture.rs | 329 +- core/src/render/atlas.rs | 284 ++ core/src/render/data.rs | 118 +- core/src/render/frame_report.rs | 557 +++ core/src/render/mod.rs | 579 ++- core/src/render/primitive.rs | 478 ++- core/src/render/sdf.rs | 54 + core/src/render/shader.wgsl | 246 +- core/src/render/texture.rs | 485 ++- core/src/render/util/mod.rs | 9 +- core/src/ui/access.rs | 152 + core/src/ui/active.rs | 57 +- core/src/ui/cache.rs | 18 - core/src/ui/mod.rs | 55 +- core/src/ui/painter.rs | 316 +- core/src/ui/render_state.rs | 977 ++++- core/src/ui/size.rs | 86 - core/src/util/arena.rs | 9 + core/src/util/math.rs | 17 +- core/src/util/slot.rs | 11 + core/src/util/vec2.rs | 2 +- core/src/widget/mod.rs | 66 +- core/src/widget/widgets.rs | 22 +- examples/bench_images.rs | 104 + examples/message_list.rs | 122 + examples/tabs/main.rs | 198 +- headless.conf | 14 + macro/Cargo.toml | 6 +- macro/src/lib.rs | 18 +- rig-input/Cargo.toml | 32 + rig-input/src/main.rs | 164 + run-bench.sh | 24 + run-headless.sh | 200 ++ rust-toolchain.toml | 11 + src/access_tests.rs | 129 + src/android/access.rs | 86 + src/android/attr.rs | 29 + src/android/ime.rs | 318 ++ src/android/input.rs | 39 + src/android/insets.rs | 155 + src/android/mod.rs | 45 + src/android/platform.rs | 86 + src/android/render.rs | 526 +++ src/android/view.rs | 1080 ++++++ src/attr.rs | 231 ++ src/default/access.rs | 28 + src/default/app.rs | 4 + src/default/attr.rs | 84 +- src/default/event.rs | 9 - src/default/input.rs | 18 +- src/default/logging.rs | 86 + src/default/mod.rs | 188 +- src/default/platform.rs | 33 + src/default/render.rs | 147 +- src/default/sense.rs | 308 -- src/diagnostics.rs | 83 + src/event.rs | 28 +- src/harness.rs | 428 +++ src/layout_tests.rs | 1079 ++++++ src/lib.rs | 39 +- src/platform.rs | 22 + src/sense.rs | 3171 +++++++++++++++++ src/sense_tests.rs | 781 ++++ src/{default => }/state.rs | 39 +- src/{default => }/task.rs | 34 +- src/widget/image.rs | 19 +- src/widget/mask.rs | 38 +- src/widget/position/align.rs | 33 +- src/widget/position/layer.rs | 12 +- src/widget/position/lazy_span.rs | 2481 +++++++++++++ src/widget/position/max_size.rs | 75 +- src/widget/position/mod.rs | 8 +- src/widget/position/offset.rs | 12 +- src/widget/position/pad.rs | 125 +- src/widget/position/scroll.rs | 66 - src/widget/position/scroll_area.rs | 565 +++ src/widget/position/scrollable.rs | 524 +++ src/widget/position/sized.rs | 43 +- src/widget/position/span.rs | 172 +- src/widget/position/stack.rs | 33 +- src/widget/ptr.rs | 22 +- src/widget/rect.rs | 52 +- src/widget/text/build.rs | 39 +- src/widget/text/edit.rs | 1052 +++--- src/widget/text/mod.rs | 203 +- src/widget/trait_fns.rs | 46 +- tabs-ui/Cargo.toml | 13 + {examples/tabs => tabs-ui}/assets/sungals.png | Bin tabs-ui/src/lib.rs | 215 ++ tests/mask_sdf.rs | 407 +++ 113 files changed, 23221 insertions(+), 2992 deletions(-) create mode 100644 benches/fling_spline_reference.py create mode 100644 benches/message_list.rs create mode 100755 benches/report_to_touch.py create mode 100644 benches/velocity_reference.py create mode 100644 core/assets/fonts/NERD_FONTS_LICENSE.txt create mode 100644 core/assets/fonts/nerd_icons.ttf create mode 100755 core/build-icon-font.sh create mode 100644 core/src/icon.rs create mode 100644 core/src/render/atlas.rs create mode 100644 core/src/render/frame_report.rs create mode 100644 core/src/render/sdf.rs create mode 100644 core/src/ui/access.rs delete mode 100644 core/src/ui/cache.rs delete mode 100644 core/src/ui/size.rs create mode 100644 examples/bench_images.rs create mode 100644 examples/message_list.rs create mode 100644 headless.conf create mode 100644 rig-input/Cargo.toml create mode 100644 rig-input/src/main.rs create mode 100755 run-bench.sh create mode 100755 run-headless.sh create mode 100644 rust-toolchain.toml create mode 100644 src/access_tests.rs create mode 100644 src/android/access.rs create mode 100644 src/android/attr.rs create mode 100644 src/android/ime.rs create mode 100644 src/android/input.rs create mode 100644 src/android/insets.rs create mode 100644 src/android/mod.rs create mode 100644 src/android/platform.rs create mode 100644 src/android/render.rs create mode 100644 src/android/view.rs create mode 100644 src/attr.rs create mode 100644 src/default/access.rs delete mode 100644 src/default/event.rs create mode 100644 src/default/logging.rs create mode 100644 src/default/platform.rs delete mode 100644 src/default/sense.rs create mode 100644 src/diagnostics.rs create mode 100644 src/harness.rs create mode 100644 src/layout_tests.rs create mode 100644 src/platform.rs create mode 100644 src/sense.rs create mode 100644 src/sense_tests.rs rename src/{default => }/state.rs (60%) rename src/{default => }/task.rs (57%) create mode 100644 src/widget/position/lazy_span.rs delete mode 100644 src/widget/position/scroll.rs create mode 100644 src/widget/position/scroll_area.rs create mode 100644 src/widget/position/scrollable.rs create mode 100644 tabs-ui/Cargo.toml rename {examples/tabs => tabs-ui}/assets/sungals.png (100%) create mode 100644 tabs-ui/src/lib.rs create mode 100644 tests/mask_sdf.rs diff --git a/Cargo.lock b/Cargo.lock index 0796ff1..9a47f77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,126 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "accesskit" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a7081a65b95c668db56591a4fef9bc5dad275f448bd6223fc6949d823db38" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_android" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "929aff36b0d3dd22ddb59191989d5293ed958294d7728bd6c2ec9778f73414aa" +dependencies = [ + "accesskit", + "accesskit_consumer", + "jni 0.21.1", + "log", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d47ad644916f6cb7e432a5ca0dbd7cc78281a9772881057bb72d4aadb93257" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc882fa0c9c24c53649e256311b51046cf36eca9a8f7e54ff4ef682160b0cb27" +dependencies = [ + "accesskit", + "hashbrown 0.17.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5e9b29c6ba5f4d2e0662e9c562484f061ffc58f658479c538ecca8299ada1e9" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", +] + +[[package]] +name = "accesskit_macos" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f278988416e5fb600f24d0cfffe31a249ebbff980fb9c5a3b5fbed2c579f73" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665c9079b7325a8168a6793437a172dda44b19a05af8ed52d7e32abaada996fe" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a59e8d7160cbac991c1345c99153783ab96423644ccb1f20617cf80c97596aa1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.17.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb960a51582305f94b3884a7ff54b3462abe38f0de95a9e7e04dd7ab7b757da" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -31,7 +151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -63,23 +183,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-activity" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.10.0", + "bitflags 2.13.1", "cc", - "cesu8", - "jni", - "jni-sys", + "jni 0.22.4", "libc", "log", "ndk", "ndk-context", "ndk-sys", "num_enum", - "thiserror 1.0.69", + "thiserror 2.0.20", ] [[package]] @@ -88,20 +206,34 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-view" +version = "0.1.0" +source = "git+https://github.com/rust-mobile/android-view.git?rev=bec6c62a96cef8239b0fd7fedeef9b184d02e3a1#bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" +dependencies = [ + "dpi", + "jni 0.21.1", + "ndk", + "num_enum", + "send_wrapper", + "smallvec", + "ui-events", +] + [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -118,7 +250,7 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -138,7 +270,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -149,9 +281,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -177,6 +309,137 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -184,10 +447,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.0" +name = "atspi" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av-scenechange" @@ -204,7 +504,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.17", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -225,27 +525,27 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.6" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] [[package]] name = "bit-set" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit_field" @@ -261,25 +561,19 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitstream-io" -version = "4.9.0" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" dependencies = [ - "core2", + "no_std_io2", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block2" version = "0.5.1" @@ -290,35 +584,57 @@ dependencies = [ ] [[package]] -name = "built" -version = "0.8.0" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -335,9 +651,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -345,7 +661,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -367,9 +683,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.51" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -391,9 +707,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "clipboard-win" @@ -406,9 +722,9 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", @@ -423,9 +739,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -450,16 +766,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -473,8 +779,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", + "core-foundation", + "core-graphics-types", "foreign-types", "libc", ] @@ -486,77 +792,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cosmic-text" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cadaea21e24c49c0c82116f2b465ae6a49d63c90e428b0f8d9ae1f638ac91f" -dependencies = [ - "bitflags 2.10.0", - "fontdb", - "harfrust", - "linebender_resource_handle", - "log", - "rangemap", - "rustc-hash", - "self_cell", - "skrifa 0.39.0", - "smol_str", - "swash", - "sys-locale", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -564,18 +817,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -597,19 +850,30 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", ] [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -637,9 +901,36 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "equator" @@ -658,7 +949,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -674,7 +965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -684,39 +975,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] -name = "exr" -version = "1.74.0" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", "rayon-core", "smallvec", "zune-inflate", ] [[package]] -name = "fax" -version = "0.2.6" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "fax_derive" -version = "0.2.0" +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -729,9 +1034,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.6" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -741,12 +1046,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -763,34 +1069,33 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.10.1" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", ] [[package]] -name = "fontconfig-parser" -version = "0.5.8" +name = "fontique" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" dependencies = [ - "roxmltree", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "fontconfig-parser", - "log", + "hashbrown 0.17.1", + "linebender_resource_handle", "memmap2", - "slotmap", - "tinyvec", - "ttf-parser", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "parlance", + "read-fonts", + "roxmltree", + "smallvec", + "windows", + "windows-core", + "yeslogic-fontconfig-sys", ] [[package]] @@ -805,13 +1110,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -820,6 +1125,61 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -835,7 +1195,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "windows-link", ] @@ -847,15 +1207,26 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] -name = "gif" -version = "0.14.1" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", "weezl", @@ -874,9 +1245,9 @@ dependencies = [ [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -903,30 +1274,10 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.17", + "thiserror 2.0.20", "windows", ] -[[package]] -name = "gpu-descriptor" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" -dependencies = [ - "bitflags 2.10.0", - "gpu-descriptor-types", - "hashbrown 0.15.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "half" version = "2.7.1" @@ -941,14 +1292,13 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.4.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", - "core_maths", - "read-fonts 0.36.0", + "read-fonts", "smallvec", ] @@ -973,22 +1323,159 @@ dependencies = [ ] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "hermit-abi" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -1004,8 +1491,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.8", + "zune-core", + "zune-jpeg", ] [[package]] @@ -1020,18 +1507,18 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1042,21 +1529,29 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "iris" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_android", + "accesskit_winit", + "android-view", "arboard", - "cosmic-text", + "bytemuck", "image", "iris-core", "iris-macro", + "log", + "parley", "pollster", + "send_wrapper", + "swash", + "tabs-ui", "tokio", - "unicode-segmentation", "wgpu", "winit", ] @@ -1065,12 +1560,14 @@ dependencies = [ name = "iris-core" version = "0.1.0" dependencies = [ + "accesskit", "bytemuck", - "cosmic-text", "fxhash", "image", + "parley", + "pollster", + "swash", "wgpu", - "winit", ] [[package]] @@ -1079,7 +1576,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] @@ -1100,7 +1597,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", @@ -1108,31 +1605,94 @@ dependencies = [ ] [[package]] -name = "jni-sys" -version = "0.3.0" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.1", + "serde", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -1158,15 +1718,15 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -1184,19 +1744,20 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.9.3", ] [[package]] @@ -1213,9 +1774,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1234,9 +1801,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loop9" @@ -1247,15 +1814,6 @@ dependencies = [ "imgref", ] -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "maybe-rayon" version = "0.1.1" @@ -1268,32 +1826,26 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] [[package]] -name = "metal" -version = "0.33.0" +name = "memoffset" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ - "bitflags 2.10.0", - "block", - "core-graphics-types 0.2.0", - "foreign-types", - "log", - "objc", - "paste", + "autocfg", ] [[package]] @@ -1307,10 +1859,20 @@ dependencies = [ ] [[package]] -name = "moxcms" -version = "0.7.11" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -1318,38 +1880,50 @@ dependencies = [ [[package]] name = "naga" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618f667225063219ddfc61251087db8a9aec3c3f0950c916b614e403486f1135" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set", - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.16.1", - "hexf-parse", + "hashbrown 0.17.1", "indexmap", "libm", "log", + "naga-types", "num-traits", "once_cell", "rustc-hash", "spirv", - "thiserror 2.0.17", + "thiserror 2.0.20", "unicode-ident", ] +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror 2.0.20", +] + [[package]] name = "ndk" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.10.0", - "jni-sys", + "bitflags 2.13.1", + "jni-sys 0.3.1", "log", "ndk-sys", "num_enum", @@ -1369,7 +1943,7 @@ version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.1", ] [[package]] @@ -1378,6 +1952,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "8.0.0" @@ -1395,14 +1978,24 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -1411,14 +2004,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1446,9 +2039,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -1456,23 +2049,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", + "syn 2.0.119", ] [[package]] @@ -1493,9 +2077,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1506,14 +2090,14 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "libc", "objc2 0.5.2", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -1522,8 +2106,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation 0.3.2", ] @@ -1534,8 +2118,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1547,7 +2131,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1558,8 +2142,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1570,9 +2154,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", ] [[package]] @@ -1581,9 +2165,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "dispatch2", - "objc2 0.6.3", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] @@ -1594,10 +2178,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -1606,12 +2190,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-contacts", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2-core-foundation", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -1624,8 +2218,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "dispatch", "libc", "objc2 0.5.2", @@ -1637,8 +2231,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -1648,8 +2242,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.10.0", - "objc2 0.6.3", + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -1659,7 +2253,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -1671,23 +2265,49 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -1706,8 +2326,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit", "objc2-core-data", @@ -1715,7 +2335,7 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -1727,7 +2347,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2", + "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] @@ -1738,8 +2358,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", @@ -1747,15 +2367,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "orbclient" -version = "0.3.50" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", @@ -1763,13 +2383,23 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.1.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -1777,7 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1789,6 +2419,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1812,6 +2448,39 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parlance" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" + +[[package]] +name = "parley" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" +dependencies = [ + "fontique", + "harfrust", + "hashbrown 0.17.1", + "icu_normalizer", + "icu_properties", + "icu_segmenter", + "linebender_resource_handle", + "parlance", + "parley_data", + "skrifa", +] + +[[package]] +name = "parley_data" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" +dependencies = [ + "icu_properties", +] + [[package]] name = "paste" version = "1.0.15" @@ -1842,48 +2511,108 @@ dependencies = [ ] [[package]] -name = "pin-project" -version = "1.1.10" +name = "phf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -1896,31 +2625,42 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] [[package]] name = "pollster" -version = "0.4.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1938,50 +2678,70 @@ checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] -name = "pxfm" -version = "0.1.27" +name = "pulp" +version = "0.22.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" dependencies = [ - "num-traits", + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", ] +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qoi" version = "0.4.1" @@ -1999,18 +2759,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2022,10 +2782,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "rand" -version = "0.9.2" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core", @@ -2043,24 +2809,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] name = "range-alloc" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" - -[[package]] -name = "rangemap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" [[package]] name = "rav1e" @@ -2092,16 +2852,16 @@ dependencies = [ "rand", "rand_chacha", "simd_helpers", - "thiserror 2.0.17", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] [[package]] name = "ravif" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" dependencies = [ "avif-serialize", "imgref", @@ -2112,6 +2872,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -2119,10 +2888,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "rayon" -version = "1.11.0" +name = "raw-window-metal" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2140,24 +2921,20 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.35.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", "font-types", + "once_cell", ] [[package]] -name = "read-fonts" -version = "0.36.0" +name = "reborrow" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" -dependencies = [ - "bytemuck", - "core_maths", - "font-types", -] +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" [[package]] name = "redox_syscall" @@ -2174,16 +2951,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -2194,15 +2971,27 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "rig-input" +version = "0.1.0" +dependencies = [ + "iris", + "wayland-client", + "wayland-protocols-wlr", +] [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] [[package]] name = "rustc-hash" @@ -2210,13 +2999,22 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2225,22 +3023,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "linux-raw-sys 0.12.1", + "windows-sys 0.60.2", ] [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -2277,16 +3075,22 @@ dependencies = [ ] [[package]] -name = "self_cell" -version = "1.2.2" +name = "semver" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +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.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2294,35 +3098,66 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] name = "simd_helpers" @@ -2334,30 +3169,32 @@ dependencies = [ ] [[package]] -name = "skrifa" -version = "0.37.0" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" -dependencies = [ - "bytemuck", - "read-fonts 0.35.0", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skrifa" -version = "0.39.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts 0.36.0", + "read-fonts", ] [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" @@ -2370,9 +3207,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smithay-client-toolkit" @@ -2380,7 +3217,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -2410,11 +3247,11 @@ dependencies = [ [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", ] [[package]] @@ -2437,20 +3274,20 @@ checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" [[package]] name = "swash" -version = "0.2.6" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "skrifa 0.37.0", + "skrifa", "yazi", "zeno", ] [[package]] name = "syn" -version = "2.0.113" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -2458,12 +3295,45 @@ dependencies = [ ] [[package]] -name = "sys-locale" -version = "0.3.2" +name = "syn" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ - "libc", + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tabs-ui" +version = "0.1.0" +dependencies = [ + "iris", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.60.2", ] [[package]] @@ -2486,11 +3356,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.20", ] [[package]] @@ -2501,32 +3371,32 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -2555,43 +3425,39 @@ dependencies = [ ] [[package]] -name = "tinyvec" -version = "1.10.0" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "tinyvec_macros", + "displaydoc", + "serde_core", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "pin-project-lite", ] [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime", @@ -2601,9 +3467,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow", ] @@ -2615,14 +3481,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] [[package]] name = "tree_magic_mini" @@ -2640,39 +3521,39 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "core_maths", + "memoffset", + "tempfile", + "windows-sys 0.60.2", ] [[package]] -name = "unicode-bidi" -version = "0.3.18" +name = "ui-events" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +checksum = "c4c2cc34489c685d4e7a1a1f97b7b4416c5aa789892114ae77df6cd2a60f0ec4" +dependencies = [ + "dpi", + "keyboard-types", +] [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -2680,6 +3561,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "v_frame" version = "0.3.9" @@ -2709,18 +3607,18 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2731,22 +3629,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2754,35 +3649,35 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.3", + "rustix 1.1.4", "scoped-tls", "smallvec", "wayland-sys", @@ -2790,12 +3685,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.10.0", - "rustix 1.1.3", + "bitflags 2.13.1", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -2806,29 +3701,29 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] [[package]] name = "wayland-cursor" -version = "0.31.12" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.3", + "rustix 1.1.4", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2836,11 +3731,11 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2849,11 +3744,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2862,9 +3757,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -2873,9 +3768,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -2885,9 +3780,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -2911,17 +3806,17 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cb534d5ffd109c7d1135f34cdae29e60eab94855a625dcfe1705f8bc7ad79f" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "log", "naga", @@ -2941,21 +3836,22 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb4c8b5db5f00e56f1f08869d870a0dff7c8bc7ebc01091fec140b0cf0211a9" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", "bit-set", "bit-vec", - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap", "log", "naga", + "naga-types", "once_cell", "parking_lot", "portable-atomic", @@ -2963,71 +3859,75 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] [[package]] name = "wgpu-core-deps-apple" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87b7b696b918f337c486bf93142454080a32a37832ba8a31e4f48221890047da" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b251c331f84feac147de3c4aa3aa45112622a95dd7ee1b74384fa0458dbd79" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ca976e72b2c9964eb243e281f6ce7f14a514e409920920dcda12ae40febaae" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293080d77fdd14d6b08a67c5487dfddbf874534bb7921526db56a7b75d7e3bef" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set", - "bitflags 2.10.0", - "block", + "bitflags 2.13.1", + "block2 0.6.2", "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", "gpu-allocator", - "gpu-descriptor", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "js-sys", "khronos-egl", "libc", "libloading", "log", - "metal", "naga", + "naga-types", "ndk-sys", - "objc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", @@ -3036,26 +3936,44 @@ dependencies = [ "profiling", "range-alloc", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.17", + "static_assertions", + "thiserror 2.0.20", "wasm-bindgen", + "wayland-sys", "web-sys", + "wgpu-naga-bridge", "wgpu-types", "windows", "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", ] [[package]] name = "wgpu-types" -version = "28.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", + "naga-types", + "raw-window-handle", + "static_assertions", "web-sys", ] @@ -3065,7 +3983,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3121,7 +4039,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3132,7 +4050,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3411,20 +4329,20 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" -version = "0.30.12" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.10.0", - "block2", + "bitflags 2.13.1", + "block2 0.5.1", "bytemuck", "calloop", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", @@ -3463,18 +4381,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" @@ -3485,8 +4403,8 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.3", - "thiserror 2.0.17", + "rustix 1.1.4", + "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -3494,6 +4412,12 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -3516,7 +4440,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "x11rb-protocol", ] @@ -3528,9 +4452,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xkbcommon-dl" @@ -3538,7 +4462,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -3553,9 +4477,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "y4m" @@ -3569,6 +4493,146 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zeno" version = "0.3.3" @@ -3577,35 +4641,91 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] -name = "zune-core" -version = "0.4.12" +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zune-core" -version = "0.5.0" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -3618,18 +4738,50 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.4.21" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.4.12", + "zune-core", ] [[package]] -name = "zune-jpeg" -version = "0.5.8" +name = "zvariant" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ - "zune-core 0.5.0", + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 9c2276a..385e188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,35 +8,146 @@ edition.workspace = true [dependencies] iris-core = { workspace = true } iris-macro = { workspace = true } -cosmic-text = { workspace = true } -unicode-segmentation = { workspace = true } -winit = { workspace = true } -arboard = { workspace = true, features = ["wayland-data-control"] } +parley = { workspace = true } +swash = { workspace = true } pollster = { workspace = true } wgpu = { workspace = true } image = { workspace = true } +accesskit = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } +# For diagnostics visible through android_logger (or whatever logger the +# app crate installs) -- this crate never installs one itself. Not in the +# android-only block below any more: the lines that matter most are in +# shared widget code, which the host backend compiles too. +log = "0.4.34" + +# winit everywhere except Android; android-view (below) is what stands in +# for it there. Both backends live in this crate (see `src/android/mod.rs`'s +# doc comment) but are never compiled together: winit's own Android support +# pulls in `android-activity`, which panics at compile time unless one of +# its own backend features is picked, and picking one is exactly what +# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it +# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside +# `android-activity` itself with "Either game-activity or native-activity +# must be enabled" before this split existed. +[target.'cfg(not(target_os = "android"))'.dependencies] +winit = { workspace = true } +arboard = { workspace = true, features = ["wayland-data-control"] } +# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own +# adapter over `accesskit`. No pin needed the way android-view's rev is +# pinned -- this is an ordinary crates.io release with no local abort to +# track (that finding is Android-only, see below). +accesskit_winit = "0.34.0" + +# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this +# emulator -- real Vulkan rendering, a working `InputConnection`, and the +# accesskit-detach abort, all against this rev specifically. Advancing it +# wants re-running E1's checks, the same reason the nightly toolchain pin +# is dated rather than floating. +[target.'cfg(target_os = "android")'.dependencies] +android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } +# I4 (RUST.md): the Android half of the AccessKit push, over android-view's +# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1 +# found on 0.4.0** (the `State` enum still never returns to `Inactive`, +# and `send_completed_event` still unwraps a Java exception) -- advancing +# the version is not the fix, so pinning to a specific rev buys nothing +# here the way it does for android-view itself. `android/view.rs`'s +# `raise_if_enabled` is the mitigation, carried from E1. +accesskit_android = "0.8.0" +# Not re-exported by android-view (only `jni` and `ndk` are), and needed +# for `android/insets.rs`'s own id -> state map -- the same reason +# android-view's own `PEER_MAP` carries one. +send_wrapper = "0.6.0" + +[features] +# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the +# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one +# build can be measured on either backend. A compile-time feature rather +# than an env var because nothing on this machine can hand an env var to an +# already-launched Android process (there is no `am start` environment and +# no system-property reader here to add one). +# +# **Not needed to get GLES in the emulator**, whatever the history here +# says: the emulator's guest has no hardware Vulkan at all, so an ordinary +# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the +# emulator gives a GPU app"). Keeping the emulator on the same binary the +# phone runs is the point. What this feature is still for is forcing GLES +# on a machine that *does* have Vulkan -- the desktop -- which is why +# `default/render.rs` reads it too: +# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \ +# --features iris/force-gles +force-gles = [] [dev-dependencies] tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] } +# The tabs example's widget tree. A dev-dependency cycle back to this +# package is fine -- cargo excludes dev-dependencies from the graph used +# to build the library itself, so this only matters for `--examples`. +tabs-ui = { path = "tabs-ui" } +# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it +# reads back. wgpu and pollster are ordinary dependencies already. +bytemuck = { workspace = true } + +# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's +# header for why. `harness = false` opts out of the unstable `#[bench]` +# test-crate harness cargo would otherwise want, in favour of an ordinary +# `fn main()`. +[[bench]] +name = "message_list" +harness = false [workspace] -members = ["core", "macro"] +members = [ + "core", + "macro", + "tabs-ui", + "rig-input", +] [workspace.package] version = "0.1.0" edition = "2024" +# Debug info is the reason a `cargo test --workspace` here was taking half +# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's +# default `debug = true`, linking this workspace's test binaries wrote +# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an +# **88 GB** `target/`. Eight test binaries each statically link the whole +# wgpu + naga + winit + parley graph, and at the default every one of them +# gets a full copy of that graph's DWARF written into it. On a btrfs at 83% +# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible, +# waiting on space reservation -- at about 20 MB/s between them, which is +# what "the tests are slow" actually was. Not CPU: the machine was 87% idle +# throughout. +# +# `line-tables-only` keeps what is actually read from a backtrace -- the +# file and line of every frame, which is what a panicking test prints and +# what gdb needs to name the frames of a segfault. What it gives up is +# inspecting variables in a debugger; when that is wanted, ask for it on +# the command line for that one run rather than paying for it on every +# build: +# +# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever +[profile.dev] +debug = "line-tables-only" + +# The tests are what this is really for; `cargo test` uses `dev` for +# dependencies and `test` for the test targets themselves, so setting only +# `dev` leaves the eight big binaries at the default. +[profile.test] +debug = "line-tables-only" + [workspace.dependencies] -pollster = "0.4.0" -winit = "0.30.12" -wgpu = "28.0.0" -bytemuck = "1.23.1" -image = "0.25.6" -cosmic-text = "0.16.0" -unicode-segmentation = "1.12.0" +pollster = "1.0.1" +winit = "0.30.13" +wgpu = "30.0.1" +bytemuck = "1.25.2" +image = "0.25.10" +parley = "0.11.1" +swash = "0.2.10" fxhash = "0.2.1" arboard = "3.6.1" +accesskit = "0.25.0" iris-core = { path = "core" } iris-macro = { path = "macro" } -tokio = "1.49.0" +tokio = "1.53.1" diff --git a/benches/fling_spline_reference.py b/benches/fling_spline_reference.py new file mode 100644 index 0000000..3a9af55 --- /dev/null +++ b/benches/fling_spline_reference.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""AOSP's fling spline, transcribed independently of the Rust port. + +This exists so the numbers in `sense.rs`'s `the_spline_matches_aosps_own_table` +and `a_flick_decelerates_the_way_aosp_says_it_does` are not the Rust code +grading its own homework. Every test iris's fling had before 2026-09-07 +compared the curve with itself -- monotonic, signed, integrates to the closed +form -- and all of them passed while `distance_fraction(t)` was returning +exactly `t` (see `android_fling_spline`'s doc comment). Numbers checked into a +test have to come from somewhere else, and this is the somewhere else. + +Transcribed by hand from, and only from: + + * frameworks/base `core/java/android/widget/OverScroller.java`, + `SplineOverScroller`'s static initialiser, `getSplineDeceleration`, + `getSplineFlingDistance`, `getSplineFlingDuration` and `update`. + * androidx.compose.animation:animation:1.12.0 `SplineBasedDecay.kt` + (`computeSplineInfo`, `AndroidFlingSpline.flingPosition`) and + `FlingCalculator.kt` (`computeDeceleration`, `flingDistance`, + `flingDuration`, `FlingInfo.position`/`velocity`). The two agree line for + line, which is why iris ports one curve rather than two. + +Run it with no arguments; it prints the table entries and the (velocity, +density, t) points the Rust tests assert on. +""" + +NB_SAMPLES = 100 +INFLEXION = 0.35 +START_TENSION = 0.5 +END_TENSION = 1.0 +P1 = START_TENSION * INFLEXION +P2 = 1.0 - END_TENSION * (1.0 - INFLEXION) + +# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own +# "look and feel tuning" constant -- a different number in a different place +# of the same formula, which is the pair iris got the wrong way round once. +SCROLL_FRICTION = 0.015 +TUNING = 0.84 +GRAVITY_EARTH = 9.80665 +INCHES_PER_METER = 39.37 + +import math + +DECELERATION_RATE = math.log(0.78) / math.log(0.9) + + +def spline_positions(): + """SPLINE_POSITION: distance fraction at each of 101 even time steps.""" + position = [0.0] * (NB_SAMPLES + 1) + x_min = 0.0 + for i in range(NB_SAMPLES): + alpha = i / NB_SAMPLES + x_max = 1.0 + while True: + x = x_min + (x_max - x_min) / 2.0 + coef = 3.0 * x * (1.0 - x) + # Solved on the P1/P2 curve... + tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x + if abs(tx - alpha) < 1e-5: + break + if tx > alpha: + x_max = x + else: + x_min = x + # ...and sampled on the tension curve. + position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x + position[NB_SAMPLES] = 1.0 + return position + + +POSITION = spline_positions() + + +def fling_sample(t): + """(distance fraction, velocity fraction) at time fraction `t`.""" + t = min(max(t, 0.0), 1.0) + index = int(t * NB_SAMPLES) + if index >= NB_SAMPLES: + return 1.0, 0.0 + t_inf = index / NB_SAMPLES + t_sup = (index + 1) / NB_SAMPLES + velocity_coef = (POSITION[index + 1] - POSITION[index]) / (t_sup - t_inf) + return POSITION[index] + (t - t_inf) * velocity_coef, velocity_coef + + +def physical_coefficient(density): + return GRAVITY_EARTH * INCHES_PER_METER * density * 160.0 * TUNING + + +def deceleration(velocity, density): + return math.log( + INFLEXION * abs(velocity) / (SCROLL_FRICTION * physical_coefficient(density)) + ) + + +def fling_distance(velocity, density): + l = deceleration(velocity, density) + return ( + SCROLL_FRICTION + * physical_coefficient(density) + * math.exp(DECELERATION_RATE / (DECELERATION_RATE - 1.0) * l) + ) + + +def fling_duration_s(velocity, density): + l = deceleration(velocity, density) + return math.exp(l / (DECELERATION_RATE - 1.0)) + + +def position_at(velocity, density, t_seconds): + d = fling_duration_s(velocity, density) + return fling_distance(velocity, density) * fling_sample(t_seconds / d)[0] + + +def velocity_at(velocity, density, t_seconds): + d = fling_duration_s(velocity, density) + return fling_sample(t_seconds / d)[1] * fling_distance(velocity, density) / d + + +if __name__ == "__main__": + print("SPLINE_POSITION at a few indices (index: value)") + for i in (0, 1, 10, 25, 50, 75, 99, 100): + print(f" {i:3}: {POSITION[i]:.6f}") + print() + print("distance/velocity fraction at time fractions") + for t in (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0): + d, v = fling_sample(t) + print(f" t={t:<5} distance={d:.6f} velocity={v:.6f}") + print() + # 2.55 is Iris's Pixel 9 Pro XL (docs/bench/iris-phone-v2-2026-09-06.md); + # 2.75 is this checkout's emulator. + for density in (2.55, 2.75): + # 15250 is `transcript-fixture/touch/flick-120hz.touch`'s own + # release velocity (velocity_reference.py), so `phone_screen.rs` + # can bound the fling it produces from *here* rather than from the + # `FlingCalculator` under test (docs/REVIEW-2026-09-07.md's T1). + for velocity in (5000.0, 11064.0, 15250.0): + dur = fling_duration_s(velocity, density) + print( + f"density={density} v={velocity}: " + f"distance={fling_distance(velocity, density):.3f}px " + f"duration={dur:.4f}s" + ) + # Deliberately not round fractions. The velocity coefficient is + # piecewise *constant* across each of the 100 samples, so it + # steps at t = k/100 and a test asserting on 0.75 is asserting + # on which side of a discontinuity the last float landed -- + # which is genuinely different between Python and Rust and says + # nothing about the curve. + for frac in (0.125, 0.335, 0.505, 0.755): + t = frac * dur + print( + f" t={frac:>4} of duration ({t:.4f}s): " + f"pos={position_at(velocity, density, t):.3f}px " + f"vel={velocity_at(velocity, density, t):.3f}px/s" + ) diff --git a/benches/message_list.rs b/benches/message_list.rs new file mode 100644 index 0000000..1bf0b50 --- /dev/null +++ b/benches/message_list.rs @@ -0,0 +1,493 @@ +//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's +//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run +//! explicitly with `cargo bench --bench message_list --release` or +//! `./run-bench.sh`. +//! +//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario +//! here is really "how many `Widget::draw` calls and primitive rewrites did +//! this frame cost," which `UiRenderState::take_counters` already answers +//! exactly (see `iris/src/layout_tests.rs`, which this file's harness +//! mirrors). A short loop that times itself and prints the counters +//! alongside the wall time says everything criterion's warm-up/sampling/ +//! outlier-removal machinery would add on top, for scenarios that are +//! fundamentally about a *count*, not a noisy microbenchmark distribution +//! -- and it avoids a new dependency this crate does not otherwise need. +//! Per the code rules, the plain option is also the one shorter to explain. +//! +//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a +//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this +//! file built their own giant `Span` and wrapped it in `ScrollArea`, which +//! meant (a)/(b)/(c) below were measuring "move one big child," never the +//! virtualised widget the app's transcript screen actually needs. `LazySpan` +//! still needs every row's *widget* built up front by the caller (its +//! module doc explains why: it only ever sees `&dyn Widget` through +//! `Painter`, so it cannot construct a row lazily on its own) -- what +//! virtualisation buys is that only the rows currently on screen are ever +//! *drawn*, which is what the draw/rewrite/move counters below are +//! measuring, not construction time. +//! +//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and +//! IRIS_TODO.md's "Benchmarks" wording): +//! +//! - (a) first-frame cost of a message list of N wrapped-text rows, some +//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list +//! this is expected to stop scaling with N once N exceeds a screenful -- +//! the draw/rewrite counters below are the number that used to grow 10x +//! per 10x N and should not any more. +//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not +//! re-layout. +//! - (c) the input-box case: growing a fixed-height field at the bottom of +//! the screen must move the message list above it, not re-lay its rows. +//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md +//! section 8 defines. +//! - (d) insert-above-anchor: paging older history onto the front of an +//! already-scrolled list. `LazySpan::push_front` is an O(1) index update +//! (lazy_span.rs's module doc); this measures that none of the rows already +//! on screen are touched by it. +//! - (e) expand-a-row-holding-its-edge: growing one row's height with a +//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move +//! only the rows on the far side of it, never redraw the ones already +//! correctly placed. +//! +//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in +//! place, which is what a tool card rebuilt on a tap costs. Every one of +//! its primitives is freed and rewritten, and so renumbered in the +//! layer's draw order -- the pass that used to be O(N^2) there +//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch +//! is per-glyph: it must stay flat as N grows, not grow with it. +//! +//! (f), many images with zero steady-state bind-group creation, needs a +//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead, +//! driven through `run-headless.sh` -- see that file's header. +//! +//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs` +//! notes), so everything here runs as an ordinary `--release` binary with +//! no compositor. Numbers are recorded in RUST.md's I3 box, not here -- +//! this file is the rig, not the result. + +use iris::prelude::*; +use std::time::Instant; + +/// The minimal `UiRsc` a benchmark needs -- identical in shape to +/// `layout_tests.rs`'s `TestRsc`. +struct BenchRsc { + ui: UiData, +} + +impl UiRsc for BenchRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } +} + +/// Long enough to force real wrapping at a phone-plausible column width, and +/// varied enough (no two rows byte-identical) that nothing can special-case +/// on repeated content. +const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \ +out wrapped text by shaping once per width and caching the result, so a \ +row that is offered the same width twice does not reshape. This sentence \ +exists only to give a row enough text to wrap across several lines at a \ +typical phone column width."; + +/// One message row: a wrapped `Text`, and every `image_every`th row also an +/// `Image` beneath it -- a small in-memory RGBA square rather than a file, +/// so N=10,000 rows costs no disk I/O. +fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { + let mut text = Text::new(format!("Message {i}: {BODY}")); + text.wrap = true; + let text = rsc.ui.widgets.add_strong(text).any(); + + if image_every > 0 && i.is_multiple_of(image_every) { + let img = image::DynamicImage::new_rgba8(64, 64); + let image_widget = image::(img)(rsc); + let image_widget = rsc.ui.widgets.add_strong(image_widget).any(); + let mut row = Span::empty(Dir::DOWN); + row.push(text); + row.push(image_widget); + rsc.ui.widgets.add_strong(row).any() + } else { + text + } +} + +/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them +/// carrying an image (0 disables images entirely). Returns the list widget +/// (weak, so the caller can drive it) and the erased root to render. +fn build_message_list( + rsc: &mut BenchRsc, + n: usize, + image_every: usize, +) -> (WeakWidget, StrongWidget) { + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + for i in 0..n { + let row = build_row(rsc, i, image_every); + list.push_back(LazyItem::new(i as u64, row)); + } + let list = rsc.ui.widgets.add_strong(list); + // Driven through the span's own `ScrollController`, like every other + // scroll area in iris: what this measures has to be the path the app + // actually takes. + (list.weak(), list.any()) +} + +fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, moves: u64) { + println!( + "{label}: {:.2}ms draws={draws} rewrites={rewrites} moves={moves}", + elapsed.as_secs_f64() * 1000.0 + ); +} + +/// (a) First-frame cost of a message list of N rows. +fn bench_first_frame(n: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let (_list, root) = build_message_list(&mut rsc, n, 20); + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + + let start = Instant::now(); + render.update(&root, &mut rsc); + let elapsed = start.elapsed(); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + report( + &format!("(a) first frame, N={n}"), + elapsed, + draws, + rewrites, + moves, + ); +} + +/// (b) Per-frame cost of scrolling an already-laid-out list of N rows. +/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an +/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move +/// rather than a resize), then times a run of individual scroll ticks. +fn bench_scroll(n: usize, ticks: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let (scroll, root) = build_message_list(&mut rsc, n, 20); + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + for _ in 0..ticks { + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-8.0); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!("(b) scroll, N={n}, {ticks} ticks (totals; expect draws/moves independent of N)"), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-tick average: {:.4}ms", + total.as_secs_f64() * 1000.0 / ticks as f64 + ); +} + +/// (c) The input-box case: a fixed-height field at the bottom of the screen +/// growing by a line at a time, with a message list of N rows filling the +/// rest of the screen above it. Growing the input shrinks the *offered* +/// height of the list container (a single widget, from the outer `Span`'s +/// point of view) without changing the width it offers its content -- so +/// the rows underneath, which only care about width, must not redraw; the +/// list's own re-registration of where its content sits is the one O(1) +/// move this is checking for. +fn bench_input_grows(n: usize, lines: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let (scroll, list_root) = build_message_list(&mut rsc, n, 20); + let list_area = rsc.ui.widgets.add_strong(Sized { + inner: list_root, + x: None, + y: Some(rest(1.0)), + }); + + let line_height = 24.0; + let input_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let input_area = rsc.ui.widgets.add_strong(Sized { + inner: input_rect.any(), + x: None, + y: Some(abs(line_height)), + }); + + let input_area_weak = input_area.weak(); + let mut root_span = Span::empty(Dir::DOWN); + root_span.push(list_area.any()); + root_span.push(input_area.any()); + let root = rsc.ui.widgets.add_strong(root_span).any(); + + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + for line in 1..=lines { + rsc.ui.widgets.get_mut(&input_area_weak).unwrap().y = + Some(abs(line_height * (line + 1) as f32)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!( + "(c) input grows by {lines} lines above N={n} rows (totals; \ + draws/rewrites must not scale with N)" + ), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-line average: {:.4}ms", + total.as_secs_f64() * 1000.0 / lines as f64 + ); +} + +/// (d) Insert-above-anchor: the list is scrolled to its very first loaded +/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default +/// bottom, so a row prepended above it is genuinely "inserted above the +/// anchor" rather than merely far off-screen at the far end. Each +/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an +/// index, bumped by one) and, since the prepended rows never enter the +/// viewport, none of them should cost a draw either. +fn bench_insert_above_anchor(n: usize, inserts: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let (list, root) = build_message_list(&mut rsc, n, 20); + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&list).unwrap().jump_to_start(); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + for i in 0..inserts { + // Older-history rows: distinct keys below every existing one, so a + // real caller's paging code (prepending an older page) is exactly + // what this loop does. + let row = build_row(&mut rsc, usize::MAX - i, 20); + rsc.ui + .widgets + .get_mut(&list) + .unwrap() + .push_front(LazyItem::new(i as u64, row)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!( + "(d) insert-above-anchor, N={n}, {inserts} pushes (totals; \ + must not scale with N)" + ), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-push average: {:.4}ms", + total.as_secs_f64() * 1000.0 / inserts as f64 + ); +} + +/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is +/// directly controllable) is grown a little at a time, each time preceded +/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's +/// module doc describes and its unit tests check for correctness. This +/// measures its *cost*: only the rows on the far side of the grown one +/// (below it, since the top edge is held) should ever move, and nothing +/// should be redrawn purely because the list overall got taller. +fn bench_expand_holds_edge(n: usize, growths: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + // Near the end (not the very last row) so it is already on screen + // under the list's default bottom-anchored placement, for every N -- + // no scrolling needed to bring it into view before measuring. + let growable_index = n.saturating_sub(3); + let mut growable = None; + for i in 0..n { + if i == growable_index { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let sized = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(abs(40.0)), + }); + growable = Some(sized.weak()); + list.push_back(LazyItem::new(i as u64, sized.any())); + } else { + let row = build_row(&mut rsc, i, 20); + list.push_back(LazyItem::new(i as u64, row)); + } + } + let list = rsc.ui.widgets.add_strong(list); + let list_weak = list.weak(); + let root = list.any(); + let growable = growable.unwrap(); + + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + let mut total_draws = 0u64; + let mut total_rewrites = 0u64; + let mut total_moves = 0u64; + let mut height = 40.0f32; + let key = growable_index as u64; + for _ in 0..growths { + height += 10.0; + if let Some((top, _bottom)) = rsc.ui.widgets.get(&list_weak).unwrap().extent(key) { + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .note_tap(top + 1.0); + } + rsc.ui.widgets.get_mut(&growable).unwrap().y = Some(abs(height)); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + total_draws += draws; + total_rewrites += rewrites; + total_moves += moves; + } + report( + &format!( + "(e) expand-hold, N={n}, {growths} growths (totals; \ + must not scale with N)" + ), + total, + total_draws, + total_rewrites, + total_moves, + ); + println!( + " per-growth average: {:.4}ms", + total.as_secs_f64() * 1000.0 / growths as f64 + ); +} + +/// (g) One text widget of `chars` characters, redrawn in place `redraws` +/// times -- an open tool card whose content is rebuilt, or any widget +/// holding a lot of text that a tap changes. +/// +/// A redraw frees every primitive the widget owned and writes fresh ones, +/// so every glyph is renumbered in its layer's draw order. Finding the +/// handle to renumber used to be a scan of everything the same widget +/// drew, which made one redraw quadratic in its own glyph count: 1.37s for +/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the +/// same text. Print per-glyph rather than per-redraw, since flat is the +/// pass condition and a total says nothing without dividing it. +fn bench_redraw_big_text(chars: usize, redraws: usize) { + let mut rsc = BenchRsc { + ui: UiData::default(), + }; + // One character per glyph, and varied so nothing can collapse the + // string into a repeat. + let content: String = (0..chars) + .map(|i| char::from(b'a' + (i % 26) as u8)) + .collect(); + let mut text = Text::new(content); + text.wrap = true; + let text = rsc.ui.widgets.add_strong(text); + let handle = text.weak(); + let root = text.any(); + + let mut render = UiRenderState::new(); + render.resize((1080.0, 2000.0)); + render.update(&root, &mut rsc); + render.take_counters(); + + let mut total = std::time::Duration::ZERO; + for _ in 0..redraws { + // Asking for the widget mutably is what marks it for redraw -- + // the same path a caller changing its content takes. + rsc.ui.widgets.get_mut(&handle).unwrap(); + let start = Instant::now(); + render.update(&root, &mut rsc); + total += start.elapsed(); + } + let (draws, rewrites, moves, _shapes) = render.take_counters(); + report( + &format!("(g) redraw one {chars}-glyph text, {redraws}x (totals)"), + total, + draws, + rewrites, + moves, + ); + println!( + " per redraw: {:.3}ms, per glyph: {:.4}us", + total.as_secs_f64() * 1000.0 / redraws as f64, + total.as_secs_f64() * 1_000_000.0 / (redraws * chars) as f64, + ); +} + +fn main() { + println!("iris message-list benchmark -- release build, this machine's CPU"); + for &n in &[100usize, 1_000, 10_000] { + bench_first_frame(n); + } + for &n in &[100usize, 1_000, 10_000] { + bench_scroll(n, 200); + } + for &n in &[100usize, 1_000, 10_000] { + bench_input_grows(n, 40); + } + for &n in &[100usize, 1_000, 10_000] { + bench_insert_above_anchor(n, 200); + } + for &n in &[100usize, 1_000, 10_000] { + bench_expand_holds_edge(n, 40); + } + for &chars in &[1_000usize, 10_000, 50_000] { + bench_redraw_big_text(chars, 10); + } +} diff --git a/benches/report_to_touch.py b/benches/report_to_touch.py new file mode 100755 index 0000000..252b7e4 --- /dev/null +++ b/benches/report_to_touch.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Turns `iris::input` debug lines -- from a phone's diagnostics report, or +from a report the layer-1 harness produced with tracing on +(`iris::diagnostics::set_trace(true)`) -- back into a `TouchScript` file +`iris::harness::Harness::replay` can play back at layer 1. + +Why this exists: `docs/RUST.md`'s "Three test layers" box says the cheapest +layer that can answer a question wins, and a gesture that misbehaves on +Iris's phone is otherwise only describable in words. `iris::sense:: +log_input_event`'s one line per platform event (Android's on_touch_event +once per `MotionEvent`, with historical samples inline; winit's once per +pointer `WindowEvent`; the harness's `touch`, once per script line) already +carries everything a `.touch` file's `t_ms action x y` needs -- this just +reads it back out and reconstructs the samples in order, expanding each +event's inline historical samples into their own `move` lines first (they +are always intermediate positions of a move, and Android documents them as +oldest first, which is also the order they appear in the line). + +Usage: + report_to_touch.py < report.txt > replay.touch + report_to_touch.py report.txt > replay.touch + +Only lines containing "iris input: action=..." are read; everything else in +the report (insets, frame timings, drag-release summaries) is ignored, so +this can be pointed at Copy report's whole clipboard text directly. +""" + +import re +import sys + +# The message half of `sense::log_input_event`'s format string, prefix- +# agnostic: a real report line also carries the ring's own +# `HH:MM:SS.mmm LEVEL target:` header (`LogLine::format`) or, forwarded +# through `ai_server::client_log`, a `[ #]` tag ahead +# of that -- neither of which this needs to understand, since `search` +# (not `match`) finds the marker wherever it starts. +LINE_RE = re.compile( + r"iris input: action=(?P\w+) x=(?P-?[0-9.]+) y=(?P-?[0-9.]+) " + r"t=(?P[0-9]+)ms history=(?P[0-9]+)(?P.*)$" +) +# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first +# -- see `log_input_event`'s own doc for why order matters. +HIST_RE = re.compile(r"(?P[0-9]+):(?P-?[0-9.]+),(?P-?[0-9.]+)") + + +def _fmt(value: float) -> str: + """The number as `TouchScript::parse`'s own `f32::parse` would round-trip + it -- an integer without a trailing `.0` where the source was one + (every coordinate here is a physical pixel), `{:g}` otherwise so a + fractional value from a real device is not silently truncated.""" + if value == int(value): + return str(int(value)) + return f"{value:g}" + + +def convert(lines): + """Every `iris::input` line, oldest first, expanded to one `(t_ms, + action, x, y)` tuple per touch sample -- a historical sample is always + an intermediate `move`, and the event's own sample keeps its real + action (`down`/`move`/`up`/`cancel`).""" + rows = [] + for line in lines: + m = LINE_RE.search(line) + if not m: + continue + hist_count = int(m.group("hist")) + hist_matches = list(HIST_RE.finditer(m.group("rest"))) + if len(hist_matches) != hist_count: + print( + f"report_to_touch: {line.strip()!r} says history={hist_count} but " + f"holds {len(hist_matches)} samples -- skipped", + file=sys.stderr, + ) + continue + for hm in hist_matches: + rows.append( + (int(hm.group("t")), "move", float(hm.group("x")), float(hm.group("y"))) + ) + rows.append( + (int(m.group("t")), m.group("action"), float(m.group("x")), float(m.group("y"))) + ) + return rows + + +def main(): + if len(sys.argv) > 2: + print("usage: report_to_touch.py [report.txt] < report.txt", file=sys.stderr) + return 2 + text = open(sys.argv[1]) if len(sys.argv) == 2 else sys.stdin + for t_ms, action, x, y in convert(text): + print(f"{t_ms} {action} {_fmt(x)} {_fmt(y)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benches/velocity_reference.py b/benches/velocity_reference.py new file mode 100644 index 0000000..c60bdc2 --- /dev/null +++ b/benches/velocity_reference.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Compose's touch velocity tracker, transcribed independently of the Rust port. + +Same reason `fling_spline_reference.py` exists: the numbers checked into +`sense.rs`'s velocity tests must not be numbers the Rust produced. The old +estimator -- total motion over the sample span, an average -- passed every test +it had, because every one of those tests asserted the average's own definition +back at it. An average cannot tell an accelerating flick from a steady drag, and +that is exactly what Iris reported from the phone on 2026-09-07: "flinging now +actually works but is slower than Compose's immediately after releasing the +flick". + +Transcribed by hand from, and only from, the `-sources.jar` of +**androidx.compose.ui:ui-android:1.12.0** and +**androidx.compose.foundation:foundation-android:1.12.0** +(dl.google.com/dl/android/maven2), read 2026-09-07: + + * `androidx/compose/ui/input/pointer/util/VelocityTracker.kt` -- + `VelocityTracker1D.calculateVelocity`, `polyFitLeastSquares`, + `calculateImpulseVelocity`, `kineticEnergyToVelocity`, and the constants + `HistorySize = 20`, `HorizonMilliseconds = 100`, + `AssumePointerMoveStoppedMilliseconds = 40`. + * `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.kt` -- + `Lsq2VelocityTracker`, which is what the 2D `VelocityTracker` delegates to. + * `androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.android.kt` + -- the `AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled` fork. + * `androidx/compose/ui/AndroidComposeUiFlags.android.kt` -- that flag's + default, which is `false`. + * `androidx/compose/foundation/gestures/Draggable.kt` -- `sendDragStart` / + `sendDragEvent` / `sendDragStopped`, i.e. *which* samples a touch drag + feeds the tracker and where the maximum-velocity clamp is applied. + * `androidx/compose/foundation/gestures/DifferentialVelocityTracker.kt` and + `NonTouchScrollingLogic.kt` -- the Impulse strategy's only caller. + * `androidx/compose/foundation/gestures/Scrollable.kt` -- + `DefaultFlingBehavior.performFling`, for the minimum-velocity question. + +**Which strategy a touch fling actually uses, since this was the surprise.** +`Strategy.Impulse` is *not* it. `scrollable`/`draggable` release through +`DragGestureNode.sendDragStopped`, which calls the 2D `VelocityTracker`; on +Android that is `Lsq2VelocityTracker` (the framework-tracker flag defaults to +false), which is two `VelocityTracker1D(strategy = Lsq2)` -- a degree-2 +least-squares fit over **absolute positions**, whose velocity is the fitted +polynomial's derivative at the newest sample. Impulse is reached only through +`DifferentialVelocityTracker`, whose sole caller is `NonTouchScrollingLogic`: +mouse wheel and trackpad, never a finger. So this script transcribes Lsq2 and +iris ports Lsq2. `calculate_impulse_velocity` is here anyway, unused by the +printed points, because ruling it out by reading is cheaper than ruling it out +again next time somebody remembers "Compose uses impulse". + +**Which samples a touch drag feeds it.** `sendDragStart` adds the DOWN change; +every subsequent MOVE, historical samples included, is added by `sendDragEvent`. +The **UP position is never added**: `Lsq2VelocityTracker.addPointerInputChange` +wraps its two `addPosition` calls in `if (!event.changedToUpIgnoreConsumed())`, +and all the UP branch does is reset the tracker when more than 40ms have passed +since the last MOVE (b/238654963). So a finger that stops before lifting reads +as a stop, not as a decelerating tail. Positions are the raw event positions, +so the touch slop is inside the motion the tracker sees even though the list +never scrolled by it. + +Two of Compose's samples iris does *not* reproduce, both noted rather than +copied: pre-slop MOVEs (iris's `DragArbiter` is `Undecided` then too, so it +feeds none either -- these agree), and the single MOVE that *crosses* the slop, +which Compose drops because `sendDragStart` adds only the DOWN. iris feeds that +one, since it is a real measured position and dropping it would be copying a +quirk of where Compose happens to split its state machine. + +**The clamps.** Maximum: `sendDragStopped` passes +`LocalViewConfiguration.maximumFlingVelocity`, which on Android is +`ViewConfiguration.getScaledMaximumFlingVelocity()` -- 8000 dp/s. Minimum: +there is **none** on this path. `ViewConfiguration.minimumFlingVelocity` +exists in Compose's `ViewConfiguration` interface but its only use in either +artifact is `NestedScrollInteropConnection`, for View interop. +`DefaultFlingBehavior.performFling` guards with `abs(initialVelocity) > 1f` +and says why in its own comment: "we need it since spline curve gives us +NaNs". 1 px/s, not 50 dp/s. + +Run it with no arguments; it prints the sample sets and the velocities the +Rust tests assert on. +""" + +import math + +HISTORY_SIZE = 20 +HORIZON_MILLISECONDS = 100.0 +ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0 +MIN_SAMPLE_SIZE_LSQ2 = 3 + +# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s. +MAXIMUM_FLING_VELOCITY_DP_S = 8000.0 +# DefaultFlingBehavior.performFling's own threshold, in the units of the +# positions fed to the tracker -- pixels per second here. +FLING_MINIMUM_PX_S = 1.0 + + +def poly_fit_least_squares(x, y, sample_count, degree): + """`polyFitLeastSquares`: Gram-Schmidt QR, coefficients low order first.""" + if degree < 1: + raise ValueError("The degree must be at positive integer") + if sample_count == 0: + raise ValueError("At least one point must be provided") + + truncated_degree = sample_count - 1 if degree >= sample_count else degree + m = sample_count + n = truncated_degree + 1 + + # a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight. + a = [[0.0] * m for _ in range(n)] + for h in range(m): + a[0][h] = 1.0 + for i in range(1, n): + a[i][h] = a[i - 1][h] * x[h] + + q = [[0.0] * m for _ in range(n)] + r = [[0.0] * n for _ in range(n)] + for j in range(n): + w = q[j] + w[:] = a[j][:m] + for i in range(j): + z = q[i] + dot = sum(w[h] * z[h] for h in range(m)) + for h in range(m): + w[h] -= dot * z[h] + norm = math.sqrt(sum(v * v for v in w)) + inverse_norm = 1.0 / max(norm, 1e-6) + for h in range(m): + w[h] *= inverse_norm + for i in range(n): + r[j][i] = 0.0 if i < j else sum(w[h] * a[i][h] for h in range(m)) + + coefficients = [0.0] * n + for i in range(n - 1, -1, -1): + c = sum(q[i][h] * y[h] for h in range(m)) + for j in range(n - 1, i, -1): + c -= r[i][j] * coefficients[j] + coefficients[i] = c / r[i][i] + return coefficients + + +def kinetic_energy_to_velocity(kinetic_energy): + sign = 0.0 if kinetic_energy == 0.0 else math.copysign(1.0, kinetic_energy) + return sign * math.sqrt(2 * abs(kinetic_energy)) + + +def calculate_impulse_velocity(data_points, time, sample_count, is_data_differential): + """`calculateImpulseVelocity` -- not on the touch path; see the module doc.""" + work = 0.0 + start = sample_count - 1 + next_time = time[start] + for i in range(start, 0, -1): + current_time = next_time + next_time = time[i - 1] + if current_time == next_time: + continue + if is_data_differential: + delta = -data_points[i - 1] + else: + delta = data_points[i] - data_points[i - 1] + v_curr = delta / (current_time - next_time) + v_prev = kinetic_energy_to_velocity(work) + work += (v_curr - v_prev) * abs(v_curr) + if i == start: + work = work * 0.5 + return kinetic_energy_to_velocity(work) + + +def calculate_velocity(samples): + """`VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`. + + `samples` is `(time_millis, position)` oldest first, at most the last + `HISTORY_SIZE` of which the circular buffer would still be holding. + Returns units per second. + """ + held = samples[-HISTORY_SIZE:] + if not held: + return 0.0 + + data_points = [] + time = [] + newest_time, _ = held[-1] + previous_time = newest_time + for sample_time, sample_position in reversed(held): + age = float(newest_time - sample_time) + delta = abs(float(sample_time - previous_time)) + # Lsq2 walks back sample to sample; only the non-differential + # Impulse branch compares every sample against the newest one. + previous_time = sample_time + if age > HORIZON_MILLISECONDS or delta > ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS: + break + data_points.append(sample_position) + time.append(-age) + if len(data_points) == HISTORY_SIZE: + break + + if len(data_points) < MIN_SAMPLE_SIZE_LSQ2: + return 0.0 + try: + coefficients = poly_fit_least_squares(time, data_points, len(data_points), 2) + except ValueError: + return 0.0 + # The 2nd coefficient is the fitted polynomial's derivative at x = 0, + # which is the newest sample's timestamp. units/ms -> units/s. + return coefficients[1] * 1000.0 + + +def clamped(velocity, maximum): + """`VelocityTracker1D.calculateVelocity(maximumVelocity)`.""" + if velocity == 0.0 or math.isnan(velocity): + return 0.0 + return min(velocity, maximum) if velocity > 0 else max(velocity, -maximum) + + +def average(samples): + """The estimator being replaced: total motion over the span.""" + if len(samples) < 2: + return 0.0 + span = (samples[-1][0] - samples[0][0]) / 1000.0 + if span <= 0.0: + return 0.0 + return (samples[-1][1] - samples[0][1]) / span + + +# --- The three recorded sample sets the Rust tests assert on. ---------------- + +# 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it: +# the DOWN position, then one position per MOVE. The UP at t=20 adds no +# sample (see the module doc), which is why the finger sitting still for its +# last 4ms does not drag the estimate down. y only; the flick is vertical. +FLICK_120HZ = [(0, 1000.0), (4, 1040.0), (8, 1086.0), (12, 1138.0), (16, 1196.0)] + +# 2. A steady drag: 5px every 10ms for 100ms. A constant-velocity fit and an +# average must agree here -- this is the case that cannot tell the two +# estimators apart, which is why it is not the only one. +STEADY_DRAG = [(i * 10, float(i * 5)) for i in range(11)] + +# 3. A flick that accelerates into the release: 10ms apart, deltas doubling. +# This is the case the average gets wrong, and the negative control for +# the port -- reverting to the average must fail this test and only this +# kind of test. +ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (50, 54.0)] + +# 4. The two edges of the sample walk, checked here so the Rust asserts +# Compose's answer rather than iris's own reading of the rule. +# (a) An old, fast burst outside the 100ms horizon, then a slow steady +# drag: the burst must not leak into the estimate. +OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)] +# (b) The finger stops for 48ms and then lifts. The gap exceeds +# AssumePointerMoveStopped, so the walk breaks after one sample and +# there is no fling -- what stops a "park it and let go" from +# flinging at whatever speed the finger arrived with. +STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)] + +# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a +# press and two move frames, which is the fewest a fit can use. +TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)] +# ... and one move frame, which Compose cannot fit either. +ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)] + +# The phone: 1080x2424 at content_scale 2.55. +PHONE_DENSITY = 2.55 + + +def report(name, samples): + v = calculate_velocity(samples) + print(f"{name}:") + print(f" samples (t_ms, position): {samples}") + print(f" Lsq2 (Compose's touch path): {v:.4f} px/s") + print(f" average (the old estimator): {average(samples):.4f} px/s") + print(f" impulse (non-touch, for ref): ", end="") + held = list(reversed(samples[-HISTORY_SIZE:])) + newest = held[0][0] + print( + f"{calculate_impulse_velocity([p for _, p in held], [-(newest - t) for t, _ in held], len(held), False) * 1000.0:.4f} px/s" + ) + print() + + +if __name__ == "__main__": + print("Compose 1.12.0 touch velocity: VelocityTracker1D, Strategy.Lsq2,") + print("non-differential (positions), HistorySize=20, Horizon=100ms,") + print("AssumePointerMoveStopped=40ms, minSampleSize=3.\n") + report("flick-120hz.touch", FLICK_120HZ) + report("steady drag (5px/10ms)", STEADY_DRAG) + report("accelerating flick (deltas 2,4,8,16,24 per 10ms)", ACCELERATING_FLICK) + + report("old burst then steady 1px/10ms", OLD_BURST_THEN_STEADY) + report("stopped 48ms before release", STOPPED_BEFORE_RELEASE) + report("press and two move frames", TWO_MOVE_FRAMES) + report("press and one move frame", ONE_MOVE_FRAME) + + print("Clamps:") + print(f" maximum: {MAXIMUM_FLING_VELOCITY_DP_S} dp/s") + print( + f" = {MAXIMUM_FLING_VELOCITY_DP_S * PHONE_DENSITY:.1f} px/s at the phone's density {PHONE_DENSITY}" + ) + print(f" minimum: none on the fling path; DefaultFlingBehavior skips |v| <= {FLING_MINIMUM_PX_S} px/s") + print() + print("Two samples only (a press and one move, the phone's 120Hz worst case):") + print(f" Lsq2 needs 3 and answers {calculate_velocity(FLICK_120HZ[:2]):.4f} px/s") diff --git a/core/Cargo.toml b/core/Cargo.toml index a85a26d..cfe77c1 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -4,9 +4,16 @@ version.workspace = true edition.workspace = true [dependencies] -winit = { workspace = true } wgpu = { workspace = true } +# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair +# (renderer-creation error reporting, RUST.md's P0 phone-crash box) -- +# `block_on` turns that one async pop into the same synchronous call shape +# `device_limits()`'s two callers already use for `request_adapter`/ +# `request_device`, rather than making this crate's one entry point async. +pollster = { workspace = true } bytemuck ={ workspace = true } image = { workspace = true } -cosmic-text = { workspace = true } +parley = { workspace = true } +swash = { workspace = true } fxhash = { workspace = true } +accesskit = { workspace = true } diff --git a/core/assets/fonts/NERD_FONTS_LICENSE.txt b/core/assets/fonts/NERD_FONTS_LICENSE.txt new file mode 100644 index 0000000..06eb073 --- /dev/null +++ b/core/assets/fonts/NERD_FONTS_LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/core/assets/fonts/nerd_icons.ttf b/core/assets/fonts/nerd_icons.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5395b949d8da6b3bf5ef0416ee777cefc40cca2e GIT binary patch literal 992 zcmb7DO=uHQ5dQY#E=KI=C_Z4NwK*Isbm_V&0x4UL>~aUbmAZWWxMP?9`jv(Rg)pfjVM9BGYAWW1t7)_5>njSff!p6)1J1>O-Er}Zo)^5$JF!p@s3 zlvVmvdOt9NJ7gM`^l_Pz>ITjdrMlv-Z47i17x7@ATYtsasGv8nXV}9pTtOQhY~vo* zdF~CwaM_d3-FGpKB1)Jg-^CJRJmz2Qzlcl!=s@~9 { type Input; diff --git a/core/src/event/manager.rs b/core/src/event/manager.rs index 840fcb8..94a5776 100644 --- a/core/src/event/manager.rs +++ b/core/src/event/manager.rs @@ -79,6 +79,8 @@ type EventData = (E, Rc EventFn::Data<'a>> pub struct TypeEventManager { // TODO: reduce visiblity!! pub active: HashMap>, + /// This event's own input-wide state -- see [`Event::Global`]. + pub global: E::Global, map: HashMap>>, } @@ -107,6 +109,7 @@ impl Default for TypeEventManager { fn default() -> Self { Self { active: Default::default(), + global: Default::default(), map: Default::default(), } } @@ -135,6 +138,18 @@ impl TypeEventManager { )); } + /// The event lists this widget was registered with (`register`'s + /// `event` argument, one per call), without running anything. Lets a + /// caller ask "would this widget's registrations match the current + /// state" separately from actually dispatching to it -- used by + /// `sense.rs` to decide whether a widget genuinely consumes a scroll + /// or press this frame (so a lower layer can still receive it if not) + /// without that decision being conflated with "the cursor happens to + /// be over it," which is all `run_fn` running something tells you. + pub fn registered(&self, id: WidgetId) -> impl Iterator { + self.map.get(&id).into_iter().flatten().map(|(e, _)| e) + } + pub fn run_fn<'a>( &mut self, id: impl IdLike, diff --git a/core/src/event/mod.rs b/core/src/event/mod.rs index 7b038f4..5619fb4 100644 --- a/core/src/event/mod.rs +++ b/core/src/event/mod.rs @@ -9,6 +9,20 @@ pub use rsc::*; pub trait Event: Sized + 'static + Clone { type Data<'a>: Clone = (); type State: Default = (); + /// State this event owns that belongs to no single widget -- what the + /// thing dispatching the event knows about the *input*, rather than + /// about a listener. `()` for almost every event; the cursor's is + /// `iris::sense::PointerInput` (which widget holds pointer capture, + /// and who is tracking the press in flight). + /// + /// It lives here so that such state has one owner, reached by `&mut` + /// through the event manager, instead of being parked on whatever + /// structure a handler happens to be able to reach and guarded with a + /// lock. Iris asked for that on 2026-09-08, of the pointer capture + /// that used to sit in a `Mutex` on `UiRenderState`: "everything + /// global should be stored in the general input handler, not in + /// specific senses with locking stuff." + type Global: Default = (); #[allow(unused_variables)] fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { Some(data.clone()) diff --git a/core/src/icon.rs b/core/src/icon.rs new file mode 100644 index 0000000..b26e833 --- /dev/null +++ b/core/src/icon.rs @@ -0,0 +1,39 @@ +//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships. +//! +//! **Why a bundled font rather than ordinary Unicode**: the disclosure +//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform +//! resolved, and once iris stopped bundling fonts (DECISIONS.md, +//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a +//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is +//! also what the Compose app has always done for its icons +//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design +//! family, so an icon means the same thing in both apps. +//! +//! **Why not vector assets or drawn shapes**: an icon beside a line of +//! text wants that line's size, colour and baseline, and text gets all +//! three for free. This replaced `iris::widget::mark`, which drew the +//! triangle into a texture: correct, but one shape, and every further icon +//! would have been another bespoke rasteriser. +//! +//! Each constant here has to have a matching codepoint in +//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the +//! script did not subset is a glyph that silently isn't there. The subset +//! is the font's **Mono** face, where every glyph is one em wide and one +//! em tall, so two icons at one font size are one size without either +//! being given one -- and why an icon looks smaller than text at the same +//! size, since the glyph is drawn inside that em rather than filling it. +//! +//! Draw one with [`crate::Family::Icons`]: +//! +//! ```ignore +//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons) +//! ``` + +/// `md-menu_down` -- a filled triangle pointing down: this card is open. +pub const OPEN: &str = "\u{F035D}"; + +/// `md-menu_right` -- pointing right: this card opens. +pub const CLOSED: &str = "\u{F035F}"; + +/// `md-menu_up` -- pointing up: fold this group of cards away again. +pub const COLLAPSE: &str = "\u{F0360}"; diff --git a/core/src/lib.rs b/core/src/lib.rs index f60415a..195706a 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -2,12 +2,9 @@ #![feature(const_ops)] #![feature(const_trait_impl)] #![feature(const_convert)] -#![feature(map_try_insert)] #![feature(unboxed_closures)] #![feature(fn_traits)] -#![feature(const_cmp)] #![feature(const_destruct)] -#![feature(portable_simd)] #![feature(associated_type_defaults)] #![feature(unsize)] #![feature(coerce_unsized)] @@ -22,6 +19,7 @@ mod render; mod ui; mod widget; +pub mod icon; pub mod util; pub use attr::*; diff --git a/core/src/num.rs b/core/src/num.rs index c4ad5cd..bcd3a08 100644 --- a/core/src/num.rs +++ b/core/src/num.rs @@ -5,19 +5,19 @@ pub const trait UiNum { fn to_f32(self) -> f32; } -impl const UiNum for f32 { +const impl UiNum for f32 { fn to_f32(self) -> f32 { self } } -impl const UiNum for u32 { +const impl UiNum for u32 { fn to_f32(self) -> f32 { self as f32 } } -impl const UiNum for i32 { +const impl UiNum for i32 { fn to_f32(self) -> f32 { self as f32 } @@ -27,7 +27,7 @@ pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 { Vec2::new(x.to_f32(), y.to_f32()) } -impl const From for Vec2 { +const impl From for Vec2 { fn from(v: T) -> Self { Self { x: v.to_f32(), @@ -36,7 +36,7 @@ impl const From for Vec2 { } } -impl const From<(T, U)> for Vec2 +const impl From<(T, U)> for Vec2 where (T, U): const Destruct, { diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs index 4fe79d2..876208a 100644 --- a/core/src/orientation/align.rs +++ b/core/src/orientation/align.rs @@ -187,7 +187,7 @@ impl From for Align { } } -impl const From for UiVec2 { +const impl From for UiVec2 { fn from(align: RegionAlign) -> Self { Self::rel(align.rel()) } diff --git a/core/src/orientation/axis.rs b/core/src/orientation/axis.rs index 0f31958..fa1f16a 100644 --- a/core/src/orientation/axis.rs +++ b/core/src/orientation/axis.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Copy, Clone, Eq, PartialEq)] +#[derive(Copy, Clone, Eq, PartialEq, Debug)] pub enum Axis { X, Y, @@ -74,14 +74,14 @@ pub const trait AxisT { } pub struct XAxis; -impl const AxisT for XAxis { +const impl AxisT for XAxis { fn get() -> Axis { Axis::X } } pub struct YAxis; -impl const AxisT for YAxis { +const impl AxisT for YAxis { fn get() -> Axis { Axis::Y } diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 8725211..599e37b 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -9,7 +9,31 @@ pub struct Size { #[derive(Debug, Clone, Copy, PartialEq)] pub struct Len { + /// Physical pixels -- a raw device pixel, unaffected by the display's + /// density. Rare to want directly (a hairline border is the usual + /// case); most sizes should be `dp` instead. See `dp`'s own doc for why + /// the two are kept separate rather than one field a caller has to + /// remember to pre-multiply. pub abs: f32, + /// Density-independent pixels -- Android's `dp` / CSS's reference pixel + /// (1 unit = 1/160in), resolved against the display's density at + /// layout time (`apply_rest`'s `density` parameter) rather than at the + /// point a widget is built, since density is a property of the device + /// this ends up running on, not of the widget tree. This is the unit + /// IRIS_TODO.md's "a density-independent length unit" item asked for, + /// 2026-09-06: before it existed, every size in the tree was `abs` + /// (physical pixels), and the only way to make a 16px design draw at + /// the right *size* on a denser display was a single global multiply + /// applied to the whole rendered scene after layout -- which is also + /// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn + /// at logical size and stretched by the scale"): a glyph rasterised at + /// 16 physical px and then stretched 3x by that global multiply is a + /// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at + /// layout time instead means the font size handed to the text shaper + /// is already the physical size (`16.0.dp() * 3.0`), so the glyph + /// atlas rasterises at the display's real resolution and nothing + /// downstream needs to stretch anything. + pub dp: f32, pub rel: f32, pub rest: f32, } @@ -67,10 +91,10 @@ impl Size { } } - pub fn to_uivec2(self) -> UiVec2 { + pub fn to_uivec2(self, density: f32) -> UiVec2 { UiVec2 { - x: self.x.apply_rest(), - y: self.y.apply_rest(), + x: self.x.apply_rest(density), + y: self.y.apply_rest(density), } } @@ -98,26 +122,66 @@ impl Size { impl Len { pub const ZERO: Self = Self { abs: 0.0, + dp: 0.0, rel: 0.0, rest: 0.0, }; pub const REST: Self = Self { abs: 0.0, + dp: 0.0, rel: 0.0, rest: 1.0, }; - pub fn apply_rest(&self) -> UiScalar { + /// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against + /// `density` (physical pixels per dp -- 1.0 on a desktop or an + /// unscaled display, `content_scale` on Android; see `dp`'s field + /// doc). Every other component of `Len` is already resolution- + /// independent (`rel` is a fraction of the parent; `rest` becomes a + /// fraction too, below), so `density` only ever touches this one term. + pub fn apply_rest(&self, density: f32) -> UiScalar { UiScalar { rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 }, - abs: self.abs, + abs: self.abs + self.dp * density, + } + } + + /// The same fold as [`Self::apply_rest`] but staying a `Len`, so + /// `rest` survives: `dp` becomes physical pixels and every other + /// component is left alone. + /// + /// **A `Len` a widget *reports* must have been through this.** `dp` is + /// an input unit -- a number the widget author wrote -- and the + /// containers that consume a reported length read `abs`/`rel`/`rest` + /// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition), + /// so a reported `dp` is silently worth zero. That is what made the + /// composer's bar collapse to nothing the moment its content grew past + /// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved, + /// so the bar was given a slot of 0 and the field inside it was panned + /// out of a container measured at -63px. `UiRenderState::draw_inner` + /// debug-asserts the invariant after every `Widget::draw`. + pub fn fold_dp(&self, density: f32) -> Self { + Self { + abs: self.abs + self.dp * density, + dp: 0.0, + rel: self.rel, + rest: self.rest, } } pub fn abs(abs: impl UiNum) -> Self { Self { abs: abs.to_f32(), + dp: 0.0, + rel: 0.0, + rest: 0.0, + } + } + pub fn dp(dp: impl UiNum) -> Self { + Self { + abs: 0.0, + dp: dp.to_f32(), rel: 0.0, rest: 0.0, } @@ -125,6 +189,7 @@ impl Len { pub fn rel(rel: impl UiNum) -> Self { Self { abs: 0.0, + dp: 0.0, rel: rel.to_f32(), rest: 0.0, } @@ -132,6 +197,7 @@ impl Len { pub fn rest(ratio: impl UiNum) -> Self { Self { abs: 0.0, + dp: 0.0, rel: 0.0, rest: ratio.to_f32(), } @@ -144,6 +210,15 @@ pub mod len_fns { pub fn abs(abs: impl UiNum) -> Len { Len { abs: abs.to_f32(), + dp: 0.0, + rel: 0.0, + rest: 0.0, + } + } + pub fn dp(dp: impl UiNum) -> Len { + Len { + abs: 0.0, + dp: dp.to_f32(), rel: 0.0, rest: 0.0, } @@ -151,6 +226,7 @@ pub mod len_fns { pub fn rel(rel: impl UiNum) -> Len { Len { abs: 0.0, + dp: 0.0, rel: rel.to_f32(), rest: 0.0, } @@ -158,14 +234,15 @@ pub mod len_fns { pub fn rest(ratio: impl UiNum) -> Len { Len { abs: 0.0, + dp: 0.0, rel: 0.0, rest: ratio.to_f32(), } } } -impl_op!(Len Add add; abs rel rest); -impl_op!(Len Sub sub; abs rel rest); +impl_op!(Len Add add; abs dp rel rest); +impl_op!(Len Sub sub; abs dp rel rest); impl_op!(Size Add add; x y); impl_op!(Size Sub sub; x y); @@ -187,6 +264,9 @@ impl std::fmt::Display for Len { if self.abs != 0.0 { write!(f, "{} abs;", self.abs)?; } + if self.dp != 0.0 { + write!(f, "{} dp;", self.dp)?; + } if self.rel != 0.0 { write!(f, "{} rel;", self.rel)?; } diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 204e048..466022e 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -124,13 +124,13 @@ impl Display for UiVec2 { impl_op!(UiVec2 Add add; x y); impl_op!(UiVec2 Sub sub; x y); -impl const From for UiVec2 { +const impl From for UiVec2 { fn from(abs: Vec2) -> Self { Self::abs(abs) } } -impl const From<(T, U)> for UiVec2 +const impl From<(T, U)> for UiVec2 where (T, U): const Destruct, { @@ -421,7 +421,7 @@ impl Display for UiRegion { } } -#[derive(Debug)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct PixelRegion { pub top_left: Vec2, pub bot_right: Vec2, diff --git a/core/src/primitive/color.rs b/core/src/primitive/color.rs index 1681c2b..2afb7a2 100644 --- a/core/src/primitive/color.rs +++ b/core/src/primitive/color.rs @@ -10,6 +10,15 @@ pub struct Color { pub a: T, } +/// Required by parley's `Brush`, which every text style is generic over. Opaque +/// black rather than transparent: a brush that was never set should be visible +/// and obviously unstyled, not invisible. +impl Default for Color { + fn default() -> Self { + Self::BLACK + } +} + impl Color { pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN); pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX); @@ -144,7 +153,7 @@ impl ColorNum for f32 { unsafe impl bytemuck::Pod for Color {} -impl const F32Conversion for f32 { +const impl F32Conversion for f32 { fn to(self) -> f32 { self } @@ -153,7 +162,7 @@ impl const F32Conversion for f32 { } } -impl const F32Conversion for u8 { +const impl F32Conversion for u8 { fn to(self) -> f32 { self as f32 / 255.0 } diff --git a/core/src/primitive/layer.rs b/core/src/primitive/layer.rs index 54f6a96..1db6ac6 100644 --- a/core/src/primitive/layer.rs +++ b/core/src/primitive/layer.rs @@ -1,9 +1,6 @@ use std::ops::{Index, IndexMut}; -use crate::{ - render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives}, - util::to_mut, -}; +use crate::{render::LayerOrder, util::to_mut}; pub type LayerId = usize; @@ -39,7 +36,10 @@ struct Child { tail: usize, } -pub type PrimitiveLayers = Layers; +/// The draw order of every layer. The primitives themselves live in one +/// arena beside this (`UiRenderState::primitives`); a layer names the +/// slots it draws, which is what its vertex buffer is. +pub type PrimitiveLayers = Layers; impl Layers { pub fn new() -> Layers { @@ -119,20 +119,6 @@ impl Layers { } } -impl PrimitiveLayers { - pub fn write( - &mut self, - layer: LayerId, - info: PrimitiveInst

, - ) -> PrimitiveHandle { - self[layer].write(layer, info) - } - - pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx { - self[h.layer].free(h) - } -} - impl Default for Layers { fn default() -> Self { Self::new() diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 820d1f0..1bc24e7 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -1,60 +1,444 @@ -use crate::{Align, RegionAlign, TextureHandle, Textures, UiColor, util::Vec2}; -use cosmic_text::{ - Attrs, AttrsList, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Placement, SwashCache, - SwashContent, +use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2}; +use parley::{ + Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight, + GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, + fontique::Blob, +}; +use std::ops::Range; +use std::sync::Arc; +use swash::{ + FontRef, + scale::{Render, ScaleContext, Source, StrikeWith}, + zeno::{Format, Vector}, }; -use image::{DynamicImage, GenericImageView, RgbaImage}; -use std::simd::{Simd, num::SimdUint}; -/// TODO: properly wrap this -pub mod text_lib { - pub use cosmic_text::*; +/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built +/// by `iris/core/build-icon-font.sh`, holding only the codepoints +/// `crate::icon` names (992 bytes for three glyphs today). +/// +/// This is the one font bundled here, and it is not a text font: body and +/// monospace text still come from the platform's own collection +/// (DECISIONS.md, 2026-09-07). An icon is the opposite case -- a small, +/// closed set of codepoints no system font is guaranteed to have -- which +/// is the same division the Compose app makes. +const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); + +/// What starting up found about text rendering, for the on-screen +/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log +/// once at startup ... the number of font families found, the default +/// family resolved"). Built once by `TextData::font_diagnostics` -- +/// `Default::default` still exists for callers (tests, examples) that +/// don't need the report. +#[derive(Clone, Debug)] +pub struct FontDiagnostics { + /// `Collection::family_names().count()` after registering the bundled + /// fonts -- system families plus the two bundled ones. + pub families_found: usize, + /// The family `GenericFamily::SansSerif` resolves to first -- the + /// bundled "Noto Sans" unless registration itself failed. + pub default_family: Option, + /// The family `GenericFamily::Monospace` resolves to first. + pub default_mono_family: Option, + /// One resolved family name per style axis this crate actually uses + /// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a + /// bold/italic request is landing on a real face rather than being + /// silently absorbed by whatever the sans-serif default resolves to + /// for every weight (RUST.md's P0 box, "bold words render as blank + /// gaps" -- a family that resolves but has no distinct bold face is + /// exactly what produced that). + pub regular_resolved: Option, + pub bold_resolved: Option, + pub italic_resolved: Option, + pub mono_resolved: Option, + /// The family the bundled icon font registered under, or `None` if + /// registering it failed. Reported rather than assumed: it is the one + /// font iris ships, so `None` is a broken build and must not look + /// like a device that happens to lack a face. + pub icon_family: Option, } +/// Everything text needs that outlives one string: the font collection, the +/// layout scratch space, the glyph rasteriser and the atlas they fill. pub struct TextData { - pub font_system: FontSystem, - pub swash_cache: SwashCache, - glyph_cache: Vec<(Placement, CacheKey, Color)>, + pub font_cx: FontContext, + pub layout_cx: LayoutContext, + scale_cx: ScaleContext, + pub atlas: GlyphAtlas, + /// Physical pixels per dp -- a second copy of + /// `UiRenderState::density`, kept here too because `TextEditCtx::layout` + /// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text + /// from an event callback that has a `TextData` but no `Painter`, so it + /// has nowhere else to read the display's density from. Both copies are + /// set together, from the one place either backend learns the real + /// value (`android::view::new_peer`); this is the same accepted + /// duplication as `AndroidRenderer::content_scale`; a single source of + /// truth would mean carrying a `Painter` (or output size) into every + /// input handler for the sake of one field. + pub density: f32, + /// The family name [`NERD_ICONS`] registered under, which is what + /// [`Family::Icons`] resolves to. `None` only if registering the + /// bundled font failed, which is a broken build rather than a + /// platform difference -- said in the startup diagnostics rather than + /// silently drawn as tofu. + pub icon_family: Option, } impl Default for TextData { + /// Text comes entirely from the platform's own font collection -- + /// `FontContext::new()` builds a `fontique::Collection` with + /// `CollectionOptions::system_fonts` on by default, which is real + /// discovery on both targets this crate ships on: Android's backend + /// parses `/system/fonts` and `/system/etc/fonts.xml` and maps + /// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]` + /// and `Monospace` to the platform's `"monospace"` alias; the desktop + /// build's backend is fontconfig. No font is bundled or registered + /// here -- see DECISIONS.md's 2026-09-07 entry for why (matching what + /// the Compose app does: it takes body/monospace text from + /// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto + /// and its platform monospace face, and ships no text font of its own, + /// only its committed Nerd Fonts icon subset for fixed glyphs). fn default() -> Self { + let mut font_cx = FontContext::new(); + patch_android_monospace(&mut font_cx); + let icon_family = register_icon_font(&mut font_cx); Self { - font_system: FontSystem::new(), - swash_cache: SwashCache::new(), - glyph_cache: Default::default(), + font_cx, + layout_cx: LayoutContext::new(), + scale_cx: ScaleContext::new(), + atlas: GlyphAtlas::default(), + density: 1.0, + icon_family, } } } -#[derive(Clone, Copy)] +/// Registers the bundled icon font as an ordinary named family and +/// answers the name it registered under -- read back from the collection +/// rather than written down here, so the name cannot drift from the file +/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release +/// ships). +/// +/// A *named* family rather than a generic one: nothing should fall back +/// to it for ordinary text, and nothing should fall back out of it for an +/// icon -- a system face that happens to have one of these codepoints +/// would draw somebody else's picture. +fn register_icon_font(font_cx: &mut FontContext) -> Option { + let blob = Blob::new(Arc::new(NERD_ICONS)); + let id = font_cx + .collection + .register_fonts(blob, None) + .into_iter() + .map(|(id, _)| id) + .next()?; + font_cx.collection.family_name(id).map(str::to_string) +} + +/// Works around `fontique` 0.11.1's Android backend never resolving +/// `GenericFamily::Monospace` (confirmed against +/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still +/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no +/// released fix to bump to yet -- see DECISIONS.md's 2026-09-07 entry, +/// "Platform fonts," for the full account). Two bugs stack, not one: +/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before* +/// `fonts.xml` is parsed into that same name map, and even after parsing, +/// AOSP's `fonts.xml` names it with a `` element +/// (not an ``) whose `` children the backend's own parser +/// does not read (a `TODO` in that match arm) -- so the name gets a +/// `FamilyId` with no font data behind it, and `family_by_name("monospace")` +/// comes back empty too. Confirmed on this checkout's emulator: `adb pull +/// /system/etc/fonts.xml` shows +/// `DroidSansMono.ttf` with no matching +/// alias. +/// +/// So this reads `fonts.xml` itself (already on-device, already the +/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the +/// filename that declaration names, then finds which of fontique's +/// *actually* scanned families (from `/system/fonts`, which do carry real +/// font data, just under whatever name the font's own metadata gives it -- +/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font +/// file with that name, and registers that family as the `Monospace` +/// generic the way the backend itself would have if its parser had reified +/// the declaration. A no-op if the family is somehow already resolved +/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless +/// test, or a device that names it some other way). +#[cfg(target_os = "android")] +fn patch_android_monospace(font_cx: &mut FontContext) { + use parley::fontique::SourceKind; + + let already_resolved = font_cx + .collection + .generic_families(GenericFamily::Monospace) + .next() + .is_some(); + if already_resolved { + return; + } + let Some(target_file) = android_monospace_font_filename() else { + return; + }; + let names: Vec = font_cx + .collection + .family_names() + .map(str::to_string) + .collect(); + for name in names { + let Some(id) = font_cx.collection.family_id(&name) else { + continue; + }; + let Some(info) = font_cx.collection.family(id) else { + continue; + }; + let Some(font) = info.default_font() else { + continue; + }; + let SourceKind::Path(path) = font.source().kind() else { + continue; + }; + if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) { + font_cx + .collection + .append_generic_families(GenericFamily::Monospace, std::iter::once(id)); + return; + } + } +} + +/// Reads the font filename `fonts.xml` names for its `"monospace"` family +/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a +/// real XML parser -- a new dependency for one well-known, stable AOSP file +/// whose structure fontique itself already parses with a full parser one +/// module over. Not a general XML reader; assumes the file has exactly one +/// `` element with at least one `` child, +/// which is the format on every AOSP `fonts.xml` this was checked against. +#[cfg(target_os = "android")] +fn android_monospace_font_filename() -> Option { + let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string()); + let xml = + std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?; + let family_start = xml.find("")?; + let block = &xml[family_start..]; + let block = &block[..block.find("")?]; + let font_tag = block.find("')? + 1; + let content = &after_tag[content_start..]; + let filename = content[..content.find('<')?].trim(); + (!filename.is_empty()).then(|| filename.to_string()) +} + +#[cfg(not(target_os = "android"))] +fn patch_android_monospace(_font_cx: &mut FontContext) {} + +impl TextData { + /// [`Family::Icons`] as the name the bundled font actually registered + /// under; everything else unchanged. + /// + /// Cloned rather than borrowed because the caller needs it while the + /// layout builder holds `&mut self` -- a `String` per shaped icon run, + /// paid only when the layout is rebuilt. + pub fn resolve_family(&self, family: &Family) -> Family { + match family { + Family::Icons => self + .icon_family + .clone() + .map_or(Family::Icons, Family::Named), + other => other.clone(), + } + } + + /// Builds the startup report -- see `FontDiagnostics`. Queries the + /// collection directly (`fontique::Query`) rather than shaping a real + /// string, since all that's needed is which family each axis lands on. + pub fn font_diagnostics(&mut self) -> FontDiagnostics { + use parley::fontique::{Attributes, FontWidth, QueryStatus}; + let families_found = self.font_cx.collection.family_names().count(); + let default_family_id = self + .font_cx + .collection + .generic_families(GenericFamily::SansSerif) + .next(); + let default_family = default_family_id + .and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string)); + let default_mono_family_id = self + .font_cx + .collection + .generic_families(GenericFamily::Monospace) + .next(); + let default_mono_family = default_mono_family_id + .and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string)); + + // Resolves the family a (generic family, weight, style) query lands + // on, without holding the `Query`'s borrow of `collection` across + // the `family_name` lookup that needs it back -- the `FamilyId` is + // captured out of the closure first, then looked up once `query` + // (and its borrow) has been dropped. + let mut resolve_family = + |generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option { + let mut family_id = None; + { + let mut query = self + .font_cx + .collection + .query(&mut self.font_cx.source_cache); + query.set_families([generic]); + query.set_attributes(Attributes { + width: FontWidth::NORMAL, + style, + weight, + }); + query.matches_with(|font| { + family_id = Some(font.family.0); + QueryStatus::Stop + }); + } + family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string)) + }; + + let regular_resolved = resolve_family( + GenericFamily::SansSerif, + FontWeight::NORMAL, + FontStyle::Normal, + ); + let bold_resolved = resolve_family( + GenericFamily::SansSerif, + FontWeight::BOLD, + FontStyle::Normal, + ); + let italic_resolved = resolve_family( + GenericFamily::SansSerif, + FontWeight::NORMAL, + FontStyle::Italic, + ); + let mono_resolved = resolve_family( + GenericFamily::Monospace, + FontWeight::NORMAL, + FontStyle::Normal, + ); + + FontDiagnostics { + families_found, + default_family, + default_mono_family, + regular_resolved, + bold_resolved, + italic_resolved, + mono_resolved, + icon_family: self.icon_family.clone(), + } + } +} + +/// Which family to ask for. Kept as an owned name rather than parley's +/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime. +#[derive(Clone, PartialEq)] +pub enum Family { + SansSerif, + Serif, + Monospace, + /// The bundled icon font -- see [`crate::icon`] for what is in it. + /// Named as an intention rather than as a font name because only + /// [`TextData`] knows what the file registered as; it resolves this + /// during shaping ([`TextData::resolve_family`]). + Icons, + Named(String), +} + +impl Family { + fn family(&self) -> FontFamily<'_> { + let name = match self { + Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif), + Self::Serif => FontFamilyName::Generic(GenericFamily::Serif), + Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace), + // Only reachable if `resolve_family` did not run, which no + // shaping path allows -- and sans-serif is the honest answer + // for a build whose icon font failed to register: the reader + // gets the platform's own tofu rather than a wrong picture. + Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif), + Self::Named(name) => FontFamilyName::Named(name.as_str().into()), + }; + FontFamily::Single(name) + } +} + +/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style +/// over `range` (a byte range into the buffer's text). Every field is +/// optional so a span only says what it changes -- e.g. a link span sets +/// `color` and `underline` and leaves weight/family at the paragraph's own +/// default. This is I5's answer to RUST.md's inline-rich-text ceiling +/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the +/// whole editor, with `// TODO: RichTextInput` beside it): parley's own +/// `RangedBuilder::push` already takes a style and a range, so per-span +/// bold/italic/monospace/colour/underline only needed plumbing this struct +/// through to it and giving each glyph its own colour at draw time (see +/// `PlacedGlyph::color` and `TextData::place` below) instead of the one +/// `RenderedText::color` every glyph used to share. +#[derive(Clone, PartialEq)] +pub struct SpanStyle { + pub range: Range, + pub color: Option, + pub family: Option, + /// Overrides `TextAttrs::font_size` for just this range -- what lets a + /// heading inside a transcript row's single `TextEdit` be bigger than + /// the paragraph text around it, so a whole markdown-folded row (block + /// and inline styling both) can stay one selectable text buffer instead + /// of one widget per block. + pub font_size: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, +} + +impl SpanStyle { + pub fn new(range: Range) -> Self { + Self { + range, + color: None, + family: None, + font_size: None, + bold: false, + italic: false, + underline: false, + } + } + pub fn color(mut self, color: UiColor) -> Self { + self.color = Some(color); + self + } + pub fn family(mut self, family: Family) -> Self { + self.family = Some(family); + self + } + pub fn font_size(mut self, size: f32) -> Self { + self.font_size = Some(size); + self + } + pub fn bold(mut self) -> Self { + self.bold = true; + self + } + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + pub fn underline(mut self) -> Self { + self.underline = true; + self + } +} + +#[derive(Clone, PartialEq)] pub struct TextAttrs { pub color: UiColor, pub font_size: f32, pub line_height: f32, - pub family: Family<'static>, + pub family: Family, pub wrap: bool, /// inner alignment of text region (within where it's drawn) pub align: RegionAlign, } -impl TextAttrs { - pub fn apply(&self, font_system: &mut FontSystem, buf: &mut Buffer, width: Option) { - buf.set_metrics_and_size( - font_system, - Metrics::new(self.font_size, self.line_height), - width, - None, - ); - let attrs = Attrs::new().family(self.family); - let list = AttrsList::new(&attrs); - for line in &mut buf.lines { - line.set_attrs_list(list.clone()); - } - } -} - -pub type TextBuffer = Buffer; +pub const LINE_HEIGHT_MULT: f32 = 1.1; impl Default for TextAttrs { fn default() -> Self { @@ -70,122 +454,324 @@ impl Default for TextAttrs { } } -pub const LINE_HEIGHT_MULT: f32 = 1.1; +/// A string together with its laid-out form. +/// +/// The text and the layout live in one place because parley's `Layout` borrows +/// nothing but is only meaningful against the string it was built from: keeping +/// them apart is how they get out of step. +pub struct TextBuffer { + text: String, + layout: Layout, + spans: Vec, + /// What the current layout was built for, so `shape` can decline to redo + /// work that would come out the same. Spans are not part of this key -- + /// `set_spans` forces `shaped` to `None` directly, the same way `edit` + /// does, since spans change far less often than a naive equality check + /// on the whole `Vec` would cost to compute every frame. + shaped: Option<(TextAttrs, Option, f32)>, +} + +impl TextBuffer { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + layout: Layout::new(), + spans: Vec::new(), + shaped: None, + } + } + + /// Replace this buffer's per-range style overrides (I5's rich text -- + /// see `SpanStyle`). Invalidates the layout unconditionally, mirroring + /// `set_text`. + pub fn set_spans(&mut self, spans: Vec) { + self.spans = spans; + self.shaped = None; + } + + pub fn new_empty() -> Self { + Self::new("") + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn layout(&self) -> &Layout { + &self.layout + } + + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub fn set_text(&mut self, text: impl Into) { + let text = text.into(); + if text != self.text { + self.text = text; + self.shaped = None; + } + } + + /// Edit the string in place; invalidates the layout unconditionally, since + /// the caller is assumed to have changed something. + pub fn edit(&mut self) -> &mut String { + self.shaped = None; + &mut self.text + } + + pub fn size(&self) -> Vec2 { + Vec2::new(self.layout.width(), self.layout.height()) + } + + /// Lay the text out, unless it is already laid out for these + /// attributes, this width and this density. + /// + /// **`attrs.font_size`/`line_height` and every span's own `font_size` + /// are density-independent (dp) units, multiplied by `density` here -- + /// the one place text crosses from the widget tree's dp sizes into the + /// physical pixels the shaper and rasteriser (`TextData::place`) both + /// then work in.** This is what makes glyphs sharp on a dense display: + /// before this existed, `font_size` was already a physical-pixel value + /// (RUST.md's P0 box's global-scale stopgap resolved density by + /// stretching the whole rendered frame afterward instead), so a glyph + /// was rasterised small and then upscaled by whatever the display's + /// scale factor was -- exactly the blur Iris's report described. + /// Multiplying here instead means the font size hitting `ScaleContext` + /// in `place` below is already the display's real physical size, so + /// the atlas holds a bitmap at the resolution it is actually shown at. + /// `GlyphKey.size` already keys on that resolved `font_size` + /// (`(font_size * 16.0).round()`), so a cache entry is naturally per + /// physical size with no change needed there. + pub fn shape( + &mut self, + data: &mut TextData, + attrs: &TextAttrs, + width: Option, + density: f32, + ) { + if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { + return; + } + // Resolved before the builder borrows `data`: `Family::Icons` + // names an intention, and the name behind it lives on `TextData`. + let base_family = data.resolve_family(&attrs.family); + let span_families: Vec> = self + .spans + .iter() + .map(|span| span.family.as_ref().map(|f| data.resolve_family(f))) + .collect(); + let mut builder = data + .layout_cx + .ranged_builder(&mut data.font_cx, &self.text, 1.0, true); + builder.push_default(StyleProperty::FontFamily(base_family.family())); + builder.push_default(StyleProperty::FontSize(attrs.font_size * density)); + builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( + attrs.line_height * density, + ))); + builder.push_default(StyleProperty::Brush(attrs.color)); + for (span, family) in self.spans.iter().zip(&span_families) { + let range = span.range.clone(); + if let Some(color) = span.color { + builder.push(StyleProperty::Brush(color), range.clone()); + } + if let Some(family) = family { + builder.push(StyleProperty::FontFamily(family.family()), range.clone()); + } + if let Some(size) = span.font_size { + builder.push(StyleProperty::FontSize(size * density), range.clone()); + } + if span.bold { + builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); + } + if span.italic { + builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone()); + } + if span.underline { + builder.push(StyleProperty::Underline(true), range.clone()); + } + } + builder.build_into(&mut self.layout, &self.text); + self.layout.break_all_lines(width); + self.layout + .align(Alignment::Start, AlignmentOptions::default()); + self.shaped = Some((attrs.clone(), width, density)); + } +} impl TextData { - pub fn draw( + /// Rasterise whatever of `buffer` is not in the atlas yet, and return where + /// each glyph goes relative to the text's top-left. + /// + /// Nothing is uploaded for a glyph already in the atlas, which is the point + /// of having one: a resize re-runs this and touches the GPU only if the new + /// width brought genuinely new glyphs into view. + pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec { + let mut placed = Vec::new(); + for line in buffer.layout.lines() { + for item in line.items() { + let PositionedLayoutItem::GlyphRun(run) = item else { + continue; + }; + let font = run.run().font(); + let font_size = run.run().font_size(); + let coords = run.run().normalized_coords(); + let run_color = run.style().brush; + let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize) + else { + continue; + }; + let coords_hash = hash_coords(coords); + // `font.data.id()` rather than the pointer, so the same font + // loaded twice is still one set of entries. + let font_id = font.data.id(); + + for glyph in run.positioned_glyphs() { + let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8; + let key = GlyphKey { + font: font_id, + glyph: glyph.id, + size: (font_size * 16.0).round() as u32, + subpixel, + coords: coords_hash, + }; + let entry = match self.atlas.get(&key) { + Some(entry) => entry, + None => { + let mut scaler = self + .scale_cx + .builder(font_ref) + .size(font_size) + .hint(true) + .normalized_coords(coords) + .build(); + let image = Render::new(&[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ]) + .format(Format::Alpha) + .offset(Vector::new(subpixel as f32 / 4.0, 0.0)) + .render(&mut scaler, glyph.id as u16); + match image { + Some(image) => self.atlas.insert(key, &image, textures), + None => { + self.atlas.insert_empty(key); + None + } + } + } + }; + let Some(entry) = entry else { continue }; + placed.push(PlacedGlyph { + entry, + offset: Vec2::new( + glyph.x.floor() + entry.left as f32, + glyph.y.floor() - entry.top as f32, + ), + color: run_color, + }); + } + } + } + placed + } +} + +fn hash_coords(coords: &[i16]) -> u64 { + // FxHash over the coordinates; they are short and change rarely. + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for c in coords { + h ^= *c as u16 as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + h +} + +/// A laid-out string, ready to draw: where each glyph goes and how big the +/// whole thing is. +/// +/// Cheap to clone and to keep, which is the point -- a widget holds one across +/// frames and re-emits its quads without going near the rasteriser. `color` +/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants +/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is +/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can +/// override per range. +#[derive(Clone)] +pub struct RenderedText { + pub glyphs: std::sync::Arc>, + pub size: Vec2, + pub color: UiColor, + /// The [`GlyphAtlas::generation`] the glyphs above were placed against. + /// A holder must re-render rather than re-emit these quads once the + /// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens + /// otherwise); `Painter::glyphs` debug-asserts it. + pub generation: u64, +} + +impl TextData { + /// Lay out and place in one step, which is what a widget wants. + pub fn render( &mut self, buffer: &mut TextBuffer, attrs: &TextAttrs, + width: Option, textures: &mut Textures, + density: f32, ) -> RenderedText { - // TODO: either this or the layout stuff (or both) is super slow, - // should probably do texture packing and things if possible. - // very visible if you add just a couple of wrapping texts and resize window - // should also be timed to figure out exactly what points need to be sped up - // let mut pixels = HashMap::<_, [u8; 4]>::default(); - let mut min_x = 0; - let mut min_y = 0; - let mut max_x = 0; - let mut max_y = 0; - let text_color = { - let c = attrs.color; - cosmic_text::Color::rgba(c.r, c.g, c.b, c.a) - }; - let mut max_width = 0.0f32; - let mut height = 0.0; - - for run in buffer.layout_runs() { - for glyph in run.glyphs.iter() { - let physical_glyph = glyph.physical((0., 0.), 1.0); - - let glyph_color = match glyph.color_opt { - Some(some) => some, - None => text_color, - }; - - if let Some(img) = self - .swash_cache - .get_image(&mut self.font_system, physical_glyph.cache_key) - { - let mut pos = img.placement; - pos.left += physical_glyph.x; - pos.top = physical_glyph.y + run.line_y as i32 - pos.top; - min_x = min_x.min(pos.left); - min_y = min_y.min(pos.top); - max_x = max_x.max(pos.left + pos.width as i32); - max_y = max_y.max(pos.top + pos.height as i32); - self.glyph_cache - .push((pos, physical_glyph.cache_key, glyph_color)); - } - } - max_width = max_width.max(run.line_w); - height += run.line_height; - } - let img_width = (max_x - min_x + 1) as u32; - let img_height = (max_y - min_y + 1) as u32; - let mut image = RgbaImage::new(img_width, img_height); - - for (pos, key, color) in self.glyph_cache.drain(..) { - let img = self - .swash_cache - .get_image(&mut self.font_system, key) - .as_ref() - .unwrap(); - let mut merge = |i, color: [u8; 4]| { - let i = i as i32; - let x = (i % pos.width as i32 + pos.left - min_x) as u32; - let y = (i / pos.width as i32 + pos.top - min_y) as u32; - let pixel = &mut image[(x, y)].0; - // TODO: no clue if proper alpha blending should be done - *pixel = Simd::from(color).saturating_add(Simd::from(*pixel)).into(); - }; - - match img.content { - SwashContent::Mask => { - for (i, a) in img.data.iter().enumerate() { - let mut color = color.as_rgba(); - color[3] = ((color[3] as u32 * *a as u32) / u8::MAX as u32) as u8; - merge(i, color); - } - } - SwashContent::SubpixelMask => todo!("subpixel mask text rendering"), - SwashContent::Color => { - let (colors, _) = img.data.as_chunks::<4>(); - for (i, color) in colors.iter().enumerate() { - merge(i, *color); - } - } - } - } - - let max_dim = 8192; - if image.width() > max_dim || image.height() > max_dim { - let width = image.width().min(max_dim); - let height = image.height().min(max_dim); - eprintln!( - "WARNING: image of size {:?} cropped to {:?} (texture too big)", - image.dimensions(), - (width, height) - ); - image = image.view(0, 0, width, height).to_image(); - } - + buffer.shape(self, attrs, width, density); + let glyphs = self.place(buffer, textures); RenderedText { - handle: textures.add(image), - top_left_offset: Vec2::new(min_x as f32, min_y as f32), - size: Vec2::new(max_width, height), + glyphs: std::sync::Arc::new(glyphs), + size: buffer.size(), + color: attrs.color, + generation: self.atlas.generation(), } } } -#[derive(Clone)] -pub struct RenderedText { - pub handle: TextureHandle, - pub top_left_offset: Vec2, - pub size: Vec2, -} +#[cfg(test)] +mod tests { + use super::*; + use crate::icon; -pub trait HasTextures { - fn add_texture(&mut self, image: DynamicImage) -> TextureHandle; + /// Every codepoint `icon` names is actually in the subset the script + /// built. This is the failure `build-icon-font.sh`'s own comment warns + /// about -- a constant added on one side and not the other is a glyph + /// that silently isn't there -- and it is invisible at runtime, + /// because a missing glyph draws as nothing rather than as an error. + #[test] + fn every_icon_is_in_the_bundled_font() { + let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses"); + let charmap = font.charmap(); + for (name, glyph) in [ + ("OPEN", icon::OPEN), + ("CLOSED", icon::CLOSED), + ("COLLAPSE", icon::COLLAPSE), + ] { + let mut chars = glyph.chars(); + let ch = chars.next().expect("an icon is one character"); + assert!(chars.next().is_none(), "{name} is more than one character"); + assert_ne!( + charmap.map(ch), + 0, + "{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \ + build-icon-font.sh's GLYPHS and rerun the script", + ch as u32 + ); + } + } + + /// The font registers, so `Family::Icons` resolves to a real family + /// rather than falling through to sans-serif and drawing tofu. + #[test] + fn the_icon_family_registers_and_resolves() { + let data = TextData::default(); + let family = data.resolve_family(&Family::Icons); + assert!( + matches!(family, Family::Named(_)), + "the bundled icon font did not register: {:?}", + data.icon_family + ); + } } diff --git a/core/src/primitive/texture.rs b/core/src/primitive/texture.rs index 74e3bf0..04cd363 100644 --- a/core/src/primitive/texture.rs +++ b/core/src/primitive/texture.rs @@ -1,19 +1,44 @@ -use crate::{ - render::TexturePrimitive, - util::{RefCounter, Vec2}, -}; +use crate::util::{RefCounter, Vec2}; use image::{DynamicImage, GenericImageView}; use std::{ + collections::HashMap, ops::Index, sync::mpsc::{Receiver, Sender, channel}, }; +/// Which of the two things a texture slot holds. See TEXTURES.md's +/// "Recommended shape" for why these are drawn so differently: a page is a +/// layer of one shared array texture and never gets its own bind group; a +/// standalone image is the opposite, one texture and one bind group, never a +/// layer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextureKind { + Image, + /// The array-texture layer this page was assigned. Chosen synchronously + /// by `Textures::add_page` rather than by the renderer, because glyph + /// insertion needs it in the same call, before any GPU sync happens. + Page { + layer: u32, + }, +} + +/// What a [`Textures::shared`] texture is a picture of -- exactly, not by +/// hash: `owner` names the widget kind whose description it is, and `id` +/// packs that description's own fields, so two owners cannot collide and +/// a debugger shows which picture a slot holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SharedTextureKey { + pub owner: &'static str, + pub id: u64, +} + #[derive(Debug, Clone)] pub struct TextureHandle { - inner: TexturePrimitive, + slot: u32, + kind: TextureKind, size: Vec2, counter: RefCounter, - send: Sender, + send: Sender<(TextureKind, u32)>, } /// a texture manager for a ui @@ -21,22 +46,47 @@ pub struct TextureHandle { pub struct Textures { free: Vec, images: Vec>, + /// What each slot is, kept beside the image so a slot can be pushed + /// again without the handle that knows -- see [`Textures::reupload`]. + kinds: Vec, + /// Textures built from a description rather than from a file, one per + /// distinct description: see [`Textures::shared`]. The map holds a + /// reference of its own, so a shared texture outlives every widget + /// drawing it and its slot is never recycled underneath one. + shared: HashMap, + /// Next layer to hand out to an atlas page. Pages are never freed (no + /// atlas eviction), so this only grows and `free` never holds one. + next_page_layer: u32, updates: Vec, - send: Sender, - recv: Receiver, + send: Sender<(TextureKind, u32)>, + recv: Receiver<(TextureKind, u32)>, } pub enum TextureUpdate<'a> { - Push(&'a DynamicImage), - Set(u32, &'a DynamicImage), + Push(TextureKind, &'a DynamicImage), + Set(TextureKind, u32, &'a DynamicImage), + /// Overwrite a rectangle of an existing texture, rather than replacing it. + /// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas + /// per glyph is megabytes of copy for a few hundred bytes of change. + /// Only ever issued against a page -- a standalone image is never patched. + Patch(u32, PatchRect, &'a DynamicImage), Free(u32), - PushFree, + PushFree(TextureKind), SetFree, } +#[derive(Debug, Clone, Copy)] +pub struct PatchRect { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + enum Update { - Push(u32), - Set(u32), + Push(TextureKind, u32), + Set(TextureKind, u32), + Patch(u32, PatchRect), Free(u32), } @@ -46,58 +96,162 @@ impl Textures { Self { free: Vec::new(), images: Vec::new(), + kinds: Vec::new(), + shared: HashMap::new(), + next_page_layer: 0, updates: Vec::new(), send, recv, } } + pub fn add(&mut self, image: impl Into) -> TextureHandle { let image = image.into(); let size = image.dimensions().into(); - let view_idx = self.push(image); - // 0 == default in renderer; TODO: actually create samplers here - let sampler_idx = 0; + let kind = TextureKind::Image; + let slot = self.push(kind, image); TextureHandle { - inner: TexturePrimitive { - view_idx, - sampler_idx, - }, + slot, + kind, size, counter: RefCounter::new(), send: self.send.clone(), } } - fn push(&mut self, image: DynamicImage) -> u32 { + /// Adds a page of the shared glyph atlas array. Only `atlas.rs` should + /// call this -- everything else wants `add`. + pub fn add_page(&mut self, image: impl Into) -> TextureHandle { + let image = image.into(); + let size = image.dimensions().into(); + let layer = self.next_page_layer; + self.next_page_layer += 1; + let kind = TextureKind::Page { layer }; + let slot = self.push(kind, image); + TextureHandle { + slot, + kind, + size, + counter: RefCounter::new(), + send: self.send.clone(), + } + } + + fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 { if let Some(i) = self.free.pop() { self.images[i as usize] = Some(image); - self.updates.push(Update::Set(i)); + self.kinds[i as usize] = kind; + self.updates.push(Update::Set(kind, i)); i } else { let i = self.images.len() as u32; self.images.push(Some(image)); - self.updates.push(Update::Push(i)); + self.kinds.push(kind); + self.updates.push(Update::Push(kind, i)); i } } + /// The one texture for `key`, building it on the first ask and handing + /// out a further reference to it every time after. + /// + /// **Why this exists**: a texture rasterised from a *description* -- + /// `widget::mark`'s triangle, from a direction and a colour -- has as + /// many copies as there are widgets asking for it, and each copy is + /// its own GPU texture, its own bind group and its own draw call. A + /// transcript screen with a folded card per tool call built one per + /// card: hundreds of 48x48 textures of three distinct pictures, + /// created and freed again as rows recycled. `make` is not called when + /// the key is already known, so the rasterising is paid once too. + /// + /// The map keeps its own reference for the life of the `Textures`, so + /// a shared slot is never freed and never reused for something else -- + /// which is what makes a handle held by a long-lived widget safe. + pub fn shared( + &mut self, + key: SharedTextureKey, + make: impl FnOnce() -> DynamicImage, + ) -> TextureHandle { + if let Some(handle) = self.shared.get(&key) { + return handle.clone(); + } + let handle = self.add(make()); + self.shared.insert(key, handle.clone()); + handle + } + + /// The stored image for a handle, to be written into before `patch`. + pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage { + self.images[handle.slot as usize] + .as_mut() + .expect("texture was freed while still held") + } + + /// Queue an upload of just `rect`, after writing it with `image_mut`. + pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) { + self.updates.push(Update::Patch(handle.slot, rect)); + } + + /// Queue every live slot for upload again, in slot order -- what a + /// genuinely new GPU device needs, in place of forgetting everything. + /// + /// A new device starts with no textures, and the renderer-side mirror + /// of these slots (`render::texture::GpuTextures`) starts empty with + /// it. What it must not do is start empty while the handles widgets + /// are still holding name slots by *index*: `Textures::reset` used to + /// throw this bookkeeping away, which left every live `TextureHandle` + /// -- one per `widget::mark`, hundreds on a transcript screen -- + /// pointing at a slot nothing recognised, and the first frame after an + /// Android surface rebuild panicked in `image_bind_group` ("texture + /// slot 89 is not a live standalone image: None"). Re-uploading + /// instead keeps every index meaning what it meant, because this side + /// still holds the images: the slot list is rebuilt identically, + /// including the empty slots, which go across as `PushFree` so the + /// ones after them still land where they were. + /// + /// The glyph atlas comes back with it and is deliberately *not* + /// cleared any more: its pages are slots here, this side holds their + /// pixels, and re-uploading them restores exactly the atlas that was + /// there -- so an app switch no longer costs a re-rasterisation of + /// every glyph on screen either. + /// + /// Pending updates are dropped rather than kept: each is either a push + /// or a patch of a slot this replays in full. + pub fn reupload(&mut self) { + self.updates.clear(); + self.updates + .extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i))); + } + pub fn free(&mut self) { - for idx in self.recv.try_iter() { + for (kind, idx) in self.recv.try_iter() { self.images[idx as usize] = None; self.updates.push(Update::Free(idx)); - self.free.push(idx); + // A page's slot is never reclaimed: `GlyphAtlas` never drops the + // handles it holds, and there is no eviction path for a hole in + // the middle of the array's layers. If that ever changes, this + // is where a freed page's layer would need to go on a free list + // of its own, separate from `free`, which only ever holds + // ordinary image slots today. + if kind == TextureKind::Image { + self.free.push(idx); + } } } pub fn updates(&mut self) -> impl Iterator> { self.updates.drain(..).map(|u| match u { - Update::Push(i) => self.images[i as usize] + Update::Push(kind, i) => self.images[i as usize] .as_ref() - .map(TextureUpdate::Push) - .unwrap_or(TextureUpdate::PushFree), - Update::Set(i) => self.images[i as usize] + .map(|img| TextureUpdate::Push(kind, img)) + .unwrap_or(TextureUpdate::PushFree(kind)), + Update::Set(kind, i) => self.images[i as usize] .as_ref() - .map(|img| TextureUpdate::Set(i, img)) + .map(|img| TextureUpdate::Set(kind, i, img)) + .unwrap_or(TextureUpdate::SetFree), + Update::Patch(i, rect) => self.images[i as usize] + .as_ref() + .map(|img| TextureUpdate::Patch(i, rect, img)) .unwrap_or(TextureUpdate::SetFree), Update::Free(i) => TextureUpdate::Free(i), }) @@ -105,18 +259,36 @@ impl Textures { } impl TextureHandle { - pub fn primitive(&self) -> TexturePrimitive { - self.inner - } pub fn size(&self) -> Vec2 { self.size } + + /// The bind-group index this handle draws with. Only valid for a + /// standalone image; an atlas page has no bind group of its own -- it + /// samples the shared array via `layer()` instead. Getting this wrong is + /// a caller bug (the wrong kind of handle reached the wrong draw path), + /// not a recoverable condition, so it panics rather than drawing garbage. + pub fn image_index(&self) -> u32 { + match self.kind { + TextureKind::Image => self.slot, + TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"), + } + } + + /// The layer this page occupies in the shared atlas array texture. + /// Only valid for a page handle; see `image_index`'s note. + pub fn layer(&self) -> u32 { + match self.kind { + TextureKind::Page { layer } => layer, + TextureKind::Image => panic!("layer() called on a standalone image handle"), + } + } } impl Drop for TextureHandle { fn drop(&mut self) { if self.counter.drop() { - let _ = self.send.send(self.inner.view_idx); + let _ = self.send.send((self.kind, self.slot)); } } } @@ -125,7 +297,7 @@ impl Index<&TextureHandle> for Textures { type Output = DynamicImage; fn index(&self, index: &TextureHandle) -> &Self::Output { - self.images[index.inner.view_idx as usize].as_ref().unwrap() + self.images[index.slot as usize].as_ref().unwrap() } } @@ -134,3 +306,90 @@ impl Default for Textures { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use image::RgbaImage; + + fn image(n: u32) -> DynamicImage { + RgbaImage::new(n, n).into() + } + + fn key(id: u64) -> SharedTextureKey { + SharedTextureKey { owner: "test", id } + } + + /// What `widget::mark` needs: one texture per description, however + /// many widgets ask for it, and a different description is a + /// different texture. + #[test] + fn a_shared_texture_is_built_once_and_handed_out_again() { + let mut textures = Textures::new(); + let built = std::cell::Cell::new(0); + let make = |textures: &mut Textures, id: u64| { + textures.shared(key(id), || { + built.set(built.get() + 1); + image(4) + }) + }; + let first = make(&mut textures, 1); + let again = make(&mut textures, 1); + let other = make(&mut textures, 2); + assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again"); + assert_eq!(first.image_index(), again.image_index()); + assert_ne!(first.image_index(), other.image_index()); + } + + /// The map's own reference is what keeps a shared slot alive: every + /// widget holding one can go away and the slot must not be recycled, + /// because the next widget to ask gets that same index back. + #[test] + fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() { + let mut textures = Textures::new(); + let slot = textures.shared(key(1), || image(4)).image_index(); + textures.free(); + let plain = textures.add(image(4)); + assert_ne!( + plain.image_index(), + slot, + "an ordinary texture was handed the shared mark's slot" + ); + } + + /// A new GPU device gets the same slot numbering back, so a handle a + /// widget has been holding all along still names its own texture -- + /// the crash `reupload` replaced `reset` to fix. + #[test] + fn reupload_replays_every_slot_in_order_including_the_empty_ones() { + let mut textures = Textures::new(); + let keep_a = textures.add(image(4)); + let dropped = textures.add(image(4)); + let keep_b = textures.add(image(4)); + let (a, gone, b) = ( + keep_a.image_index(), + dropped.image_index(), + keep_b.image_index(), + ); + drop(dropped); + textures.free(); + // Drain the updates so far, the way a frame does. + assert!(textures.updates().count() > 0); + + textures.reupload(); + let kinds: Vec = textures + .updates() + .map(|u| match u { + TextureUpdate::Push(..) => "push".to_string(), + TextureUpdate::PushFree(..) => "push-free".to_string(), + _ => "other".to_string(), + }) + .collect(); + assert_eq!( + kinds, + ["push", "push-free", "push"], + "slots {a}, {gone} (freed) and {b} must replay in order, so the \ + indices after a hole still land where they were" + ); + } +} diff --git a/core/src/render/atlas.rs b/core/src/render/atlas.rs new file mode 100644 index 0000000..320b81b --- /dev/null +++ b/core/src/render/atlas.rs @@ -0,0 +1,284 @@ +//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text +//! is a quad per glyph rather than a texture per string. +//! +//! What this replaces is why it exists. Text used to be rasterised into its own +//! `RgbaImage` and uploaded as a whole texture, per text widget, every time +//! anything about it changed -- so every window resize re-rasterised and +//! re-uploaded every visible string, which is what the TODO meant by "resizing +//! (per frame) is really slow". Here a glyph is rasterised once for a given +//! font, size and subpixel offset and then reused by every string that contains +//! it, and a resize re-emits quads without touching the GPU's copy at all. + +use crate::{ + PatchRect, TextureHandle, Textures, UiColor, + util::{HashMap, Vec2}, +}; +use image::RgbaImage; +use swash::scale::image::{Content, Image}; + +/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a +/// few thousand glyphs at UI sizes, and small enough that a page nobody fills +/// is not a big waste. Also the fixed width/height of every layer of the +/// shared array texture in `render::texture` -- `pub(crate)` so that module +/// can size it without a second constant to keep in sync. +pub(crate) const PAGE: u32 = 1024; + +/// Transparent margin kept around every glyph, so that sampling one cannot +/// pick up its neighbour along a shared edge. +const PAD: u32 = 1; + +/// Identifies a rasterised glyph. Anything that changes the pixels has to be in +/// here, or two different glyphs share one entry and the wrong one is drawn. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct GlyphKey { + pub font: u64, + pub glyph: u32, + /// Font size in 1/16 px, so sizes that round to the same pixels share a + /// raster instead of filling the atlas with near-duplicates. + pub size: u32, + /// Horizontal subpixel phase, in 1/4 px. + pub subpixel: u8, + /// Hash of the variation coordinates; a variable font at two weights is two + /// different sets of pixels from one glyph id. + pub coords: u64, +} + +#[derive(Clone, Copy)] +pub struct GlyphEntry { + pub uv_min: [f32; 2], + pub uv_max: [f32; 2], + /// Offset from the glyph's pen position to the top-left of its pixels. + pub left: i32, + pub top: i32, + pub width: u32, + pub height: u32, + pub is_color: bool, + /// The atlas array layer this glyph's page occupies. + pub layer: u32, +} + +struct Page { + handle: TextureHandle, + /// Shelf packing: glyphs are placed left to right along a shelf whose + /// height is the tallest glyph on it, and a new shelf starts above when the + /// row runs out. Chosen over a real packer because glyphs at one size are + /// close to the same height, which is the case shelves are good at. + x: u32, + y: u32, + shelf_height: u32, +} + +#[derive(Default)] +pub struct GlyphAtlas { + pages: Vec, + /// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs + /// from an earlier atlas can tell that its coordinates are stale -- + /// see that method's doc for what goes wrong without it. + generation: u64, + /// `None` for a glyph that rasterised to nothing -- a space, say. Cached + /// too, so it is not re-rasterised on every layout. + entries: HashMap>, +} + +impl GlyphAtlas { + pub fn get(&self, key: &GlyphKey) -> Option> { + self.entries.get(key).copied() + } + + /// Rasterised pixels in, a place in the atlas out. `None` means the glyph + /// has no pixels, which is a normal answer rather than a failure. + pub fn insert( + &mut self, + key: GlyphKey, + image: &Image, + textures: &mut Textures, + ) -> Option { + let w = image.placement.width; + let h = image.placement.height; + if w == 0 || h == 0 { + self.entries.insert(key, None); + return None; + } + if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE { + // A single glyph larger than a page. Refusing is better than + // silently drawing a cropped one; the caller draws nothing. + self.entries.insert(key, None); + return None; + } + + let (page_idx, x, y) = self.allocate(w, h, textures); + let page = &self.pages[page_idx]; + + let img = textures.image_mut(&page.handle); + let rgba = img.as_mut_rgba8().expect("atlas page is rgba8"); + write_glyph(rgba, image, x, y); + + let handle = page.handle.clone(); + let rect = PatchRect { + x, + y, + width: w, + height: h, + }; + textures.patch(&handle, rect); + + let page = &self.pages[page_idx]; + let scale = 1.0 / PAGE as f32; + let entry = GlyphEntry { + uv_min: [x as f32 * scale, y as f32 * scale], + uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale], + left: image.placement.left, + top: image.placement.top, + width: w, + height: h, + is_color: matches!(image.content, Content::Color), + layer: page.handle.layer(), + }; + self.entries.insert(key, Some(entry)); + Some(entry) + } + + /// A free `w`x`h` spot, opening a shelf or a page as needed. + fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) { + let need_w = w + PAD; + let need_h = h + PAD; + if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) { + let page = &mut self.pages[i]; + if page.x + need_w > PAGE { + page.y += page.shelf_height; + page.x = PAD; + page.shelf_height = 0; + } + let (x, y) = (page.x, page.y); + page.x += need_w; + page.shelf_height = page.shelf_height.max(need_h); + return (i, x, y); + } + + let handle = textures.add_page(RgbaImage::new(PAGE, PAGE)); + self.pages.push(Page { + handle, + x: PAD + w + PAD, + y: PAD, + shelf_height: h + PAD, + }); + (self.pages.len() - 1, PAD, PAD) + } + + /// Record that a glyph has no pixels, so it is not re-rasterised. + pub fn insert_empty(&mut self, key: GlyphKey) { + self.entries.insert(key, None); + } + + /// Which atlas the entries handed out right now belong to. A + /// [`crate::RenderedText`] records this when it is built and is only + /// reusable while it still matches. + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn page_count(&self) -> usize { + self.pages.len() + } + + pub fn glyph_count(&self) -> usize { + self.entries.len() + } + + /// Forget every page and every rasterised entry -- what a genuinely new + /// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s + /// "not already live" branch, e.g. after backgrounding): the pages this + /// atlas remembers are `TextureHandle`s into the *old* device's + /// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/ + /// `uv_max`/`layer` point into them. Without this, a glyph already + /// cached here is treated as "already placed" and never re-inserted + /// into the fresh (empty) atlas the new renderer actually has -- + /// exactly the "rectangles stay, glyphs disappear" bug the resize path + /// (`AndroidRenderer::resize`) was built to avoid for the reuse case; + /// this is its counterpart for the case where the renderer really is + /// new. Dropping `pages` also drops its `TextureHandle`s, which send a + /// free message back through their `Textures`; see `Textures::reset`'s + /// doc for why that is harmless here. + /// Bumping `generation` here is the other half of the same + /// invalidation: emptying this atlas does nothing about the + /// `RenderedText`s widgets are *already holding* + /// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry + /// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown + /// away. Those redraw perfectly happily and sample whatever now sits at + /// those coordinates -- the fragments-of-other-glyphs Iris photographed + /// after resuming the app on 2026-09-06. One counter, checked where the + /// cache is read, is what makes a cached render un-reusable across a + /// renderer rebuild. + pub fn clear(&mut self) { + self.pages.clear(); + self.entries.clear(); + self.generation += 1; + } +} + +fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { + // On the current shelf, or on a new one above it. + (page.x + need_w <= PAGE && page.y + need_h <= PAGE) + || (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE) +} + +/// Copy one rasterised glyph into the page image at `(x, y)`. +/// +/// A mask glyph keeps its coverage in alpha with the colour left to the shader, +/// so one raster serves text of any colour; a colour glyph carries its own. +fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { + let w = image.placement.width; + let h = image.placement.height; + match image.content { + Content::Mask => { + for row in 0..h { + for col in 0..w { + let a = image.data[(row * w + col) as usize]; + page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a])); + } + } + } + Content::Color => { + for row in 0..h { + for col in 0..w { + let i = ((row * w + col) * 4) as usize; + let px = [ + image.data[i], + image.data[i + 1], + image.data[i + 2], + image.data[i + 3], + ]; + page.put_pixel(x + col, y + row, image::Rgba(px)); + } + } + } + Content::SubpixelMask => { + // Not asked for: `Format::Alpha` is what the renderer requests, so + // reaching here means the request changed and this needs writing. + // Drawn as a plain mask from the green channel rather than dropped, + // so the text is readable rather than absent. + for row in 0..h { + for col in 0..w { + let i = ((row * w + col) * 4) as usize; + let a = image.data[i + 1]; + page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a])); + } + } + } + } +} + +/// Where a glyph goes on screen, in pixels relative to the text's origin. +/// +/// `color` is per-glyph (read from the parley run's own `Brush`, since +/// `UiColor` is parley's brush type here) rather than a single colour for +/// the whole `RenderedText`, so that a span pushed with its own +/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour +/// inside one wrapped paragraph) actually renders in that colour instead of +/// the buffer's base one. +#[derive(Clone, Copy)] +pub struct PlacedGlyph { + pub entry: GlyphEntry, + pub offset: Vec2, + pub color: UiColor, +} diff --git a/core/src/render/data.rs b/core/src/render/data.rs index 2953065..6583efd 100644 --- a/core/src/render/data.rs +++ b/core/src/render/data.rs @@ -8,6 +8,15 @@ pub struct WindowUniform { pub height: f32, } +/// One primitive's placement and what to draw there, in the one arena +/// every layer shares (`Primitives`). Read from a storage buffer by +/// **both** shader stages: the vertex stage for the corners of the +/// primitive it is drawing, the fragment stage for the corners of a +/// *mask's* primitive, which is generally a different one and often in +/// another layer. A layer's vertex buffer carries only the slot +/// ([`instance_slot_layout`]), so there is exactly one copy of a +/// placement and a mask cannot disagree with what was drawn. See +/// LAYOUT.md's "Masks with a shape". #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PrimitiveInstance { @@ -15,25 +24,20 @@ pub struct PrimitiveInstance { pub binding: u32, pub idx: u32, pub mask_idx: MaskIdx, + pub move_idx: MoveIdx, } -impl PrimitiveInstance { - const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![ - 0 => Float32x2, - 1 => Float32x2, - 2 => Float32x2, - 3 => Float32x2, - 4 => Uint32, - 5 => Uint32, - 6 => Uint32, - ]; - - pub fn desc() -> VertexBufferLayout<'static> { - VertexBufferLayout { - array_stride: std::mem::size_of::() as BufferAddress, - step_mode: VertexStepMode::Instance, - attributes: &Self::ATTRIBS, - } +/// The vertex layout of a layer's draw order: one `u32` slot into the +/// global instance arena per instance, stepped per instance. Everything a +/// primitive is made of used to be here as eight vertex attributes; it +/// moved into the storage buffer above so the fragment stage can read it +/// too. +pub fn instance_slot_layout() -> VertexBufferLayout<'static> { + const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32]; + VertexBufferLayout { + array_stride: std::mem::size_of::() as BufferAddress, + step_mode: VertexStepMode::Instance, + attributes: &ATTRIBS, } } @@ -43,8 +47,86 @@ impl MaskIdx { pub const NONE: Self = Self::preset(u32::MAX); } +pub type MoveIdx = Id; + +/// A clip, as a reference to a primitive already written plus the mask it +/// nests inside. The fragment stage evaluates that primitive's coverage +/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage` +/// from the same SDF the rect itself is drawn with -- and multiplies it +/// into the pixel's alpha, so a rounded container's corner and its +/// children's clipped corner are the same arithmetic and cannot disagree. +/// See LAYOUT.md's "Masks with a shape". +/// +/// **No `kind` and no `flags`**, which the design sketched: the referenced +/// instance already carries its own `binding`, and a copy of it here is a +/// second thing to keep in step; alpha-only is the only mode there is, so +/// there is nothing to select. Both are a field away if a second mode +/// appears. #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct Mask { - pub region: UiRegion, + /// The slot in `UiRenderState::primitives` of the primitive whose + /// coverage this mask is. Today always a `RectPrimitive`: a glyph or + /// a standalone image would need, respectively, a CPU-side alpha + /// plane for the hit test to agree with the shader, and a bind-group + /// switch the fragment stage cannot make -- `Painter::set_mask` + /// rejects both by name rather than leaving the shader to read a rect + /// that is not there. + /// + /// Who owns it depends on which way the mask was set. A plain + /// `.masked()` writes its own undrawn rect, so the primitive is in + /// the masking widget's `ActiveData::primitives` and lives exactly as + /// long as the mask. `.masked_by(shape)` points at a *child's* + /// primitive, which that child can free on any redraw of its own -- + /// so `UiRenderState::remask_shape_users` marks the mask's owner for + /// redraw whenever a referenced slot is freed, since that widget's + /// own `set_mask` is the only thing that resolves the slot again. + pub primitive: u32, + /// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so + /// clipping nests: the fragment stage walks the chain and multiplies + /// every coverage on it, which is what makes a pixel inside two + /// feathered corners dimmed by both. Chained rather than intersected + /// on the CPU because each mask moves with its own widget -- a code + /// fence inside a transcript row carries the row's scroll, the list's + /// own box does not, and one region resolved when the fence was last + /// drawn gets the second of those wrong as soon as the row moves. + /// + /// A child holds one ref on its parent's slot (`Painter::set_mask`), + /// released when the child's own slot goes + /// (`UiRenderState::remove`), so the chain cannot outlive what it + /// points at. + pub parent: MaskIdx, +} + +/// One widget's cumulative on-screen translation, and the slot of the +/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A +/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for +/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are +/// translations of an already-drawn subtree. See LAYOUT.md section 2. +/// +/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is +/// a `vec2`, which gives the struct an 8-byte alignment and rounds its +/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 -- +/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not +/// check this for us, and getting it wrong is a wgpu validation panic at +/// draw time ("buffer bound ... with size 12 where the shader expects 16"), +/// not a compile error. +#[repr(C)] +#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct MoveOffset { + pub delta: [f32; 2], + pub parent: u32, + _pad: u32, +} + +impl MoveOffset { + pub const NONE_PARENT: u32 = u32::MAX; + + pub fn new(delta: [f32; 2], parent: u32) -> Self { + Self { + delta, + parent, + _pad: 0, + } + } } diff --git a/core/src/render/frame_report.rs b/core/src/render/frame_report.rs new file mode 100644 index 0000000..596a315 --- /dev/null +++ b/core/src/render/frame_report.rs @@ -0,0 +1,557 @@ +use std::time::{Duration, Instant}; + +/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the +/// 60Hz vsync period. Kept as the same threshold so a percentage from this +/// report and a percentage from `gfxinfo` mean the same thing. Only a +/// fallback now that a caller can read the display's real refresh rate +/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a +/// 90Hz or 120Hz phone judged against this constant would call every frame +/// "late" that merely met its own, faster budget. +pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667); + +/// Enough frames for several minutes of scrolling before the oldest ones +/// start being overwritten -- the same "diagnostic, not a log" sizing +/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently +/// here since a `Duration` is smaller than the six `Long` arrays it keeps. +/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+ +/// keyboard run is ~6,500+ frames on the Compose side, comfortably under +/// this so `phase_stats` never has to report a phase as partially evicted. +const RING_CAPACITY: usize = 16384; + +/// One `mark_phase` call: the wall-clock instant and the (0-based, +/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at +/// which a phase began -- `phase_stats` slices `index_ring` against this to +/// find which recorded samples belong to which phase, since the ring +/// itself only keeps the most recent `RING_CAPACITY` samples' *values*, +/// not which phase they were in. +struct PhaseMark { + name: String, + start_index: u64, + start_at: Instant, +} + +/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's +/// "per-phase blocks in `FrameReport`... frames, late count/percent... +/// p50/p90/p99, worst, duration". `Display` matches the shape +/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so +/// the two apps' reports read the same way side by side. +pub struct PhaseStats { + pub name: String, + /// How many frames were recorded during this phase in total -- may + /// exceed `late + (samples counted)` if some of this phase's frames + /// have since been evicted from the ring by a very long run; that + /// case is named in the `Display` rather than silently under-counted. + pub frames: u64, + pub duration: Duration, + pub late: u64, + pub late_percent: f64, + pub p50: Duration, + pub p90: Duration, + pub p99: Duration, + pub worst: Duration, + /// `false` if this phase's frame count exceeds how many samples of it + /// are still in the ring -- the percentiles above are then computed + /// over whatever survived, not the whole phase. UI_RULES.md: this is + /// the "we don't fully know" state, named rather than folded silently + /// into a number that looks exact. + pub complete: bool, +} + +impl std::fmt::Display for PhaseStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + " {}: {} frames over {:.1}s{}", + self.name, + self.frames, + self.duration.as_secs_f64(), + if self.complete { + "" + } else { + " (ring evicted some of this phase)" + }, + )?; + writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?; + writeln!( + f, + " total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms", + self.p50.as_secs_f64() * 1000.0, + self.p90.as_secs_f64() * 1000.0, + self.p99.as_secs_f64() * 1000.0, + )?; + write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0) + } +} + +/// A per-frame wall-time report iris keeps of itself, because `dumpsys +/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all +/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's +/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered +/// `SurfaceView` bypasses entirely. `record` is meant to be called once per +/// frame, wrapping the same span Compose's own render report and `gfxinfo` +/// count -- from the frame's redraw/update start to after the frame is +/// handed to the platform to present. +/// +/// **What this does not measure**: wgpu's `present()` call queues the frame +/// with the compositor and returns; it is not fenced against the GPU +/// actually finishing the frame or the compositor actually showing it, the +/// way `gfxinfo`'s own `GPU_DURATION`/vsync accounting is. So a sample here +/// is "how long the CPU took to build and submit this frame", not +/// "how long the frame took to reach the screen" -- named in +/// [`FrameStats`]'s own `Display` line rather than presented as the latter, +/// per the standing rule against showing an inferred number as a measured +/// one where the two differ. +/// +/// Fixed-size ring, no allocation on the hot path -- `report()` is the only +/// place that allocates (a sort over the current ring), and it is only +/// ever called from a button tap, not once per frame. +pub struct FrameReport { + ring: Box<[Duration; RING_CAPACITY]>, + /// The `submit_to_present` half of each sample in `ring`, same index, + /// same lifetime -- kept as a second ring rather than a ring of pairs so + /// the existing `ring`/percentile code above is untouched (RUST.md's I5 + /// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05). + /// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half. + submit_ring: Box<[Duration; RING_CAPACITY]>, + /// The absolute (0-based, since the last `reset`) frame index each + /// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats` + /// slices against `PhaseMark::start_index` to tell which recorded + /// frames fall in which phase. + index_ring: Box<[u64; RING_CAPACITY]>, + /// How many of `ring`'s slots hold a real sample -- saturates at + /// `RING_CAPACITY`, unlike `total_frames` below which keeps counting. + len: usize, + pos: usize, + /// All frames recorded since the last `reset`, even past `RING_CAPACITY` + /// -- what `janky_percent` divides by, so a long run's percentage stays + /// correct even once the ring itself only holds the most recent frames. + total_frames: u64, + janky_frames: u64, + /// `mark_phase` calls since the last `reset`, oldest first -- see + /// `phase_stats`. Empty on an ordinary run that never calls + /// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller + /// prints no "per phase:" section at all, matching RUST.md's "empty/ + /// absent on an ordinary 'Copy' press, which never marks a phase." + phases: Vec, +} + +/// One resolved reading. `Display` is the log line both the "Frame report" +/// button and `transcript-bench.sh`-style scripts read, grep-able on +/// `"iris frame report"`. +pub struct FrameStats { + pub total_frames: u64, + pub janky_percent: f64, + pub p50: Duration, + pub p90: Duration, + pub p99: Duration, + pub worst: Duration, + /// Median of `redraw_to_submit` -- iris's own CPU work (layout, text, + /// primitive building) up to and including building the `queue.submit` + /// call, per frame. RUST.md's I5 "Where iris's frame time goes" split, + /// added 2026-09-05 to answer "CPU or GPU?" with a number rather than a + /// guess. + pub cpu_p50: Duration, + /// Median of `submit_to_present` -- the `queue.submit` call itself plus + /// `present()`, i.e. wherever the driver/GPU/compositor wait actually + /// happens. Same caveat as the type's own doc: `present()` is not + /// fenced against the GPU actually finishing, so this is "how long the + /// CPU was blocked handing the frame off", not the frame's true GPU + /// time -- still enough to separate "iris is slow building the frame" + /// from "iris is slow handing it to the driver". + pub gpu_wait_p50: Duration, +} + +impl std::fmt::Display for FrameStats { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "frames={} janky%={:.2} p50={:.1}ms p90={:.1}ms p99={:.1}ms worst={:.1}ms \ + (measures redraw-start to after present() is called, not GPU/compositor \ + completion)", + self.total_frames, + self.janky_percent, + self.p50.as_secs_f64() * 1000.0, + self.p90.as_secs_f64() * 1000.0, + self.p99.as_secs_f64() * 1000.0, + self.worst.as_secs_f64() * 1000.0, + )?; + write!( + f, + " cpu_p50={:.1}ms gpu_wait_p50={:.1}ms (redraw-start-to-submit vs. \ + submit-to-after-present)", + self.cpu_p50.as_secs_f64() * 1000.0, + self.gpu_wait_p50.as_secs_f64() * 1000.0, + ) + } +} + +impl FrameReport { + pub fn new() -> Self { + Self { + ring: Box::new([Duration::ZERO; RING_CAPACITY]), + submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]), + index_ring: Box::new([0; RING_CAPACITY]), + len: 0, + pos: 0, + total_frames: 0, + janky_frames: 0, + phases: Vec::new(), + } + } + + /// Record one frame's elapsed wall time, with no CPU/GPU split (the + /// `submit_to_present` half is recorded as zero, so `cpu_p50` reads as + /// the whole frame and `gpu_wait_p50` as nothing -- honest for a caller + /// that never measured the split, rather than fabricating one). O(1), + /// no allocation. + pub fn record(&mut self, elapsed: Duration) { + self.record_split(elapsed, Duration::ZERO); + } + + /// Record one frame's elapsed wall time, split at `queue.submit`: + /// `submit_to_present` is the `queue.submit()` call plus `present()`; + /// `total - submit_to_present` is everything before it (layout, text, + /// primitive building). RUST.md's I5 "Where iris's frame time goes" + /// CPU/GPU split, added 2026-09-05. O(1), no allocation. + pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) { + self.ring[self.pos] = total; + self.submit_ring[self.pos] = submit_to_present; + self.index_ring[self.pos] = self.total_frames; + self.pos = (self.pos + 1) % RING_CAPACITY; + self.len = (self.len + 1).min(RING_CAPACITY); + self.total_frames += 1; + if total > JANK_THRESHOLD { + self.janky_frames += 1; + } + } + + /// Clears every counter and every sample -- what the "Reset frame + /// report" control calls, so a report covers only what was scrolled + /// after the button was pressed (the same reason `FrameStats.kt`'s + /// `reset()` exists on the Compose side). Also clears every phase + /// mark, so a fresh run starts with no "per phase:" section until it + /// marks one of its own. + pub fn reset(&mut self) { + self.len = 0; + self.pos = 0; + self.total_frames = 0; + self.janky_frames = 0; + self.phases.clear(); + } + + /// Marks the start of a named phase at the current moment -- every + /// frame recorded from here until the next `mark_phase` (or `reset`) + /// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls + /// this once per phase (fling/stream/type/keyboard) so `phase_stats` + /// can slice one whole run's frames by what was happening during each. + pub fn mark_phase(&mut self, name: &str) { + // `phase_stats`'s slicing (`idx >= phase.start_index && idx < + // end_index`) silently produces an empty or nonsensical slice for + // a phase pushed out of order rather than surfacing the misuse + // (docs/REVIEW-2026-09-06.md finding 5). + debug_assert!( + self.phases + .last() + .is_none_or(|p| self.total_frames >= p.start_index) + ); + self.phases.push(PhaseMark { + name: name.to_string(), + start_index: self.total_frames, + start_at: Instant::now(), + }); + } + + /// One [`PhaseStats`] per `mark_phase` call since the last `reset`, + /// oldest first. `now` closes the last phase's wall-clock span (there + /// is no "next phase" instant to use for it); `refresh_hz` is what + /// each phase's own `late`/`late_percent` is judged against, read from + /// the display rather than assumed -- RUST.md's "Benchmark v2": "late + /// count/% against the display's refresh rate." + pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec { + if self.phases.is_empty() || refresh_hz <= 0.0 { + return Vec::new(); + } + let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64); + self.phases + .iter() + .enumerate() + .map(|(i, phase)| { + let (end_index, end_at) = match self.phases.get(i + 1) { + Some(next) => (next.start_index, next.start_at), + None => (self.total_frames, now), + }; + let frames = end_index.saturating_sub(phase.start_index); + let mut samples: Vec = (0..self.len) + .filter(|&j| { + let idx = self.index_ring[j]; + idx >= phase.start_index && idx < end_index + }) + .map(|j| self.ring[j]) + .collect(); + let complete = samples.len() as u64 >= frames; + if samples.is_empty() { + return PhaseStats { + name: phase.name.clone(), + frames, + duration: end_at.saturating_duration_since(phase.start_at), + late: 0, + late_percent: 0.0, + p50: Duration::ZERO, + p90: Duration::ZERO, + p99: Duration::ZERO, + worst: Duration::ZERO, + complete, + }; + } + samples.sort_unstable(); + let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)]; + let late = samples.iter().filter(|&&d| d > budget).count() as u64; + PhaseStats { + name: phase.name.clone(), + frames, + duration: end_at.saturating_duration_since(phase.start_at), + late, + late_percent: 100.0 * late as f64 / samples.len() as f64, + p50: pct(50), + p90: pct(90), + p99: pct(99), + worst: *samples.last().expect("checked not empty above"), + complete, + } + }) + .collect() + } + + /// `None` if nothing has been recorded since the last reset -- the + /// "no frames recorded, scroll first" case, not a zeroed report that + /// would read as a real (perfect) measurement. + pub fn report(&self) -> Option { + if self.len == 0 { + return None; + } + let mut samples: Vec = self.ring[..self.len].to_vec(); + samples.sort_unstable(); + let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)]; + + // Separate arrays rather than subtracting the two medians above: + // medians do not distribute over subtraction, and each needs its + // own sort. + let submit_samples: Vec = self.submit_ring[..self.len].to_vec(); + let cpu_samples: Vec = self.ring[..self.len] + .iter() + .zip(self.submit_ring[..self.len].iter()) + .map(|(&total, &submit_to_present)| total.saturating_sub(submit_to_present)) + .collect(); + let median = |mut v: Vec| { + v.sort_unstable(); + v[v.len() / 2] + }; + + Some(FrameStats { + total_frames: self.total_frames, + janky_percent: 100.0 * self.janky_frames as f64 / self.total_frames as f64, + p50: pct(50), + p90: pct(90), + p99: pct(99), + worst: *samples.last().expect("len > 0 checked above"), + cpu_p50: median(cpu_samples), + gpu_wait_p50: median(submit_samples), + }) + } + + /// `(late count, late percent)` over every sample still in the ring, + /// judged against `refresh_hz`'s own frame budget rather than the + /// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late + /// count/% against the display's refresh rate... print 'at N Hz (X ms + /// budget)' like Compose does." A separate method from `report()` + /// rather than a parameter on it, so `report()`'s own `janky_percent` + /// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is + /// unaffected for every existing caller that never measured a real + /// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive + /// `refresh_hz`. + pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) { + if self.len == 0 || refresh_hz <= 0.0 { + return (0, 0.0); + } + let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64); + let late = self.ring[..self.len] + .iter() + .filter(|&&d| d > budget) + .count() as u64; + (late, 100.0 * late as f64 / self.len as f64) + } +} + +impl Default for FrameReport { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_frames_reports_none() { + assert!(FrameReport::new().report().is_none()); + } + + #[test] + fn one_frame_is_every_percentile_and_the_worst() { + let mut r = FrameReport::new(); + r.record(Duration::from_millis(10)); + let stats = r.report().unwrap(); + assert_eq!(stats.total_frames, 1); + assert_eq!(stats.p50, Duration::from_millis(10)); + assert_eq!(stats.p99, Duration::from_millis(10)); + assert_eq!(stats.worst, Duration::from_millis(10)); + assert_eq!(stats.janky_percent, 0.0); + } + + #[test] + fn percentiles_and_worst_over_a_known_set() { + let mut r = FrameReport::new(); + // 100 samples, 1ms..=100ms, fed out of order so the ring's own + // order is not what gives the right answer -- the sort has to. + for ms in (1..=100).rev() { + r.record(Duration::from_millis(ms)); + } + let stats = r.report().unwrap(); + assert_eq!(stats.total_frames, 100); + assert_eq!(stats.p50, Duration::from_millis(51)); + assert_eq!(stats.p90, Duration::from_millis(91)); + assert_eq!(stats.p99, Duration::from_millis(100)); + assert_eq!(stats.worst, Duration::from_millis(100)); + } + + #[test] + fn jank_threshold_matches_gfxinfos_60hz_budget() { + let mut r = FrameReport::new(); + r.record(Duration::from_nanos(16_666_667)); // exactly on budget: not janky + r.record(Duration::from_nanos(16_666_668)); // one ns over: janky + let stats = r.report().unwrap(); + assert_eq!(stats.janky_percent, 50.0); + } + + #[test] + fn janky_percent_is_over_all_time_frames_not_just_the_ring() { + // Fewer than RING_CAPACITY frames, all janky, then a fresh reset -- + // the percentage must reset to 0, not divide by a stale count. + let mut r = FrameReport::new(); + for _ in 0..10 { + r.record(Duration::from_millis(50)); + } + assert_eq!(r.report().unwrap().janky_percent, 100.0); + r.reset(); + assert!(r.report().is_none()); + r.record(Duration::from_millis(1)); + assert_eq!(r.report().unwrap().janky_percent, 0.0); + } + + #[test] + fn record_without_a_split_reports_the_whole_frame_as_cpu() { + // A caller that never measured the split (plain `record`) should + // not fabricate a GPU-wait number -- it reads as zero, and the CPU + // half reads as the whole frame. + let mut r = FrameReport::new(); + r.record(Duration::from_millis(20)); + let stats = r.report().unwrap(); + assert_eq!(stats.cpu_p50, Duration::from_millis(20)); + assert_eq!(stats.gpu_wait_p50, Duration::ZERO); + } + + #[test] + fn record_split_reports_each_halfs_own_median() { + let mut r = FrameReport::new(); + // Three frames: total is always 30ms, but the CPU/GPU-wait split + // moves, so the two medians must be independent of each other and + // of `total`'s own median. + r.record_split(Duration::from_millis(30), Duration::from_millis(5)); + r.record_split(Duration::from_millis(30), Duration::from_millis(10)); + r.record_split(Duration::from_millis(30), Duration::from_millis(20)); + let stats = r.report().unwrap(); + assert_eq!(stats.p50, Duration::from_millis(30)); + assert_eq!(stats.gpu_wait_p50, Duration::from_millis(10)); + assert_eq!(stats.cpu_p50, Duration::from_millis(20)); + } + + #[test] + fn ring_wraps_without_growing_past_capacity() { + let mut r = FrameReport::new(); + for i in 0..(RING_CAPACITY * 2) { + r.record(Duration::from_millis(1 + (i % 5) as u64)); + } + let stats = r.report().unwrap(); + // total_frames keeps the full count even once the ring has wrapped. + assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64); + // but every sample the ring can report on is still one of the five + // values fed in, since a wrap can only overwrite with more of the + // same pattern here. + assert!(stats.worst <= Duration::from_millis(5)); + } + + #[test] + fn no_marks_means_no_phases() { + let mut r = FrameReport::new(); + r.record(Duration::from_millis(5)); + assert!(r.phase_stats(Instant::now(), 60.0).is_empty()); + } + + #[test] + fn phases_slice_frames_by_when_they_were_marked() { + let mut r = FrameReport::new(); + r.mark_phase("a"); + for _ in 0..5 { + r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late + } + r.mark_phase("b"); + for _ in 0..3 { + r.record(Duration::from_millis(20)); // 20ms: late at 60Hz + } + let now = Instant::now(); + let phases = r.phase_stats(now, 60.0); + assert_eq!(phases.len(), 2); + assert_eq!(phases[0].name, "a"); + assert_eq!(phases[0].frames, 5); + assert_eq!(phases[0].late, 0); + assert_eq!(phases[0].worst, Duration::from_millis(10)); + assert_eq!(phases[1].name, "b"); + assert_eq!(phases[1].frames, 3); + assert_eq!(phases[1].late, 3); + assert_eq!(phases[1].late_percent, 100.0); + assert_eq!(phases[1].worst, Duration::from_millis(20)); + assert!(phases[0].complete); + assert!(phases[1].complete); + } + + #[test] + fn the_last_phase_runs_until_now() { + let mut r = FrameReport::new(); + r.mark_phase("only"); + r.record(Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(20)); + let now = Instant::now(); + let phases = r.phase_stats(now, 60.0); + assert_eq!(phases.len(), 1); + assert!(phases[0].duration >= Duration::from_millis(20)); + } + + #[test] + fn reset_clears_phase_marks() { + let mut r = FrameReport::new(); + r.mark_phase("a"); + r.record(Duration::from_millis(1)); + r.reset(); + assert!(r.phase_stats(Instant::now(), 60.0).is_empty()); + } + + #[test] + fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() { + let mut r = FrameReport::new(); + // 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one. + r.record(Duration::from_millis(10)); + assert_eq!(r.late_at_hz(60.0), (0, 0.0)); + assert_eq!(r.late_at_hz(120.0), (1, 100.0)); + } +} diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 1695efa..32b3071 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -1,30 +1,141 @@ -use std::num::NonZero; - use crate::{ UiData, UiRenderState, - render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf}, - util::HashMap, + render::{ + data::{PrimitiveInstance, instance_slot_layout}, + texture::GpuTextures, + util::ArrBuf, + }, + util::{HashMap, Vec2}, }; use data::WindowUniform; +use pollster::FutureExt; use wgpu::{ util::{BufferInitDescriptor, DeviceExt}, *, }; -use winit::dpi::PhysicalSize; +mod atlas; mod data; +mod frame_report; mod primitive; +mod sdf; mod texture; mod util; -pub use data::{Mask, MaskIdx}; +pub use atlas::*; +pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset}; +pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD}; pub use primitive::*; +pub use sdf::{distance_from_rect, rounded_rect_coverage}; -const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); +/// The one shader every primitive is drawn with. Public so a test can run +/// a function out of it against the CPU transliteration in [`sdf`] -- +/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns +/// on: a masked corner that cannot be tapped and a masked corner that is +/// not drawn are only the same corner while the two agree. +pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); + +/// The `wgpu::Limits` both platform backends (`android::render:: +/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask +/// `Adapter::request_device` for -- shared so the two copies cannot drift, +/// per AGENTS.md's "write the logic once." +/// +/// Built from `Limits::default()`, **not** a downlevel variant: the shader +/// (`shader.wgsl`) reads four `var` buffers (rects, glyphs, masks, +/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()` +/// zeroes `max_storage_buffers_per_shader_stage` along with the compute +/// limits below -- switching to it would trade one `request_device` crash +/// for a bind-group-layout one on the same downlevel hardware this is meant +/// to support. `max_buffer_size` is raised for the growing instance/atlas +/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s +/// desktop-tier value, unchanged. +/// +/// The six `max_compute_*` fields are zeroed because nothing in this crate +/// creates a `ComputePipeline` or writes a `@compute` shader stage -- +/// grepped for both across `iris`/`iris-core` before writing this, found +/// none. `Limits::default()` requests desktop-tier compute limits +/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even +/// though nothing asks a device to actually support compute, which is what +/// crashed `request_device` on the Android emulator's software GL path +/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as +/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit +/// is 0 and the unconditional request fails outright +/// (`RUST.md`'s "Software mode ... crashes for a third, different reason"). +/// The same would happen on a real GLES-3.0-only Android device. If a +/// future change adds a compute pass, request the specific limits it needs +/// here rather than reverting to the desktop-tier default for everything. +pub fn device_limits() -> Limits { + Limits { + max_buffer_size: 1 << 30, + max_compute_workgroup_storage_size: 0, + max_compute_invocations_per_workgroup: 0, + max_compute_workgroup_size_x: 0, + max_compute_workgroup_size_y: 0, + max_compute_workgroup_size_z: 0, + max_compute_workgroups_per_dimension: 0, + ..Default::default() + } +} + +/// A capped log of wgpu's *uncaptured* errors -- everything that reaches +/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s +/// own error scopes, i.e. every wgpu error raised outside device/pipeline +/// creation: a validation failure during an ordinary frame's `update`/ +/// `draw`, for instance. wgpu's default handler for these is `panic!` with +/// no caller able to intervene -- exactly what aborted the P0 bench APK +/// once already (this file's `UiRenderNode::new` doc comment) -- so both +/// platform backends install a handler here instead of leaving the default +/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it +/// must never panic in release"). +/// +/// Cheap to `Clone` (an `Arc` around the real storage) rather than a +/// process-wide static, so a caller builds one alongside its `Device`, +/// hands one clone to `on_uncaptured_error`'s closure and keeps the other +/// for the Diagnostics page to read -- context passed explicitly, per +/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a +/// `OnceLock`. +#[derive(Clone)] +pub struct WgpuErrorLog { + errors: std::sync::Arc>>, +} + +/// How many uncaptured errors the log keeps -- old ones drop off the front +/// rather than being trimmed on read, so a build spraying errors every +/// frame doesn't grow this without bound. +const WGPU_ERROR_LOG_CAP: usize = 20; + +impl Default for WgpuErrorLog { + fn default() -> Self { + Self { + errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())), + } + } +} + +impl WgpuErrorLog { + pub fn record(&self, error: impl std::fmt::Display) { + let mut errors = self.errors.lock().unwrap(); + if errors.len() >= WGPU_ERROR_LOG_CAP { + errors.pop_front(); + } + errors.push_back(error.to_string()); + } + + /// A snapshot for the Diagnostics page -- cloned rather than held, + /// since the lock must not outlive one call. + pub fn snapshot(&self) -> Vec { + self.errors.lock().unwrap().iter().cloned().collect() + } +} pub struct UiRenderNode { uniform_group: BindGroup, primitive_layout: BindGroupLayout, + /// Group 1: `rects` and `glyphs`. Global and bound once per frame, + /// not per layer -- a mask referencing a rect drawn in another layer + /// has to be able to read it (see `Primitives`). + primitives: PrimitiveBuffers, + primitive_group: BindGroup, rsc_layout: BindGroupLayout, rsc_group: BindGroup, @@ -34,28 +145,76 @@ pub struct UiRenderNode { active: Vec, window_buffer: Buffer, textures: GpuTextures, + /// Every primitive's placement, read by the vertex stage for the + /// primitive being drawn and by the fragment stage for a mask's. + instances: ArrBuf, masks: ArrBuf, + move_offsets: ArrBuf, + /// Group 3: the masks and move-offsets storage buffers, on their own -- + /// see IRIS_TODO.md's "Appending one image ... rebuilds every other + /// image's bind group". These used to live in group 2 alongside each + /// standalone image's own texture view, so an image's bind group named + /// the masks/move_offsets buffer directly; the moment either buffer + /// resized (which a widget getting its *first* move slot can trigger, + /// unrelated to any image), `ArrBuf::update` handed back a new `Buffer` + /// identity and every image's bind group -- one per live image -- had + /// to be rebuilt to reference it. Pulling both buffers into their own + /// group, bound once per frame rather than once per draw call, means a + /// buffer resize now rebuilds exactly this one group instead of N. + masks_layout: BindGroupLayout, + masks_group: BindGroup, } +/// One layer's vertex buffers: the slots it draws, in order. The +/// primitives themselves are in `UiRenderNode::instances`. struct RenderLayer { - instance: ArrBuf, - primitives: PrimitiveBuffers, - primitive_group: BindGroup, + order: ArrBuf, + /// A standalone image's slots, kept apart from `order` because each + /// one draws with its own bind group -- see `UiRenderNode::draw`. + images: ArrBuf, + /// The texture slot each entry of `images` draws with, in the same + /// order, refreshed alongside it. Not in the vertex buffer itself + /// because it names a bind group, not shader data. + image_tex_indices: Vec, } impl UiRenderNode { pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) { pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.uniform_group, &[]); - pass.set_bind_group(2, &self.rsc_group, &[]); + // Group 1 is global now, so it is set here rather than per layer. + pass.set_bind_group(1, &self.primitive_group, &[]); + // Set once, not per layer or per image: masks/move_offsets are read + // by every primitive and every standalone image alike, and living + // in their own group (rather than folded into group 2 alongside the + // per-image texture view) is what keeps an image's own bind group + // from naming a buffer that changes size on an unrelated widget's + // first draw -- see the comment on `masks_group` below. + pass.set_bind_group(3, &self.masks_group, &[]); for i in &self.active { let layer = &self.layers[i]; - if layer.instance.len() == 0 { + if layer.order.len() == 0 && layer.images.len() == 0 { continue; } - pass.set_bind_group(1, &layer.primitive_group, &[]); - pass.set_vertex_buffer(0, layer.instance.buffer.slice(..)); - pass.draw(0..4, 0..layer.instance.len() as u32); + if layer.order.len() > 0 { + pass.set_bind_group(2, &self.rsc_group, &[]); + pass.set_vertex_buffer(0, layer.order.buffer.slice(..)); + pass.draw(0..4, 0..layer.order.len() as u32); + } + // Images draw after this layer's rects and glyphs, one draw call + // each with its own bind group. That draws every image "on top" + // within the layer, which loses nothing that currently exists: + // `Primitives::apply_free` frees with `swap_remove`, so a layer's + // draw order was already undefined before images had their own + // list -- nothing before this relied on interleaving a rect + // between two images at a particular position. + if layer.images.len() > 0 { + pass.set_vertex_buffer(0, layer.images.buffer.slice(..)); + for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() { + pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]); + pass.draw(0..4, k as u32..k as u32 + 1); + } + } } } @@ -65,79 +224,156 @@ impl UiRenderNode { queue: &Queue, ui: &mut UiData, ui_render: &mut UiRenderState, - ) { + ) -> FrameUpdateStats { self.active.clear(); - for (i, primitives) in ui_render.layers.iter_mut() { + for (i, order) in ui_render.layers.iter_mut() { self.active.push(i); - for change in primitives.apply_free() { - if let Some(inst) = ui_render.active.get_mut(&change.id) { - for h in &mut inst.primitives { - if h.layer == i && h.inst_idx == change.old { - h.inst_idx = change.new; - break; - } - } - } - } - let rlayer = self.layers.entry(i).or_insert_with(|| { - let primitives = PrimitiveBuffers::new(device); - let primitive_group = - Self::primitive_group(device, &self.primitive_layout, primitives.buffers()); - RenderLayer { - instance: ArrBuf::new( - device, - BufferUsages::VERTEX | BufferUsages::COPY_DST, - "instance", - ), - primitives, - primitive_group, - } - }); - if primitives.updated { - rlayer - .instance - .update(device, queue, primitives.instances()); - rlayer.primitives.update(device, queue, primitives.data()); - rlayer.primitive_group = Self::primitive_group( + let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer { + order: ArrBuf::new( device, - &self.primitive_layout, - rlayer.primitives.buffers(), - ); - primitives.updated = false; + BufferUsages::VERTEX | BufferUsages::COPY_DST, + "layer order", + ), + images: ArrBuf::new( + device, + BufferUsages::VERTEX | BufferUsages::COPY_DST, + "layer image order", + ), + image_tex_indices: Vec::new(), + }); + if order.updated { + rlayer.order.update(device, queue, order.order()); + rlayer.images.update(device, queue, order.images()); + rlayer.image_tex_indices = order + .images() + .iter() + .map(|&slot| ui_render.primitives.instance(slot).idx) + .collect(); + order.updated = false; } } - let mut changed = false; - changed |= self.textures.update(&mut ui.textures); - if ui.masks.changed { + let instances_resized = if ui_render.primitives.updated { + ui_render.primitives.updated = false; + let resized = self + .instances + .update(device, queue, ui_render.primitives.instances()); + self.primitives + .update(device, queue, ui_render.primitives.data()); + self.primitive_group = + Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers()); + resized + } else { + false + }; + let masks_resized = if ui.masks.changed { ui.masks.changed = false; - self.masks.update(device, queue, &ui.masks[..]); - changed = true; + self.masks.update(device, queue, &ui.masks[..]) + } else { + false + }; + let moves_resized = if ui.move_offsets.changed { + ui.move_offsets.changed = false; + self.move_offsets + .update(device, queue, &ui.move_offsets[..]) + } else { + false + }; + if masks_resized || moves_resized || instances_resized { + self.masks_group = Self::masks_group( + device, + &self.masks_layout, + &self.masks, + &self.move_offsets, + &self.instances, + ); } - if changed { - self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks); + let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout); + if rebuild_main { + self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures); + } + FrameUpdateStats { + masks_resized, + moves_resized, } } - pub fn resize(&mut self, size: &PhysicalSize, queue: &Queue) { + /// Takes a size rather than a window type: this is the only thing the + /// core wanted from winit, and depending on a windowing backend for two + /// numbers is what put `android-activity` in the core's graph for an + /// Android build that is meant to go through android-view instead. + pub fn resize(&mut self, size: impl Into, queue: &Queue) { + let size = size.into(); let slice = &[WindowUniform { - width: size.width as f32, - height: size.height as f32, + width: size.x, + height: size.y, }]; queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); } + /// Builds every bind group layout, the pipeline, and the two storage + /// buffers this needs -- fallibly, since this is exactly the call that + /// aborted the process on Iris's phone in a release build with no + /// message beyond "wgpu error: Validation Error" (RUST.md's P0 box, + /// "iris bench crash on the phone, 2026-09-06"). wgpu's own default + /// behaviour for an uncaptured error is `panic!` with no caller able to + /// intervene, so every `create_bind_group_layout`/`create_render_pipeline` + /// call below runs inside three nested error scopes (one per + /// `ErrorFilter`) instead: whichever scope catches something, its + /// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output + /// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic + /// would have printed before Android's crash reporter truncated it) and + /// becomes this function's `Err`. Both callers + /// (`android::render::AndroidRenderer::new`, `default::render:: + /// UiRenderer::new`) already call `Device`-creation with + /// `pollster::block_on`, so returning a plain `Result` here rather than + /// making this `async fn` keeps that same synchronous shape. pub fn new( device: &Device, queue: &Queue, config: &SurfaceConfiguration, - limits: UiLimits, - ) -> Self { + window_size: impl Into, + ) -> Result { + // Popped in reverse of this order, once every creation call below + // has run -- `Device::push_error_scope`'s own contract. + let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory); + let validation_scope = device.push_error_scope(ErrorFilter::Validation); + let internal_scope = device.push_error_scope(ErrorFilter::Internal); + let shader = device.create_shader_module(ShaderModuleDescriptor { label: Some("UI Shape Shader"), source: ShaderSource::Wgsl(SHAPE_SHADER.into()), }); - let window_uniform = WindowUniform::default(); + // Seeded from the caller's own reported size, not + // `WindowUniform::default()` (0, 0): the vertex shader divides by + // `window.dim` to reach clip space, so a window this buffer + // disagrees with means every primitive's position is NaN/Inf and is + // dropped before rasterization -- the clear colour still reaches + // the screen (the pass runs regardless) while nothing drawn on top + // of it ever does. winit's backend gets away with the old default + // because winit fires an initial `WindowEvent::Resized` that calls + // `resize()` before the first frame; android-view has no such + // automatic event, so `AndroidRenderer::new` built a node whose + // window buffer was never corrected -- this is I2's "nothing draws" + // bug (RUST.md). + // + // **Deliberately not `config.width`/`config.height`**: those are + // the surface's *physical* pixel size, which the swapchain needs, + // but everything downstream of this uniform (layout, hit-testing, + // glyph/rect positions) works in the caller's own units -- on + // Android that's *logical* (physical / density) since RUST.md's P0 + // box ("text is far too small"), on desktop it's whatever + // `default::render::UiRenderer::new` already divides by + // `window.scale_factor()`. Passing it in explicitly, rather than + // deriving it from `config` here, is what keeps this crate from + // needing to know either platform's notion of density at all. + let window_uniform = { + let size = window_size.into(); + WindowUniform { + width: size.x, + height: size.y, + } + }; let window_buffer = device.create_buffer_init(&BufferInitDescriptor { label: Some("window"), contents: bytemuck::cast_slice(&[window_uniform]), @@ -161,34 +397,53 @@ impl UiRenderNode { let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer); let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { - entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| { - BindGroupLayoutEntry { - binding: i as u32, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - } + entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry { + binding, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }), label: Some("primitive"), }); let tex_manager = GpuTextures::new(device, queue); + let primitives = PrimitiveBuffers::new(device); + let primitive_group = + Self::primitive_group(device, &primitive_layout, primitives.buffers()); + let instances = ArrBuf::new( + device, + BufferUsages::STORAGE | BufferUsages::COPY_DST, + "ui instances", + ); let masks = ArrBuf::new( device, BufferUsages::STORAGE | BufferUsages::COPY_DST, "ui masks", ); + let move_offsets = ArrBuf::new( + device, + BufferUsages::STORAGE | BufferUsages::COPY_DST, + "ui move offsets", + ); - let rsc_layout = Self::rsc_layout(device, &limits); - let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks); + let rsc_layout = Self::rsc_layout(device); + let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager); + let masks_layout = Self::masks_layout(device); + let masks_group = + Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances); let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some("UI Shape Pipeline Layout"), - bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout], + bind_group_layouts: &[ + Some(&uniform_layout), + Some(&primitive_layout), + Some(&rsc_layout), + Some(&masks_layout), + ], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor { @@ -197,7 +452,7 @@ impl UiRenderNode { vertex: VertexState { module: &shader, entry_point: Some("vs_main"), - buffers: &[PrimitiveInstance::desc()], + buffers: &[Some(instance_slot_layout())], compilation_options: Default::default(), }, fragment: Some(FragmentState { @@ -229,9 +484,22 @@ impl UiRenderNode { cache: None, }); - Self { + // Reverse of the push order above. Only one of these should ever be + // `Some` in practice -- three separate scopes exist to name *which* + // kind of error it was, not because more than one is expected at + // once. + let internal_err = internal_scope.pop().block_on(); + let validation_err = validation_scope.pop().block_on(); + let oom_err = oom_scope.pop().block_on(); + if let Some(err) = validation_err.or(oom_err).or(internal_err) { + return Err(err.to_string()); + } + + Ok(Self { uniform_group, primitive_layout, + primitives, + primitive_group, rsc_layout, rsc_group, pipeline, @@ -239,8 +507,12 @@ impl UiRenderNode { layers: HashMap::default(), active: Vec::new(), textures: tex_manager, + instances, masks, - } + move_offsets, + masks_layout, + masks_group, + }) } fn bind_group_0( @@ -273,7 +545,14 @@ impl UiRenderNode { }) } - fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout { + /// Group 2: the shared atlas array and one standalone-image slot (a null + /// view for the main draw, a real one for each image's own bind group -- + /// see `GpuTextures`), plus one sampler. No `count` on any entry: this + /// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the + /// `binding_array` layout it replaced (see TEXTURES.md's "Recommended + /// shape"). Masks and move_offsets are deliberately *not* here -- see + /// `masks_layout` below for why they get their own group. + fn rsc_layout(device: &Device) -> BindGroupLayout { device.create_bind_group_layout(&BindGroupLayoutDescriptor { entries: &[ BindGroupLayoutEntry { @@ -281,20 +560,91 @@ impl UiRenderNode { visibility: ShaderStages::FRAGMENT, ty: BindingType::Texture { sample_type: TextureSampleType::Float { filterable: false }, - view_dimension: TextureViewDimension::D2, + view_dimension: TextureViewDimension::D2Array, multisampled: false, }, - count: Some(NonZero::new(limits.max_textures).unwrap()), + count: None, }, BindGroupLayoutEntry { binding: 1, visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::NonFiltering), - count: Some(NonZero::new(limits.max_samplers).unwrap()), + ty: BindingType::Texture { + sample_type: TextureSampleType::Float { filterable: false }, + view_dimension: TextureViewDimension::D2, + multisampled: false, + }, + count: None, }, BindGroupLayoutEntry { binding: 2, visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::NonFiltering), + count: None, + }, + ], + label: Some("ui rsc"), + }) + } + + /// The main group: rects and glyphs never sample the image slot, so it + /// gets a 1x1 null view rather than any live standalone image's. + fn rsc_group( + device: &Device, + layout: &BindGroupLayout, + tex_manager: &GpuTextures, + ) -> BindGroup { + device.create_bind_group(&BindGroupDescriptor { + layout, + entries: &[ + BindGroupEntry { + binding: 0, + resource: BindingResource::TextureView(tex_manager.array_view()), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(tex_manager.null_view()), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::Sampler(tex_manager.sampler()), + }, + ], + label: Some("ui rsc"), + }) + } + + /// Group 3: the masks and move_offsets storage buffers, shared by the + /// main draw and every standalone image alike (see the field comment on + /// `masks_group`). Bound once per frame in `draw()` rather than folded + /// into group 2, so a resize of either buffer -- which an unrelated + /// widget's first move slot can trigger -- rebuilds this one group + /// instead of every image's. + fn masks_layout(device: &Device) -> BindGroupLayout { + device.create_bind_group_layout(&BindGroupLayoutDescriptor { + entries: &[ + BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + BindGroupLayoutEntry { + binding: 2, + visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT, ty: BindingType::Buffer { ty: BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, @@ -303,60 +653,67 @@ impl UiRenderNode { count: None, }, ], - label: Some("ui rsc"), + label: Some("ui masks"), }) } - fn rsc_group( + fn masks_group( device: &Device, layout: &BindGroupLayout, - tex_manager: &GpuTextures, masks: &ArrBuf, + move_offsets: &ArrBuf, + instances: &ArrBuf, ) -> BindGroup { device.create_bind_group(&BindGroupDescriptor { layout, entries: &[ BindGroupEntry { binding: 0, - resource: BindingResource::TextureViewArray(&tex_manager.views()), + resource: masks.buffer.as_entire_binding(), }, BindGroupEntry { binding: 1, - resource: BindingResource::SamplerArray(&tex_manager.samplers()), + resource: move_offsets.buffer.as_entire_binding(), }, BindGroupEntry { binding: 2, - resource: masks.buffer.as_entire_binding(), + resource: instances.buffer.as_entire_binding(), }, ], - label: Some("ui rsc"), + label: Some("ui masks"), }) } pub fn view_count(&self) -> usize { self.textures.view_count() } -} -pub struct UiLimits { - max_textures: u32, - max_samplers: u32, -} + /// Standalone-image bind groups built since the last call -- see + /// `GpuTextures::take_bind_group_creates`. Call once per frame before + /// `update()` to measure exactly that frame. + pub fn take_image_bind_group_creates(&mut self) -> u64 { + self.textures.take_bind_group_creates() + } -impl Default for UiLimits { - fn default() -> Self { - Self { - max_textures: 100000, - max_samplers: 1000, - } + /// Atlas-array `grow_array` calls since the last call -- same calling + /// convention as `take_image_bind_group_creates` (call once per frame, + /// before `update()`, to read exactly the previous frame's tally). Part + /// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the + /// first input frame" investigation): if a report ever shows a grow + /// landing on the same frame the glyphs vanished, that is the + /// coincidence to chase first. + pub fn take_atlas_pages_grown(&mut self) -> u64 { + self.textures.take_pages_grown() } } -impl UiLimits { - pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 { - self.max_textures + self.max_samplers - } - pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 { - self.max_samplers - } +/// What `UiRenderNode::update` changed this frame that a caller building a +/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/ +/// `take_atlas_pages_grown` for the two counters this doesn't carry (they +/// use the existing "call before update()" convention instead, so as not +/// to disturb `bench_images`' documented counts). +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameUpdateStats { + pub masks_resized: bool, + pub moves_resized: bool, } diff --git a/core/src/render/primitive.rs b/core/src/render/primitive.rs index f8f1069..e184090 100644 --- a/core/src/render/primitive.rs +++ b/core/src/render/primitive.rs @@ -4,35 +4,27 @@ use crate::{ Color, UiRegion, WidgetId, render::{ ArrBuf, - data::{MaskIdx, PrimitiveInstance}, + data::{MaskIdx, MoveIdx, PrimitiveInstance}, }, + util::HashSet, }; use bytemuck::Pod; use wgpu::*; -pub struct Primitives { - instances: Vec, - assoc: Vec, - data: PrimitiveData, - free: Vec, - pub updated: bool, -} - -impl Default for Primitives { - fn default() -> Self { - Self { - instances: Default::default(), - assoc: Default::default(), - data: Default::default(), - free: Vec::new(), - updated: true, - } - } -} +/// The `binding` tag `Painter` writes on an image instance. Distinct from any +/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key +/// one from -- a bind group already selects the texture -- so this only ever +/// has to match the shader's `TEXTURE` constant and flag "this instance is +/// drawn with its own bind group" to the code below. +pub const IMAGE_BINDING: u32 = 1; pub trait Primitive: Pod { const BINDING: u32; fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec; + /// The read-only half of [`Self::vec`], for a caller that wants to + /// look one entry up rather than write one -- a mask reading the + /// radius of the rect it clips to ([`Primitives::data`]). + fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec; } macro_rules! primitives { @@ -54,6 +46,14 @@ macro_rules! primitives { impl PrimitiveBuffers { pub const LEN: usize = primitives!(@count $($name)*); + /// The group-1 binding number each primitive's storage buffer + /// sits at, in declaration order. Not `0..LEN`: a primitive's + /// `BINDING` also tags its instances for the shader's dispatch + /// switch, and a removed primitive (as `TEXTURE` was, once + /// images stopped needing a per-instance storage entry) can + /// leave a gap, so the pipeline layout has to ask for these + /// exact numbers rather than assuming they are contiguous. + pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*]; pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { [ $((<$ty>::BINDING, &self.$name.buffer),)* @@ -90,73 +90,236 @@ macro_rules! primitives { fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec { &mut data.$name } + fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec { + &data.$name + } } )* }; - (@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) }; + // The recursion has to hand back the same shape it matches -- space + // separated, not comma separated. Written with `$($t),+` it re-entered + // with a comma as the first token and never terminated, which happened to + // work only because there were exactly two primitives: the first step left + // a single token, and a single token matches the base case whichever + // separator it was written with. + (@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) }; (@count $t:tt) => { 1 }; } -pub struct PrimitiveInst

{ - pub id: WidgetId, - pub primitive: P, - pub region: UiRegion, - pub mask_idx: MaskIdx, +/// Every primitive instance in the tree, in one arena that all layers +/// share, plus the per-primitive data (`rects`, `glyphs`) they index. +/// +/// **Why one arena rather than one per layer**, which is what this was: +/// the fragment stage evaluates a *mask's* primitive at the masked pixel +/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is +/// routinely in a different layer from the content it clips -- a rounded +/// container in one layer, a `Stack`'s child content in the layer below. +/// A per-layer buffer cannot answer that lookup at all: only one layer's +/// group is bound at a time, so the mask would silently read another +/// layer's rect. Both buffers are therefore global and bound once per +/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]). +/// +/// Slots are stable for a primitive's whole life: nothing here is +/// compacted, so a `Mask` can hold a slot across frames. +pub struct Primitives { + instances: Vec, + assoc: Vec, + /// Where each slot's [`PrimitiveHandle`] sits in its owner's + /// `ActiveData::primitives` -- the index that makes + /// `UiRenderState::apply_free` O(1) per renumbered primitive instead + /// of a scan of everything the owner drew. Written by + /// [`Self::set_handle_index`] from the one place a handle is taken + /// into that vec (`Painter::own`), and dead alongside its `assoc` + /// entry, which is what keeps the two in step. + /// + /// Without it a text widget that is freed and redrawn in one frame + /// costs O(glyphs^2): every one of its glyphs is renumbered, and each + /// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a + /// 51,200-glyph block on this machine, against 20ms for the shaping + /// and rasterising of the same text. + handle_idx: Vec, + /// Slots freed since the last [`Self::apply_free`]. Deliberately not + /// reusable yet: the layer that drew one still names it in its draw + /// order until that call compacts the order, so handing it out again + /// first would draw the new primitive twice -- once through the stale + /// order entry and once through the new one. + freed: Vec, + /// Slots [`Self::apply_free`] released, which is what [`Self::alloc`] + /// hands out. + reusable: Vec, + data: PrimitiveData, + /// Whether the instance arena or the per-primitive data changed since + /// the last upload -- one flag for both, since they are uploaded + /// together. + pub updated: bool, +} + +impl Default for Primitives { + fn default() -> Self { + Self { + instances: Default::default(), + assoc: Default::default(), + handle_idx: Default::default(), + freed: Vec::new(), + reusable: Vec::new(), + data: Default::default(), + updated: true, + } + } } impl Primitives { - pub fn write( + /// A slot whose handle has not been recorded yet -- see + /// [`Self::handle_idx`]. No owner draws four billion primitives, so + /// the sentinel cannot collide with a real index. + const NO_HANDLE: u32 = u32::MAX; + + /// Writes a primitive into the arena and hands back its slot and its + /// entry in the per-primitive data. The caller (`UiRenderState`) puts + /// the slot into a layer's draw order -- an instance that no layer + /// names is never rasterized, which is what a mask shape drawn only to + /// be *referenced* uses. + pub fn alloc( &mut self, - layer: usize, PrimitiveInst { id, primitive, region, mask_idx, + move_idx, }: PrimitiveInst

, - ) -> PrimitiveHandle { + ) -> (u32, usize) { + let data_idx = P::vec(&mut self.data).add(primitive); + let slot = self.push( + PrimitiveInstance { + region, + idx: data_idx as u32, + mask_idx, + move_idx, + binding: P::BINDING, + }, + id, + ); + (slot, data_idx) + } + + /// A standalone image, which has no `PrimitiveData` entry to allocate + /// -- its bind group already picks the texture, so `texture_idx` rides + /// in the otherwise-unused `idx` field and names the bind group the + /// draw call selects. + pub fn alloc_image( + &mut self, + id: WidgetId, + texture_idx: u32, + region: UiRegion, + mask_idx: MaskIdx, + move_idx: MoveIdx, + ) -> u32 { + self.push( + PrimitiveInstance { + region, + idx: texture_idx, + mask_idx, + move_idx, + binding: IMAGE_BINDING, + }, + id, + ) + } + + fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 { self.updated = true; - let vec = P::vec(&mut self.data); - let i = vec.add(primitive); - let inst = PrimitiveInstance { - region, - idx: i as u32, - mask_idx, - binding: P::BINDING, - }; - let inst_i = if let Some(i) = self.free.pop() { + let slot = if let Some(i) = self.reusable.pop() { self.instances[i] = inst; self.assoc[i] = id; + self.handle_idx[i] = Self::NO_HANDLE; i } else { - let i = self.instances.len(); self.instances.push(inst); self.assoc.push(id); - i + self.handle_idx.push(Self::NO_HANDLE); + self.instances.len() - 1 }; - PrimitiveHandle::new::

(layer, inst_i, i) - } - - /// returns (old index, new index) - pub fn apply_free(&mut self) -> impl Iterator { - self.free.sort_by(|a, b| b.cmp(a)); - self.free.drain(..).filter_map(|i| { - self.instances.swap_remove(i); - self.assoc.swap_remove(i); - if i == self.instances.len() { - return None; - } - let id = self.assoc[i]; - let old = self.instances.len(); - Some(PrimitiveChange { id, old, new: i }) - }) + slot as u32 } + /// Retires a slot, answering the mask it was drawn under so the caller + /// can drop that mask's ref. The slot itself only becomes reusable at + /// the next [`Self::apply_free`] -- see `freed`. pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx { self.updated = true; - self.data.free(h.binding, h.data_idx); - self.free.push(h.inst_idx); - self.instances[h.inst_idx].mask_idx + let slot = h.slot as usize; + if h.binding != IMAGE_BINDING { + self.data.free(h.binding, h.data_idx); + } + self.freed.push(slot); + self.instances[slot].mask_idx + } + + /// Hands this frame's freed slots back for reuse. Called once per + /// frame from `UiRenderState::update`, **after** every layer has + /// compacted its draw order, since that order is the only thing still + /// naming them. + pub fn release_freed(&mut self) { + self.reusable.append(&mut self.freed); + } + + /// Which widget drew the primitive in `slot` -- how a draw-order + /// change finds the handle it has to renumber. + pub fn owner(&self, slot: u32) -> WidgetId { + self.assoc[slot as usize] + } + + /// Records that `slot`'s handle is `idx` entries into its owner's + /// `ActiveData::primitives`. Called once per primitive, by the one + /// place that puts a handle into that vec. + pub fn set_handle_index(&mut self, slot: u32, idx: u32) { + self.handle_idx[slot as usize] = idx; + } + + /// Where `slot`'s handle sits in its owner's `ActiveData::primitives` + /// -- see [`Self::handle_idx`]. `None` only for a slot whose owner + /// never took the handle, which nothing in this crate does. + pub fn handle_index(&self, slot: u32) -> Option { + match self.handle_idx[slot as usize] { + Self::NO_HANDLE => None, + idx => Some(idx as usize), + } + } + + pub fn clear(&mut self) { + self.updated = true; + self.instances.clear(); + self.assoc.clear(); + self.handle_idx.clear(); + self.freed.clear(); + self.reusable.clear(); + self.data.clear(); + } + + /// How many instances are still live -- the O(1) half of the orphan + /// check, so the O(primitives) walk below only runs on a frame that + /// already looks wrong. See + /// [`crate::UiRenderState::orphaned_primitives`]. + pub fn live_count(&self) -> usize { + self.instances.len() - self.freed.len() - self.reusable.len() + } + + /// Every live instance as `(slot, owner, is_image)` -- everything + /// except the freed and the reusable. Only + /// [`crate::UiRenderState::orphaned_primitives`] uses this, to check + /// that every live primitive still belongs to a live widget. + pub fn live_instances(&self) -> impl Iterator + '_ { + let dead: HashSet = self.freed.iter().chain(&self.reusable).copied().collect(); + (0..self.instances.len()) + .filter(move |i| !dead.contains(i)) + .map(|i| { + ( + i as u32, + self.assoc[i], + self.instances[i].binding == IMAGE_BINDING, + ) + }) } pub fn data(&self) -> &PrimitiveData { @@ -167,40 +330,166 @@ impl Primitives { &self.instances } + pub fn instance(&self, slot: u32) -> &PrimitiveInstance { + &self.instances[slot as usize] + } + + /// The per-primitive data behind `slot`, or `None` if that slot holds + /// a different kind of primitive -- the `binding` check is the same + /// one the shader's dispatch switch makes, and it is what stops a + /// caller reading a glyph's index into the rect table. + pub fn primitive_data(&self, slot: u32) -> Option<&P> { + let inst = self.instance(slot); + (inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize]) + } + pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion { self.updated = true; - &mut self.instances[h.inst_idx].region + &mut self.instances[h.slot as usize].region } } -pub struct PrimitiveChange { - pub id: WidgetId, - pub old: usize, - pub new: usize, +/// One layer's draw order: the slots of the global arena it draws, in the +/// order they were written. The vertex buffer of a layer is exactly this. +/// +/// Both lists free with `swap_remove`, so a layer's draw order was already +/// undefined before this split: nothing here may assume one primitive +/// stays adjacent to another once anything in the layer has been freed. +#[derive(Default)] +pub struct LayerOrder { + order: Vec, + /// Standalone images, kept apart because each draws with its own bind + /// group rather than sharing the layer's one instanced draw -- see + /// `UiRenderNode::draw`. + images: Vec, + free: Vec, + image_free: Vec, + pub updated: bool, } +impl LayerOrder { + pub fn push(&mut self, slot: u32, is_image: bool) -> usize { + self.updated = true; + let list = if is_image { + &mut self.images + } else { + &mut self.order + }; + list.push(slot); + list.len() - 1 + } + + /// Marks a position for removal. Deferred to [`Self::apply_free`] like + /// the arena's own, so that a position is only renumbered once per + /// frame however many were dropped. + pub fn free(&mut self, pos: usize, is_image: bool) { + self.updated = true; + if is_image { + self.image_free.push(pos); + } else { + self.free.push(pos); + } + } + + /// Compacts both lists, answering every primitive whose position + /// moved so its handle can be corrected. + pub fn apply_free(&mut self) -> Vec { + let mut changes = Self::apply_free_list(&mut self.free, &mut self.order, false); + changes.extend(Self::apply_free_list( + &mut self.image_free, + &mut self.images, + true, + )); + changes + } + + fn apply_free_list( + free: &mut Vec, + list: &mut Vec, + is_image: bool, + ) -> Vec { + // Descending, so removing a contiguous tail costs no renumbering + // at all -- which is what freeing one widget's primitives is. + free.sort_by(|a, b| b.cmp(a)); + free.drain(..) + .filter_map(|pos| { + list.swap_remove(pos); + if pos == list.len() { + return None; + } + Some(OrderChange { + slot: list[pos], + is_image, + pos, + }) + }) + .collect() + } + + pub fn order(&self) -> &Vec { + &self.order + } + + pub fn images(&self) -> &Vec { + &self.images + } +} + +/// A primitive whose position in a layer's draw order moved when +/// something before it was freed -- `slot` names which primitive, so its +/// owner's handle can be found and pointed at `pos`. +pub struct OrderChange { + pub slot: u32, + /// Which of the layer's two lists moved: their positions are + /// independent index spaces, so a handle matching on position alone + /// could take an image's renumbering for a rect's. + pub is_image: bool, + pub pos: usize, +} + +/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is +/// a primitive written only to be *referenced* -- a mask's shape +/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and +/// freed exactly like any other; it is simply never rasterized. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Drawn { + Yes, + No, +} + +/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so +/// there is no position to renumber or free. +pub const NOT_DRAWN: usize = usize::MAX; + +/// Where one primitive lives: its stable slot in the global arena, and +/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is +/// only referenced). #[derive(Debug)] pub struct PrimitiveHandle { pub layer: usize, - pub inst_idx: usize, + pub pos: usize, + pub slot: u32, pub data_idx: usize, pub binding: u32, } impl PrimitiveHandle { - fn new(layer: usize, inst_idx: usize, data_idx: usize) -> Self { - Self { - layer, - inst_idx, - data_idx, - binding: P::BINDING, - } + pub fn is_image(&self) -> bool { + self.binding == IMAGE_BINDING } } +pub struct PrimitiveInst

{ + pub id: WidgetId, + pub primitive: P, + pub region: UiRegion, + pub mask_idx: MaskIdx, + pub move_idx: MoveIdx, +} + primitives!( rects: RectPrimitive => 0, - textures: TexturePrimitive => 1, + glyphs: GlyphPrimitive => 2, ); #[repr(C)] @@ -223,11 +512,48 @@ impl RectPrimitive { } } +/// One glyph, drawn as a sub-rectangle of the glyph atlas array. +/// +/// `color` is the text colour and is multiplied by the atlas's alpha for an +/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and +/// takes the atlas texel unchanged, which is what `IS_COLOR` selects. #[repr(C)] #[derive(Debug, Copy, Clone)] -pub struct TexturePrimitive { - pub view_idx: u32, - pub sampler_idx: u32, +pub struct GlyphPrimitive { + pub uv_min: [f32; 2], + pub uv_max: [f32; 2], + /// Layer of the shared atlas array texture this glyph's page occupies -- + /// not a bind-group or view index, since a page never gets one of its + /// own. See TEXTURES.md's "Recommended shape". + pub layer: u32, + pub color: Color, + pub flags: u32, + /// Pads this struct's Rust size to match WGSL's storage-buffer layout for + /// `GlyphInfo`: two `vec2` members give the struct an 8-byte + /// alignment, which rounds the WGSL size up to 32 bytes even though the + /// fields above only total 28. `bytemuck` does not check this for us. + _pad: u32, +} + +impl GlyphPrimitive { + pub const IS_COLOR: u32 = 1; + + pub fn new( + uv_min: [f32; 2], + uv_max: [f32; 2], + layer: u32, + color: Color, + flags: u32, + ) -> Self { + Self { + uv_min, + uv_max, + layer, + color, + flags, + _pad: 0, + } + } } pub struct PrimitiveVec { diff --git a/core/src/render/sdf.rs b/core/src/render/sdf.rs new file mode 100644 index 0000000..64a11ce --- /dev/null +++ b/core/src/render/sdf.rs @@ -0,0 +1,54 @@ +//! The rounded-rect coverage function, on the CPU. +//! +//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a +//! transliteration of these two, line for line, and +//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two +//! at a grid of points against values the shader itself produced. They are +//! kept together here, in the crate both a renderer and a hit test can +//! reach, because LAYOUT.md's "Masks with a shape" turns on the two +//! agreeing: a masked corner that cannot be tapped and a masked corner +//! that is not drawn have to be the same corner, and they are only the +//! same corner while one function decides both. +//! +//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion` +//! units, which the shader has already resolved by the time it evaluates +//! this. + +use crate::util::Vec2; + +/// The signed distance from `pos` to a rounded rect given by its centre, +/// its corner offset (half its size) and its corner `radius`. Negative +/// inside. +pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 { + // vec from center to pixel + let p = pos - center; + // vec from inner rect corner to pixel + let q = Vec2::new( + p.x.abs() - (corner.x - radius), + p.y.abs() - (corner.y - radius), + ); + let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0)); + (clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius +} + +/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over +/// the half-pixel either side of its edge: 1 well inside, 0 well outside. +/// +/// The half-pixel feather is why a hit test asks for **more than a half** +/// rather than "any coverage at all": half is where the geometric edge is, +/// so the two answer the same question the drawn shape does. +pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 { + let edge: f32 = 0.5; + let corner = (bot_right - top_left) / 2.0; + let center = top_left + corner; + let dist = distance_from_rect(pos, center, corner, radius); + 1.0 - smoothstep(-edge.min(radius), edge, dist) +} + +/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL +/// when `low == high`, which is why the caller above never passes a zero +/// radius into the low edge without `edge` bounding it. +fn smoothstep(low: f32, high: f32, x: f32) -> f32 { + let t = ((x - low) / (high - low)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} diff --git a/core/src/render/shader.wgsl b/core/src/render/shader.wgsl index 197d101..334d920 100644 --- a/core/src/render/shader.wgsl +++ b/core/src/render/shader.wgsl @@ -1,12 +1,16 @@ const RECT: u32 = 0u; +// TEXTURE has no entry in group 1: a standalone image draws with its own +// bind group (see UiRenderNode::draw), so there is nothing per-instance left +// to look up here -- the bind group already picked the texture. const TEXTURE: u32 = 1u; +const GLYPH: u32 = 2u; @group(0) @binding(0) var window: WindowUniform; @group(1) @binding(RECT) var rects: array; -@group(1) @binding(TEXTURE) -var textures: array; +@group(1) @binding(GLYPH) +var glyphs: array; struct Rect { color: u32, @@ -15,14 +19,30 @@ struct Rect { inner_radius: f32, } -struct TextureInfo { - view_idx: u32, - sampler_idx: u32, +struct GlyphInfo { + uv_min: vec2, + uv_max: vec2, + // Layer of the shared atlas array texture, not a view or bind-group + // index -- a page never gets its own bind group. See TEXTURES.md's + // "Recommended shape". + layer: u32, + color: u32, + flags: u32, } +/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage +/// clips this mask's subtree, and the mask it nests inside +/// (`4294967295u` at the top). struct Mask { - x: UiSpan, - y: UiSpan, + primitive: u32, + parent: u32, +} + +/// One widget's cumulative on-screen translation and the slot of the +/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs. +struct MoveOffset { + delta: vec2, + parent: u32, } struct UiSpan { @@ -35,39 +55,93 @@ struct UiScalar { abs: f32, } -struct UiVec2 { - rel: vec2, - abs: vec2, -} - +// The shared glyph atlas: every page is one layer. Growing it recreates this +// texture with headroom and copies the old layers across -- see +// GpuTextures::grow_array -- rather than the binding_array> +// this replaced, which needed VK_EXT_descriptor_indexing and does not survive +// a real share of Android GPUs (see TEXTURES.md). @group(2) @binding(0) -var views: binding_array>; +var atlas: texture_2d_array; +// One standalone image's texture. The main draw (rects and glyphs) binds a +// 1x1 null texture here, since neither samples it; each image draw call +// binds its own -- see UiRenderNode::draw. @group(2) @binding(1) -var samplers: binding_array; +var image_texture: texture_2d; @group(2) @binding(2) +var samp: sampler; +// Their own group, bound once per frame rather than folded into group 2: see +// UiRenderNode::masks_layout for why an image's own bind group must not name +// either buffer. +@group(3) @binding(0) var masks: array; +@group(3) @binding(1) +var move_offsets: array; +// Every primitive's placement, in one arena all layers share. The vertex +// stage reads the primitive it is drawing (its slot arrives as the only +// vertex attribute); the fragment stage reads a *mask's* primitive, which +// is generally a different one in a different layer. See LAYOUT.md's +// "Masks with a shape" and `Primitives` in primitive.rs. +@group(3) @binding(2) +var instances: array; + +// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in +// render_state.rs, which walks the identical chain on the CPU side for +// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot +// hang the GPU -- not a claim about how deep a real tree gets. It was 16 +// and that was too small: the transcript screen's composer field sits 17 +// slots below the root, measured 2026-09-07 on this checkout's emulator +// by tapping it (the CPU walk's own debug assert names the chain now). +// Past the bound both walks simply stop summing, so the widget draws and +// hit-tests short by whatever the outer slots held, with nothing on +// screen to say so. +const PARENT_CHAIN_LIMIT: u32 = 64u; + +/// Sums the pixel delta along the parent chain starting at `idx`, shared by +/// the vertex stage (a primitive's own corners) and the fragment stage (its +/// mask's corners) so the walk is written once. See LAYOUT.md section 2b. +fn resolve_move(idx: u32) -> vec2 { + var total = vec2(0.0, 0.0); + var i = idx; + for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) { + let entry = move_offsets[i]; + total += entry.delta; + if entry.parent == 4294967295u { + break; + } + i = entry.parent; + } + return total; +} struct WindowUniform { dim: vec2, }; +/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to +/// draw there. `x`/`y` are the `UiRegion`'s two spans. +struct PrimitiveInstance { + x: UiSpan, + y: UiSpan, + binding: u32, + idx: u32, + mask_idx: u32, + move_idx: u32, +} + +/// A layer's draw order: one slot into `instances` per instance drawn. struct InstanceInput { - @location(0) x_start: vec2, - @location(1) x_end: vec2, - @location(2) y_start: vec2, - @location(3) y_end: vec2, - @location(4) binding: u32, - @location(5) idx: u32, - @location(6) mask_idx: u32, + @location(0) slot: u32, } struct VertexOutput { @location(0) top_left: vec2, @location(1) bot_right: vec2, @location(2) uv: vec2, - @location(3) binding: u32, - @location(4) idx: u32, - @location(5) mask_idx: u32, + // `flat` is the only interpolation an integer can have, and naga + // (wgpu 30) now requires saying so rather than inferring it. + @location(3) @interpolate(flat) binding: u32, + @location(4) @interpolate(flat) idx: u32, + @location(5) @interpolate(flat) mask_idx: u32, @builtin(position) clip_position: vec4, }; @@ -78,20 +152,38 @@ struct Region { bot_right: vec2, } +/// One primitive's on-screen corners in window pixels. Written once and +/// used by both stages: the vertex stage for the primitive it is drawing, +/// the fragment stage for a mask's -- so the shape a mask clips to and the +/// shape that was drawn cannot be computed two different ways. +struct Corners { + top_left: vec2, + bot_right: vec2, +} + +fn corners_of(inst: PrimitiveInstance) -> Corners { + let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel); + let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs); + let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel); + let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs); + let move_delta = resolve_move(inst.move_idx); + return Corners( + floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta, + floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta, + ); +} + @vertex fn vs_main( @builtin(vertex_index) vi: u32, in: InstanceInput, ) -> VertexOutput { var out: VertexOutput; + let inst = instances[in.slot]; - let top_left_rel = vec2(in.x_start.x, in.y_start.x); - let top_left_abs = vec2(in.x_start.y, in.y_start.y); - let bot_right_rel = vec2(in.x_end.x, in.y_end.x); - let bot_right_abs = vec2(in.x_end.y, in.y_end.y); - - let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs); - let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs); + let c = corners_of(inst); + let top_left = c.top_left; + let bot_right = c.bot_right; let size = bot_right - top_left; let uv = vec2( @@ -101,11 +193,11 @@ fn vs_main( let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0; out.clip_position = vec4(pos.x, -pos.y, 0.0, 1.0); out.uv = uv; - out.binding = in.binding; - out.idx = in.idx; + out.binding = inst.binding; + out.idx = inst.idx; out.top_left = top_left; out.bot_right = bot_right; - out.mask_idx = in.mask_idx; + out.mask_idx = inst.mask_idx; return out; } @@ -123,29 +215,79 @@ fn fs_main( color = draw_rounded_rect(region, rects[i]); } case TEXTURE: { - color = draw_texture(region, textures[i]); + color = draw_texture(region); + } + case GLYPH: { + color = draw_glyph(region, glyphs[i]); } default: { color = vec4(1.0, 0.0, 1.0, 1.0); } } - if in.mask_idx != 4294967295u { - let mask = masks[in.mask_idx]; - let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs)); - let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs)); - - let top_left = floor(tl.rel * window.dim) + floor(tl.abs); - let bot_right = floor(br.rel * window.dim) + floor(br.abs); - if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { - color *= 0.0; + // Every mask on the chain, not just the innermost: a widget that set + // its own mask inside another is clipped by both, and the coverages + // multiply -- so a pixel inside two feathered corners is dimmed by + // both, which is what a compositor does (`Mask::parent` in data.rs). + var mask_idx = in.mask_idx; + for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) { + if mask_idx == 4294967295u { + break; } + let mask = masks[mask_idx]; + color.a *= mask_coverage(pos, mask); + mask_idx = mask.parent; } return color; } -// TODO: this seems really inefficient (per frag indexing)? -fn draw_texture(region: Region, info: TextureInfo) -> vec4 { - return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv); +/// How much of `pos` one mask lets through: the referenced primitive's +/// own coverage at that pixel, from the same SDF the primitive is drawn +/// with. Nothing about the shape is copied into the mask, so a rounded +/// container's corner and its children's clipped corner are the same +/// arithmetic. +fn mask_coverage(pos: vec2, mask: Mask) -> f32 { + let inst = instances[mask.primitive]; + if inst.binding != RECT { + // Unreachable: `Painter::set_mask` rejects a glyph or an image + // shape by name (see `Mask::primitive`). Letting the pixel + // through rather than reading a `rects` entry that is not there. + return 1.0; + } + let c = corners_of(inst); + return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius); +} + +fn draw_texture(region: Region) -> vec4 { + return textureSample(image_texture, samp, region.uv); +} + +fn draw_glyph(region: Region, g: GlyphInfo) -> vec4 { + let uv = mix(g.uv_min, g.uv_max, region.uv); + let texel = textureSample(atlas, samp, uv, i32(g.layer)); + if (g.flags & 1u) != 0u { + return texel; + } + var color = unpack4x8unorm(g.color); + color.a *= texel.a; + return color; +} + +/// The anti-aliased coverage of a rounded rect at one pixel -- the one +/// function both a drawn rect and a mask go through, and the +/// transliteration of `iris_core::rounded_rect_coverage` on the CPU, +/// which the hit test uses so a corner that cannot be tapped and a corner +/// that is not drawn are the same corner. +fn rounded_rect_coverage( + pos: vec2, + top_left: vec2, + bot_right: vec2, + radius: f32, +) -> f32 { + let edge = 0.5; + let corner = (bot_right - top_left) / 2.0; + let center = top_left + corner; + let dist = distance_from_rect(pos, center, corner, radius); + return 1.0 - smoothstep(-min(edge, radius), edge, dist); } fn draw_rounded_rect(region: Region, rect: Rect) -> vec4 { @@ -153,14 +295,12 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4 { let edge = 0.5; - let size = region.bot_right - region.top_left; - let corner = size / 2.0; - let center = region.top_left + corner; - - let dist = distance_from_rect(region.pos, center, corner, rect.radius); - color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist); + color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius); if rect.thickness > 0.0 { + let size = region.bot_right - region.top_left; + let corner = size / 2.0; + let center = region.top_left + corner; let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius); color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2); } diff --git a/core/src/render/texture.rs b/core/src/render/texture.rs index a9ec637..9fac2e4 100644 --- a/core/src/render/texture.rs +++ b/core/src/render/texture.rs @@ -1,59 +1,306 @@ -use image::{DynamicImage, EncodableLayout}; +use image::{DynamicImage, EncodableLayout, GenericImageView}; use wgpu::{util::DeviceExt, *}; -use crate::{TextureUpdate, Textures}; +use crate::{PatchRect, TextureKind, TextureUpdate, Textures}; +use super::atlas::PAGE; + +/// The fewest layers the glyph atlas array is ever created with. Two, not +/// one, for the GLES reason written on `create_array_texture`. +const MIN_ARRAY_LAYERS: u32 = 2; + +/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot +/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the +/// same thing on both sides without a second map to keep in sync. +enum Slot { + /// A slot that was freed, or pushed and freed within the same batch + /// before ever reaching here. + Empty, + Image(ImageGpu), + /// The array layer a page occupies. Pages are never freed (see + /// `Textures::free`), so this is the only variant that outlives a `Free`. + Page(u32), +} + +struct ImageGpu { + /// Kept alive alongside `view`/`bind_group`, which borrow from it only in + /// the sense that dropping this drops the GPU resource they point to. + #[allow(dead_code)] + texture: Texture, + view: TextureView, + bind_group: BindGroup, +} + +/// Owns the two kinds of texture iris draws: +/// +/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages +/// (`Slot::Page`), grown by recreating the array with headroom and +/// `copy_texture_to_texture`-ing the old layers across. No feature beyond +/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an +/// ordinary sampling operand. +/// - **Standalone images** (`Slot::Image`), each its own `Texture` and +/// `BindGroup`, drawn one `draw()` call at a time with that bind group +/// bound -- see `UiRenderNode::draw`. +/// +/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's +/// "iris's binding array does not survive real Android hardware" for what +/// this replaced (one giant `binding_array>` needing +/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack). pub struct GpuTextures { device: Device, queue: Queue, - views: Vec, - view_count: usize, - samplers: Vec, + + slots: Vec, + + array_texture: Texture, + array_view: TextureView, + array_capacity: u32, + /// Layers actually written. Only grows -- see `Slot::Page`. + page_count: u32, + + sampler: Sampler, + /// Bound in the image slot of the main draw's bind group, which has + /// nothing of its own to put there: rects and glyphs never sample it, + /// but the layout requires something bound regardless. null_view: TextureView, - no_views: Vec, + + /// Standalone-image bind groups actually built (`create_image`'s own + /// build, or one per slot touched by `rebuild_image_bind_groups`) since + /// the last `take_bind_group_creates`. IRIS_TODO.md's "many images" + /// benchmark reads this to prove the steady-state cost of an + /// unchanging image list is zero, the same way `UiRenderState`'s + /// `draw_count`/`region_mut_count` prove the layout side. + bind_group_creates: u64, + /// `grow_array` calls since the last `take_pages_grown` -- the + /// Diagnostics page's per-frame report (RUST.md's P0 box, "the first + /// input frame" investigation) reads this alongside `bind_group_creates` + /// to say whether *this* frame's glyph disappearance, if any, coincided + /// with the atlas array being recreated. + pages_grown: u64, } impl GpuTextures { - pub fn update(&mut self, textures: &mut Textures) -> bool { - let mut changed = false; + /// Applies queued `Textures` updates, then reports whether the *main* + /// bind group (the one rects and glyphs draw with) needs rebuilding -- + /// true exactly when the atlas array was recreated (its view identity + /// changed). Pushing or freeing a standalone image never touches that + /// group: it built or drops its own. Masks/move_offsets resizing is + /// `UiRenderNode`'s own concern now (its `masks_group`, group 3) -- + /// see that struct's field comment for why standalone images no longer + /// hear about either buffer at all. + pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool { + let mut rebuild_main = false; for update in textures.updates() { - changed = true; match update { - TextureUpdate::Push(image) => self.push(image), - TextureUpdate::Set(i, image) => self.set(i, image), - TextureUpdate::SetFree => self.view_count += 1, + TextureUpdate::Push(kind, image) => { + rebuild_main |= self.push(kind, image, rsc_layout); + } + TextureUpdate::Set(kind, i, image) => { + rebuild_main |= self.set(kind, i, image, rsc_layout); + } + // A patch changes texture contents, not which layer or bind + // group exists, so it never asks for a rebuild -- rebuilding + // per glyph is exactly the cost this exists to avoid. + TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image), + TextureUpdate::SetFree => {} TextureUpdate::Free(i) => self.free(i), - TextureUpdate::PushFree => self.push_free(), + TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty), } } - changed - } - fn set(&mut self, i: u32, image: &DynamicImage) { - self.view_count += 1; - let view = self.create_view(image); - self.views[i as usize] = view; - } - fn free(&mut self, i: u32) { - self.view_count -= 1; - self.views[i as usize] = self.null_view.clone(); - } - fn push(&mut self, image: &DynamicImage) { - self.view_count += 1; - let view = self.create_view(image); - self.views.push(view); - } - fn push_free(&mut self) { - self.view_count += 1; - self.views.push(self.null_view.clone()); + rebuild_main } - fn create_view(&self, image: &DynamicImage) -> TextureView { - let image = image.to_rgba8(); - let (width, height) = image.dimensions(); + fn push( + &mut self, + kind: TextureKind, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + ) -> bool { + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout); + self.slots.push(slot); + rebuilt + } + + fn set( + &mut self, + kind: TextureKind, + i: u32, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + ) -> bool { + let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout); + self.slots[i as usize] = slot; + rebuilt + } + + fn make_slot( + &mut self, + kind: TextureKind, + image: &DynamicImage, + rsc_layout: &BindGroupLayout, + ) -> (Slot, bool) { + match kind { + TextureKind::Image => { + let gpu = self.create_image(image, rsc_layout); + (Slot::Image(gpu), false) + } + TextureKind::Page { layer } => { + let mut rebuilt = false; + if layer >= self.array_capacity { + self.grow_array(rsc_layout); + rebuilt = true; + } + self.write_full_layer(layer, image); + self.page_count = self.page_count.max(layer + 1); + (Slot::Page(layer), rebuilt) + } + } + } + + fn free(&mut self, i: u32) { + if let Some(slot) = self.slots.get_mut(i as usize) { + *slot = Slot::Empty; + } + // A page's layer is not reclaimed here either -- see `Slot::Page`. + } + + fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) { + let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else { + return; + }; + if rect.width == 0 || rect.height == 0 { + return; + } + // Cropped rather than written straight from the atlas, because + // write_texture wants tightly packed rows and the atlas rows are as + // wide as the atlas. A glyph is small, so the copy is too. + let sub = image + .view(rect.x, rect.y, rect.width, rect.height) + .to_image(); + self.queue.write_texture( + TexelCopyTextureInfo { + texture: &self.array_texture, + mip_level: 0, + origin: Origin3d { + x: rect.x, + y: rect.y, + z: layer, + }, + aspect: TextureAspect::All, + }, + sub.as_bytes(), + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(rect.width * 4), + rows_per_image: Some(rect.height), + }, + Extent3d { + width: rect.width, + height: rect.height, + depth_or_array_layers: 1, + }, + ); + } + + fn write_full_layer(&self, layer: u32, image: &DynamicImage) { + // Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`), + // so this is always a whole-layer write, never a crop. + let rgba = image.to_rgba8(); + self.queue.write_texture( + TexelCopyTextureInfo { + texture: &self.array_texture, + mip_level: 0, + origin: Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect: TextureAspect::All, + }, + rgba.as_bytes(), + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(PAGE * 4), + rows_per_image: Some(PAGE), + }, + Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: 1, + }, + ); + } + + /// Doubles the array's layer capacity (headroom, so this is rare) and + /// copies the old layers across GPU-side -- no readback. Recreates the + /// array's view, which invalidates every bind group that referenced it, + /// so this also rebuilds all of them before returning. + fn grow_array(&mut self, rsc_layout: &BindGroupLayout) { + self.pages_grown += 1; + let new_capacity = self.array_capacity * 2; + let new_texture = Self::create_array_texture(&self.device, new_capacity); + if self.page_count > 0 { + let mut encoder = self + .device + .create_command_encoder(&CommandEncoderDescriptor { + label: Some("atlas array grow"), + }); + encoder.copy_texture_to_texture( + TexelCopyTextureInfo { + texture: &self.array_texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + TexelCopyTextureInfo { + texture: &new_texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: TextureAspect::All, + }, + Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: self.page_count, + }, + ); + self.queue.submit(std::iter::once(encoder.finish())); + } + self.array_texture = new_texture; + self.array_view = self.array_texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + }); + self.array_capacity = new_capacity; + self.rebuild_image_bind_groups(rsc_layout); + } + + /// Called only from `grow_array`: the atlas array's view identity is the + /// one thing an image's bind group (group 2) still names that can + /// change out from under it. Masks/move_offsets resizing no longer + /// reaches here at all -- see `UiRenderNode::masks_group`. + fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) { + for slot in &mut self.slots { + if let Slot::Image(gpu) = slot { + gpu.bind_group = Self::make_image_bind_group( + &self.device, + rsc_layout, + &self.array_view, + &gpu.view, + &self.sampler, + ); + self.bind_group_creates += 1; + } + } + } + + fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu { + let rgba = image.to_rgba8(); + let (width, height) = rgba.dimensions(); let texture = self.device.create_texture_with_data( &self.queue, &TextureDescriptor { - label: None, + label: Some("image"), size: Extent3d { width, height, @@ -63,45 +310,173 @@ impl GpuTextures { sample_count: 1, dimension: TextureDimension::D2, format: TextureFormat::Rgba8Unorm, - usage: TextureUsages::TEXTURE_BINDING, + usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, view_formats: &[], }, wgt::TextureDataOrder::MipMajor, - image.as_bytes(), + rgba.as_bytes(), ); - texture.create_view(&TextureViewDescriptor::default()) + let view = texture.create_view(&TextureViewDescriptor::default()); + let bind_group = Self::make_image_bind_group( + &self.device, + rsc_layout, + &self.array_view, + &view, + &self.sampler, + ); + self.bind_group_creates += 1; + ImageGpu { + texture, + view, + bind_group, + } + } + + /// Builds group 2 for one standalone image: the shared atlas array, this + /// image's own view and the shared sampler -- the same layout the main + /// draw uses with a null view in the image slot. Deliberately does not + /// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see + /// that field's comment for why folding them in here was the bug. + fn make_image_bind_group( + device: &Device, + rsc_layout: &BindGroupLayout, + array_view: &TextureView, + image_view: &TextureView, + sampler: &Sampler, + ) -> BindGroup { + device.create_bind_group(&BindGroupDescriptor { + layout: rsc_layout, + entries: &[ + BindGroupEntry { + binding: 0, + resource: BindingResource::TextureView(array_view), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(image_view), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::Sampler(sampler), + }, + ], + label: Some("ui rsc image"), + }) + } + + /// The atlas is sampled as a `texture_2d_array`, and **a one-layer + /// array is not one on the GLES backend**: wgpu-hal picks the GL + /// texture target from the descriptor alone + /// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), + /// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the + /// shader's `sampler2DArray`. GL then treats that unit as incomplete + /// and every `textureSample` returns (0, 0, 0, 1) -- which, through + /// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid + /// filled box. That was iris's appearance on the emulator's GLES for + /// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and + /// it is a real defect on any device whose adapter is GL rather than + /// Vulkan, not an emulator artifact. So the array never has fewer than + /// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of + /// texture memory and is used by the next atlas page anyway. + fn create_array_texture(device: &Device, capacity: u32) -> Texture { + debug_assert!( + capacity >= MIN_ARRAY_LAYERS, + "glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \ + GL_TEXTURE_2D on the GLES backend and draws every glyph as a box" + ); + device.create_texture(&TextureDescriptor { + label: Some("glyph atlas array"), + size: Extent3d { + width: PAGE, + height: PAGE, + depth_or_array_layers: capacity, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: TextureFormat::Rgba8Unorm, + usage: TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::COPY_SRC, + view_formats: &[], + }) } pub fn new(device: &Device, queue: &Queue) -> Self { + let sampler = default_sampler(device); let null_view = null_texture_view(device); + let array_capacity = MIN_ARRAY_LAYERS; + let array_texture = Self::create_array_texture(device, array_capacity); + let array_view = array_texture.create_view(&TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..Default::default() + }); Self { device: device.clone(), queue: queue.clone(), - views: Vec::new(), - samplers: vec![default_sampler(device)], - no_views: vec![null_view.clone()], + slots: Vec::new(), + array_texture, + array_view, + array_capacity, + page_count: 0, + sampler, null_view, - view_count: 0, + bind_group_creates: 0, + pages_grown: 0, } } - pub fn views(&self) -> Vec<&TextureView> { - if self.views.is_empty() { - &self.no_views - } else { - &self.views - } - .iter() - .by_ref() - .collect() + /// Reads and zeroes the standalone-image bind-group creation counter -- + /// call once per frame before `update()`, mirroring + /// `UiRenderState::take_counters`. + pub fn take_bind_group_creates(&mut self) -> u64 { + std::mem::take(&mut self.bind_group_creates) } - pub fn samplers(&self) -> Vec<&Sampler> { - self.samplers.iter().by_ref().collect() + /// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s + /// field comment. + pub fn take_pages_grown(&mut self) -> u64 { + std::mem::take(&mut self.pages_grown) + } + + pub fn array_view(&self) -> &TextureView { + &self.array_view + } + + pub fn null_view(&self) -> &TextureView { + &self.null_view + } + + pub fn sampler(&self) -> &Sampler { + &self.sampler + } + + /// The bind group a standalone image draws with. Panics if `idx` names an + /// atlas page or a freed slot instead -- either is a caller bug (the + /// wrong kind of instance reached this draw path), not a condition to + /// recover from. + pub fn image_bind_group(&self, idx: u32) -> &BindGroup { + match self.slots.get(idx as usize) { + Some(Slot::Image(gpu)) => &gpu.bind_group, + other => panic!("texture slot {idx} is not a live standalone image: {other:?}"), + } } pub fn view_count(&self) -> usize { - self.view_count + self.slots + .iter() + .filter(|s| !matches!(s, Slot::Empty)) + .count() + } +} + +impl std::fmt::Debug for Slot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Slot::Empty => write!(f, "Empty"), + Slot::Image(_) => write!(f, "Image"), + Slot::Page(layer) => write!(f, "Page(layer={layer})"), + } } } diff --git a/core/src/render/util/mod.rs b/core/src/render/util/mod.rs index c9d48ff..d4faf54 100644 --- a/core/src/render/util/mod.rs +++ b/core/src/render/util/mod.rs @@ -21,13 +21,18 @@ impl ArrBuf { _pd: PhantomData, } } - pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) { - if self.len != data.len() { + /// Returns whether the underlying `Buffer` was recreated -- a caller that + /// cached a `BindGroup` referencing it (as `GpuTextures` does for the + /// masks buffer) needs to know to rebuild that too. + pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool { + let resized = self.len != data.len(); + if resized { self.len = data.len(); self.buffer = Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label); } queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data)); + resized } fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer { let mut size = size as u64; diff --git a/core/src/ui/access.rs b/core/src/ui/access.rs new file mode 100644 index 0000000..485598f --- /dev/null +++ b/core/src/ui/access.rs @@ -0,0 +1,152 @@ +//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree, +//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s +//! through `accesskit_android::Adapter`, `default/mod.rs` through +//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry +//! is: `Widgets::named()` is a side set populated only by `.label()`, so a +//! widget nobody named is never visited here at all, not even to decide it +//! has no name. +//! +//! The tree itself is deliberately flat -- one synthetic `Role::Window` +//! root with every named widget as a direct child, in no particular order. +//! iris's actual widget nesting (a label three `Span`s deep inside a +//! `ScrollArea`) carries no accessibility meaning of its own here: nothing +//! upstream of a named leaf needs a node, since a screen reader's own +//! traversal (and uiautomator's tap-by-name, the pass condition this was +//! built for) works from each node's on-screen bounds rather than from +//! tree structure. Mirroring the real widget tree exactly would also mean +//! rebuilding intermediate nodes whenever *any* container above a named +//! widget resizes, which is most frames -- the flat shape is what keeps +//! rebuilds tied to "a name, a role or a position actually changed". + +use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap}; +use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate}; + +/// Reserved for the synthetic root; every real widget's `SlotId::as_u64` +/// starts at 1, so this can never collide with one (see that method's +/// doc comment). +const WINDOW_NODE: NodeId = NodeId(0); + +fn node_id(id: WidgetId) -> NodeId { + NodeId(id.as_u64()) +} + +#[derive(Clone, PartialEq)] +struct Entry { + name: String, + role: Role, + bounds: PixelRegion, +} + +fn entry_node(entry: &Entry) -> Node { + let mut node = Node::new(entry.role); + node.set_label(entry.name.clone()); + node.set_bounds(Rect { + x0: entry.bounds.top_left.x as f64, + y0: entry.bounds.top_left.y as f64, + x1: entry.bounds.bot_right.x as f64, + y1: entry.bounds.bot_right.y as f64, + }); + node +} + +/// Owns the last tree pushed out, so `update` can tell "nothing +/// accessibility-relevant changed" from "something did" without asking +/// the platform adapter to diff two `Node`s itself. One of these per +/// window/view -- `default::DefaultUiState` and `android::AndroidUiState` +/// each keep one. +#[derive(Default)] +pub struct AccessTree { + known: HashMap, + /// `TreeUpdate`s actually produced since the last `take_rebuilds` -- + /// the AccessKit-tree twin of `UiRenderState::take_counters`. Should + /// stay at 0 across an unchanged frame and move by exactly 1 when a + /// named widget's position, name or role changes, however many other + /// widgets are on screen; see `iris/src/access_tests.rs`. + rebuilds: u64, +} + +impl AccessTree { + pub fn new() -> Self { + Self::default() + } + + fn collect( + widgets: &Widgets, + render: &UiRenderState, + rsc: &dyn UiRsc, + ) -> HashMap { + let mut current = HashMap::default(); + for id in widgets.named() { + let Some(bounds) = render.window_region(&id, rsc) else { + continue; + }; + let Some(widget) = widgets.get_dyn(id) else { + continue; + }; + current.insert( + id, + Entry { + name: widgets.label(id).clone(), + role: widget.access_role(), + bounds, + }, + ); + } + current + } + + /// Walks `widgets.named()`, looks up each one's current screen bounds + /// via `render.window_region` (which resolves the same move-chain + /// `resolved_region` does, so a moved subtree reports where it + /// actually is), and returns a full `TreeUpdate` if and only if that + /// set differs from the last call -- added, removed, renamed, or + /// moved/resized. A widget that is named but not currently active + /// (not drawn this frame) is left out, the same as one never named at + /// all. + pub fn update( + &mut self, + widgets: &Widgets, + render: &UiRenderState, + rsc: &dyn UiRsc, + ) -> Option { + let current = Self::collect(widgets, render, rsc); + if current == self.known { + return None; + } + self.known = current.clone(); + self.rebuilds += 1; + Some(build_update(¤t)) + } + + /// The unconditional twin of `update`, for a platform adapter's + /// activation handler (`android/access.rs`'s `AndroidAccessSource`) -- + /// AccessKit asks for a full tree the first time a client attaches, + /// which is exactly the case `update`'s diff-against-`known` is not + /// meant to answer (it may have already sent this same snapshot to a + /// client that has since detached and reattached). + pub fn build_full(widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> TreeUpdate { + build_update(&Self::collect(widgets, render, rsc)) + } + + /// Reads and zeroes the rebuild counter, the same call shape as + /// `UiRenderState::take_counters`. + pub fn take_rebuilds(&mut self) -> u64 { + std::mem::take(&mut self.rebuilds) + } +} + +fn build_update(current: &HashMap) -> TreeUpdate { + let mut window = Node::new(Role::Window); + let mut nodes = Vec::with_capacity(current.len() + 1); + for (&id, entry) in current { + window.push_child(node_id(id)); + nodes.push((node_id(id), entry_node(entry))); + } + nodes.push((WINDOW_NODE, window)); + TreeUpdate { + nodes, + tree: Some(TreeInfo::new(WINDOW_NODE)), + tree_id: TreeId::ROOT, + focus: WINDOW_NODE, + } +} diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index b2c6ec9..468bfd3 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -1,4 +1,6 @@ -use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId}; +use crate::{ + LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2, +}; /// important non rendering data for retained drawing #[derive(Debug)] @@ -9,6 +11,59 @@ pub struct ActiveData { pub textures: Vec, pub primitives: Vec, pub children: Vec, + /// The mask this widget was drawn **under** (its parent's), not the + /// one it set for itself -- see `own_mask` for that. pub mask: MaskIdx, + /// The mask slot this widget allocated for *itself* with + /// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and + /// rewritten in place, the way `move_slot` is: a `Masked` that pushed + /// a fresh slot each draw left every already-drawn descendant -- + /// which `draw_inner`'s unchanged-region fast path does not revisit -- + /// clipping to the *old* slot's region, so a composer whose bar had + /// since been placed at the bottom of the screen was still being + /// clipped to a box at the top of it and drew nothing (measured + /// 2026-09-06: four mask entries live, none of them the widget's + /// current region). Its path out is the `undraw` branch of + /// `UiRenderState::remove`, which drops the self-ownership ref taken + /// when the slot was allocated. + pub own_mask: MaskIdx, pub layer: LayerId, + /// What `Widget::draw` returned the last time this widget was actually + /// drawn -- read by a parent placing this widget again without + /// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md + /// section 5. + pub size: Size, + /// This widget's slot in `UiData::move_offsets`, assigned on its first + /// draw and kept for the rest of its life (redraws reuse it in place + /// so a retained child's `parent` link never goes stale). See + /// LAYOUT.md section 2. + pub move_slot: MoveIdx, + /// How much of this widget's own `move_slot` delta is already folded + /// into `region` above, in window pixels. The two mechanisms that + /// write that slot disagree about this and cannot be told apart from + /// the slot alone: `UiRenderState::mov` shifts `region` and the delta + /// together (the *offered* region genuinely moved), while + /// `Painter::reposition` writes only the delta (`region` stays the + /// offered box and the delta says where inside it the content was + /// placed). So anything that wants the widget's real position -- + /// `resolved_region`, and through it every hit test -- must subtract + /// this from the chain sum. Without it a panned widget's own hit box + /// sits at twice the pan while its descendants' are correct, which is + /// how it went unnoticed: the composer's field became untappable + /// after a finger pan (2026-09-06). Reset to zero whenever the widget + /// is really redrawn, since `draw_inner` zeroes the slot then too. + pub move_applied: Vec2, + /// The offset the last `Painter::reposition` placed this widget's + /// content at *within* `region`, in window pixels. The move slot has + /// exactly one owner and one meaning: + /// `move_offsets[move_slot] == move_applied + repositioned`. `mov` + /// adds to the first, `reposition` overwrites the second (it + /// recomputes `from` afresh every call, so repeating it must land on + /// the same answer rather than drifting), and both then rewrite the + /// slot from the sum -- which is what lets a parent both move a child + /// with its own layout and place it inside that moved region in one + /// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a + /// row's blocks wrap. Reset to zero on a real redraw, with + /// `move_applied` and the slot itself. + pub repositioned: Vec2, } diff --git a/core/src/ui/cache.rs b/core/src/ui/cache.rs deleted file mode 100644 index 10565ee..0000000 --- a/core/src/ui/cache.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap}; - -#[derive(Default)] -pub struct Cache { - pub size: BothAxis>, -} - -impl Cache { - pub fn remove(&mut self, id: WidgetId) { - self.size.x.remove(&id); - self.size.y.remove(&id); - } - - pub fn clear(&mut self) { - self.size.x.clear(); - self.size.y.clear(); - } -} diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index 2998cbc..42d92df 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -1,15 +1,16 @@ -use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena}; +use crate::{ + Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena, +}; +mod access; mod active; -mod cache; mod painter; mod render_state; -mod size; +pub use access::*; pub use active::*; pub use painter::Painter; pub use render_state::*; -pub use size::*; #[derive(Default)] pub struct UiData { @@ -17,6 +18,52 @@ pub struct UiData { pub textures: Textures, pub text: TextData, pub masks: TrackedArena, + /// One entry per widget ever drawn, forming the parent-linked chain + /// `resolve_move` walks in both shader stages. Allocated once on a + /// widget's first draw and reused for every later redraw of the same + /// id (never reallocated), so a retained descendant's `parent` index + /// never goes stale -- see LAYOUT.md section 2. + pub move_offsets: TrackedArena, + /// Every widget whose [`crate::Widget::tick`] should run before the + /// next frame -- today, a `LazySpan` coasting through a fling. Added by + /// [`Self::animate`] when the animation starts and removed by + /// [`Self::tick_animations`] the frame its `tick` answers `false`, so + /// a stopped animation costs nothing and a dropped widget cannot be + /// ticked (`get_dyn_mut` answers `None` and it is dropped the same + /// way). + animating: Vec, +} + +impl UiData { + /// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it + /// says it is done. Idempotent -- registering an already-animating + /// widget is the ordinary case (a second fling before the first + /// settled) and must not tick it twice per frame. + pub fn animate(&mut self, id: WidgetId) { + if !self.animating.contains(&id) { + self.animating.push(id); + } + } + + /// Tick every registered widget to `now`, drop the ones that finished, + /// and say whether any is still going -- which is a backend's cue to + /// ask for another frame. Called once per frame *before* the draw, so + /// what the frame draws is this instant's position rather than the + /// previous one's. + pub fn tick_animations(&mut self, now: std::time::Instant) -> bool { + // Taken out and put back rather than iterated in place: `tick` + // needs `&mut` on the widget arena this list lives beside, and a + // widget is free to register another one while ticking. + let mut registered = std::mem::take(&mut self.animating); + registered.retain(|&id| match self.widgets.get_dyn_mut(id) { + Some(widget) => widget.tick(now), + None => false, + }); + for id in registered { + self.animate(id); + } + !self.animating.is_empty() + } } pub trait UiRsc { diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index f7f4939..9d6e54b 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -1,7 +1,10 @@ use crate::{ - Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData, - TextureHandle, UiRegion, UiRenderState, UiRsc, Widget, WidgetId, - render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst}, + Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, + UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, + render::{ + Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, + RectPrimitive, + }, util::Vec2, }; @@ -12,6 +15,11 @@ pub struct Painter<'a> { pub(super) region: UiRegion, pub(super) mask: MaskIdx, + pub(super) move_slot: MoveIdx, + /// This widget's own mask slot, reused across redraws -- see + /// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called + /// for the first time in this widget's life. + pub(super) own_mask: MaskIdx, pub(super) textures: Vec, pub(super) primitives: Vec, pub(super) children: Vec, @@ -21,19 +29,49 @@ pub struct Painter<'a> { impl<'a> Painter<'a> { fn primitive_at(&mut self, primitive: P, region: UiRegion) { - let h = self.state.layers.write( + self.write_primitive(primitive, region, Drawn::Yes); + } + + /// The one path every primitive this widget owns goes through -- + /// drawn or, for a mask's shape, only referenced. + fn write_primitive( + &mut self, + primitive: P, + region: UiRegion, + drawn: Drawn, + ) -> u32 { + let h = self.state.write_primitive( self.layer, + drawn, PrimitiveInst { id: self.id, primitive, region, mask_idx: self.mask, + move_idx: self.move_slot, }, ); if self.mask != MaskIdx::NONE { // TODO: I have no clue if this works at all :joy: self.rsc.ui_mut().masks.push_ref(self.mask); } + let slot = h.slot; + self.own(h); + slot + } + + /// Take ownership of a handle this widget just wrote. + /// + /// The one place a `PrimitiveHandle` enters `self.primitives`, and so + /// the one place that can keep `Primitives::handle_index` in step with + /// where it lands -- which is what `UiRenderState::apply_free` reads + /// instead of scanning this vec. Anything that writes a primitive + /// without coming through here leaves that index unset, and its + /// position in a layer's draw order stops being renumbered. + fn own(&mut self, h: PrimitiveHandle) { + self.state + .primitives + .set_handle_index(h.slot, self.primitives.len() as u32); self.primitives.push(h); } @@ -46,75 +84,291 @@ impl<'a> Painter<'a> { self.primitive_at(primitive, region.within(&self.region)); } + /// Clip everything this widget draws, itself and its descendants, to + /// `region`. One call per widget; a widget drawn inside another + /// widget's mask nests instead -- the new mask chains to the inherited + /// one (`Mask::parent`) and the fragment stage multiplies both + /// coverages, which is what lets a transcript row's code fence clip + /// to itself *and* to the list it scrolls inside. + /// + /// The clip is a **primitive**, not a rectangle copied into the mask: + /// this writes an undrawn `RectPrimitive` at `region` and points the + /// mask at it, so the fragment stage evaluates the same rounded-rect + /// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape". + /// + /// The slot is allocated once and **rewritten in place** on every + /// later draw rather than pushed again, because a descendant whose own + /// region did not change is not redrawn (`draw_inner`'s fast path) and + /// so keeps pointing at whichever slot it was drawn under. See + /// `ActiveData::own_mask` for what pushing a fresh one cost. pub fn set_mask(&mut self, region: UiRegion) { - assert!(self.mask == MaskIdx::NONE); - self.mask = self.rsc.ui_mut().masks.push(Mask { region }); + let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No); + self.set_mask_to(shape); } - /// Draws a widget within this widget's region. - pub fn widget(&mut self, id: &StrongWidget) { - self.widget_at(id, self.region); + /// Clip everything this widget draws after this call to `shape`'s + /// own shape -- the first primitive `shape`'s subtree drew, which + /// must already have been drawn this frame + /// (`UiRenderState::first_primitive`). What `.masked_by()` uses to + /// clip a container's content to the rounded background it draws, + /// with no radius argument anywhere that could fall out of step with + /// the one being drawn. + pub fn set_mask_to_widget(&mut self, shape: &StrongWidget) { + let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| { + panic!( + "'{}' was given as a mask's shape but drew no primitive, so there is nothing to \ + clip to", + self.rsc.widgets().label(shape.id()), + ) + }); + self.set_mask_to(slot); + } + + /// Points this widget's mask at a primitive that has already been + /// written -- the shared half of [`Self::set_mask`]. + fn set_mask_to(&mut self, shape: u32) { + // `assert!`, not `debug_assert!`: one comparison per widget draw, + // and the second call silently *replacing* the first is a widget + // drawn unclipped -- which reaches the screen and nothing says so. + // Every build anybody runs here is release + // (docs/REVIEW-2026-09-07.md's R1). + assert!( + self.own_mask == MaskIdx::NONE || self.mask != self.own_mask, + "set_mask called twice while drawing one widget: the second would replace the first \ + rather than nest inside it", + ); + // A glyph would need a CPU-side alpha plane for the hit test to + // agree with the shader, and a standalone image a bind-group + // switch the fragment stage cannot make -- see `Mask::primitive`. + // Named here rather than left to the shader, which would read a + // rect that is not there and clip to nothing. + let binding = self.state.primitives.instance(shape).binding; + assert_eq!( + binding, + RectPrimitive::BINDING, + "a mask's shape must be a rect primitive; primitive {shape} is binding {binding}", + ); + let parent = self.mask; + let mask = Mask { + primitive: shape, + parent, + }; + let old_parent = if self.own_mask == MaskIdx::NONE { + let slot = self.rsc.ui_mut().masks.push(mask); + // The one ref this widget holds on its own slot, so the slot + // outlives any single frame's primitives; released in + // `UiRenderState::remove`'s `undraw` branch. + self.rsc.ui_mut().masks.push_ref(slot); + self.own_mask = slot; + MaskIdx::NONE + } else { + let old = self.rsc.ui().masks[self.own_mask.idx()].parent; + *self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask; + old + }; + // The chain link's own ref, taken before the old one is dropped so + // that re-chaining to the same slot cannot free it in between. + // Released here when the link changes, and in + // `UiRenderState::remove` when this widget's slot goes. + if old_parent != parent { + if parent != MaskIdx::NONE { + self.rsc.ui_mut().masks.push_ref(parent); + } + if old_parent != MaskIdx::NONE { + self.rsc.ui_mut().masks.remove(old_parent); + } + } + self.mask = self.own_mask; + } + + /// Draws a widget within this widget's region, returning the size it + /// reported using. + pub fn widget(&mut self, id: &StrongWidget) -> Size { + self.widget_at(id, self.region) } /// Draws a widget somewhere within this one. /// Useful for drawing child widgets in select areas. - pub fn widget_within(&mut self, id: &StrongWidget, region: UiRegion) { - self.widget_at(id, region.within(&self.region)); + pub fn widget_within(&mut self, id: &StrongWidget, region: UiRegion) -> Size { + self.widget_at(id, region.within(&self.region)) } - fn widget_at(&mut self, id: &StrongWidget, region: UiRegion) { + fn widget_at(&mut self, id: &StrongWidget, region: UiRegion) -> Size { self.children.push(id.id()); + // Passed directly rather than looked up from `self.active`: this + // widget's own `ActiveData` (which would carry its `move_slot`) is + // not inserted there until *after* its own `Widget::draw` returns, + // so a lookup here -- for a child drawn partway through that same + // call -- would always find nothing. `self.move_slot` is this + // widget's own slot, already known, and always correct regardless + // of insertion order. See `UiRenderState::move_parent_of`. self.state.draw_inner( self.layer, id.id(), region, Some(self.id), + self.move_slot.idx() as u32, self.mask, None, + None, + crate::render::MaskIdx::NONE, self.rsc, ); + self.state + .active + .get(&id.id()) + .map(|a| a.size) + .unwrap_or_default() + } + + /// Move an already-drawn child from wherever it currently sits to + /// `region` (resolved against this widget's own region, matching + /// `widget_within`) without a second draw -- an O(1) offset write via + /// `UiRenderState::mov`. For a container that draws a child + /// provisionally to learn its size (e.g. `Aligned`) and then places it + /// for real. Only valid when the target keeps the child's drawn size; + /// if the shape actually changes, the normal `widget_within` dispatch + /// (which detects that from the stored region) does the right thing + /// instead. + pub fn reposition(&mut self, id: &StrongWidget, region: UiRegion) { + let region = region.within(&self.region); + self.state.reposition(id.id(), region, self.rsc); + } + + /// Draw `child` at a provisional region to learn its size under one + /// axis's worth of assumption, discard everything it wrote, then draw + /// it again at the region that assumption produced. For the rare + /// parent that cannot pick an offered size without already knowing the + /// answer. Twice the cost of one `draw`; every other case in this file + /// avoids it. + pub fn draw_twice( + &mut self, + id: &StrongWidget, + first: UiRegion, + second: impl FnOnce(Size) -> UiRegion, + ) -> Size { + let used = self.widget_within(id, first); + let region = second(used); + self.widget_within(id, region) } pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { self.textures.push(handle.clone()); - self.primitive_at(handle.primitive(), region.within(&self.region)); + self.write_image(handle.image_index(), region.within(&self.region)); } pub fn texture(&mut self, handle: &TextureHandle) { self.textures.push(handle.clone()); - self.primitive(handle.primitive()); + self.write_image(handle.image_index(), self.region); } pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) { self.textures.push(handle.clone()); - self.primitive_at(handle.primitive(), region); + self.write_image(handle.image_index(), region); } - /// returns (handle, offset from top left) - pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText { + /// A standalone image draws with its own bind group rather than sharing + /// the layer's one instanced draw, so it goes through + /// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`. + fn write_image(&mut self, texture_idx: u32, region: UiRegion) { + let h = self.state.write_image( + self.layer, + self.id, + texture_idx, + region, + self.mask, + self.move_slot, + ); + if self.mask != MaskIdx::NONE { + self.rsc.ui_mut().masks.push_ref(self.mask); + } + self.own(h); + } + + pub fn render_text( + &mut self, + buffer: &mut TextBuffer, + attrs: &TextAttrs, + width: Option, + ) -> RenderedText { + let density = self.state.density; + // Counted here rather than in `TextView::render`, which returns + // its memoized layout without reaching this -- so this counts + // shapes, not requests. `UiRenderState::take_counters`. + self.state.shape_count += 1; let ui = self.rsc.ui_mut(); - ui.text.draw(buffer, attrs, &mut ui.textures) + ui.text + .render(buffer, attrs, width, &mut ui.textures, density) + } + + /// Which glyph atlas the glyphs handed out right now belong to -- + /// what a widget caching a [`RenderedText`] across frames has to + /// compare against before re-emitting it (`GlyphAtlas::clear`). + pub fn atlas_generation(&mut self) -> u64 { + self.rsc.ui_mut().text.atlas.generation() + } + + /// Draw a laid-out string: one quad per glyph, all sampling the atlas. + /// + /// `origin` is where the text's top-left goes; every glyph is placed at an + /// absolute pixel offset from it, so re-drawing after a resize is this loop + /// and nothing else. + pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { + // A caller re-emitting quads placed against an atlas that has since + // been cleared draws every glyph from coordinates now holding + // something else. Caught at the submission rather than on screen, + // where it reads as fragments of unrelated letters. `assert_eq!` + // for R1's reason: two integers per laid-out string, not per + // glyph, and the failure is unreadable text on a release build. + assert_eq!( + text.generation, + self.atlas_generation(), + "glyphs placed against atlas generation {} submitted against {}: the holder did not \ + re-render after the atlas was cleared", + text.generation, + self.atlas_generation(), + ); + let flags_for = |is_color| { + if is_color { + GlyphPrimitive::IS_COLOR + } else { + 0 + } + }; + for glyph in text.glyphs.iter() { + let mut region = origin; + region.x.end = region.x.start; + region.y.end = region.y.start; + let mut region = region.offset(UiVec2::abs(glyph.offset)); + region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32); + region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32); + self.primitive_at( + GlyphPrimitive::new( + glyph.entry.uv_min, + glyph.entry.uv_max, + glyph.entry.layer, + glyph.color, + flags_for(glyph.entry.is_color), + ), + region, + ); + } } pub fn region(&self) -> UiRegion { self.region } - pub fn size(&mut self, id: &StrongWidget) -> Size { - self.size_ctx().size(id) - } - - pub fn len_axis(&mut self, id: &StrongWidget, axis: Axis) -> Len { - match axis { - Axis::X => self.size_ctx().width(id), - Axis::Y => self.size_ctx().height(id), - } - } - pub fn output_size(&self) -> Vec2 { self.state.output_size } + /// Physical pixels per `dp` -- see `UiRenderState::density`'s field + /// doc. What `Len::dp`'s `apply_rest` call resolves against. + pub fn density(&self) -> f32 { + self.state.density + } + pub fn px_size(&mut self) -> Vec2 { self.region.size().to_abs(self.state.output_size) } @@ -138,8 +392,4 @@ impl<'a> Painter<'a> { pub fn id(&self) -> &WidgetId { &self.id } - - pub fn size_ctx(&mut self) -> SizeCtx<'_> { - self.state.size_ctx(self.id, self.region.size(), self.rsc) - } } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 57e79a6..0ad3eb8 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,39 +1,269 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + use crate::{ - ActiveData, Axis, IdLike, MaskIdx, Painter, PixelRegion, PrimitiveLayers, SizeCtx, + ActiveData, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, StrongWidget, UiRegion, UiRsc, UiVec2, WidgetId, Widgets, - ui::cache::Cache, - util::{HashMap, HashSet, Vec2, forget_ref}, + render::{ + Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives, + RectPrimitive, rounded_rect_coverage, + }, + util::{HashMap, HashSet, Id, Vec2}, }; +/// What [`UiRenderState::update`] did on its last call -- read back by the +/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris` +/// crate) so a report can tell a full relayout from a frame that only +/// redrew a handful of dirty widgets from one that drew nothing at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedrawKind { + /// Neither the root nor any widget changed -- `update` did nothing. + None, + /// [`UiRenderState::redraw_all`]: a new root, or a resize. + All, + /// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw` + /// named. + Updates, +} + pub struct UiRenderState { pub active: HashMap, + /// Every primitive in the tree, in one arena -- see [`Primitives`] for + /// why it is not per layer. + pub primitives: Primitives, + /// What each layer draws, in order: slots into `primitives`. pub layers: PrimitiveLayers, pub(super) output_size: Vec2, - pub cache: Cache, + /// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an + /// unscaled display) until a backend that knows its own density calls + /// `set_density` (Android's `content_scale`, read at `surface_changed` + /// time); the winit backend has no analogous per-monitor value wired up + /// yet and stays at the default. + pub(super) density: f32, old_root: Option, resized: bool, + /// The widgets whose `Widget::draw` is on the stack right now -- so + /// [`Self::redraw`] can tell "this widget needs drawing again" from + /// "an ancestor is drawing it at this very moment", where a second + /// draw would leave the first one's primitives behind with nothing + /// owning them. An id is inserted immediately before `draw` is called + /// and removed the moment it returns (both in `draw_inner`), so this + /// is empty between frames -- asserted at the end of `update`. + /// + /// It used to only ever be inserted into, and `redraw` removed the id + /// *before* testing for it, which made the test constant `false`: the + /// guard could never fire and the set grew by one entry per widget + /// ever drawn and was never emptied. draw_started: HashSet, + + /// `Widget::draw` calls and `Primitives::region_mut` rewrites since the + /// last `take_counters`. LAYOUT.md section 8's pass conditions are + /// stated in terms of these two: an unchanged frame must cost 0 of + /// each, and moving one widget must cost 0 draws and 0 rewrites + /// regardless of how many primitives are in its subtree. + draw_count: u64, + region_mut_count: u64, + mov_count: u64, + /// Text layouts actually computed -- bumped by `Painter::render_text`, + /// which `TextView::render` only reaches on a cache miss. + pub(super) shape_count: u64, + + /// `Instant::now()` at construction -- the zero every `iris::frame` line + /// dates itself from, so a report's `now=` is comparable to a harness's + /// own `t_ms` (`Harness::new` builds its `base` the same way, in the + /// same constructor call) without either side needing the wall clock. + epoch: Instant, + /// How many times [`Self::update`] has run -- the `iris::frame` line's + /// frame number. Counts every call, including one that found nothing to + /// redraw, so a gap in the sequence in a report is a frame this state + /// was never asked to run at all (a stalled event loop), not one that + /// ran and did nothing. + frame_no: u64, + /// How long the redraw phase of the last [`Self::update`] took -- + /// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or + /// zero if neither did. Read back by `iris::diagnostics::log_frame`. + last_layout: Duration, + last_redraw_kind: RedrawKind, + /// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris` + /// crate) last saw an input sample, dated by the sample's own clock + /// (`CursorState::time`) rather than when the dispatch ran -- same + /// reasoning as that field's own doc. A `Mutex` because `run_sensors` + /// takes `&self` and this is the one render state both backends + /// already share across frames. + last_input_at: Mutex>, } +/// The bound on the parent walk -- see `resolve_move` in shader.wgsl, +/// which walks the identical chain and must be kept in step with this +/// constant. It exists so a cyclic `parent` link cannot hang either walk, +/// not as a statement about how deep a real tree gets: it was 16, and the +/// transcript screen's composer field turned out to sit **17** slots below +/// the root (measured 2026-09-07 on this checkout's emulator, by tapping +/// the composer in a debug build -- the assert in `resolve_move_chain` +/// prints the chain). A chain past the bound is not reported anywhere at +/// run time; both walks just stop summing, so the widget is drawn and hit +/// tested short by whatever the outer slots held. +/// +/// Named for the walk rather than for one of its two subjects: it bounds +/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in +/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first +/// (docs/REVIEW-2026-09-07.md). +pub const PARENT_CHAIN_LIMIT: usize = 64; + impl UiRenderState { pub fn new() -> Self { Self { active: Default::default(), + primitives: Default::default(), layers: Default::default(), - cache: Default::default(), output_size: Vec2::ZERO, + density: 1.0, old_root: None, resized: false, draw_started: Default::default(), + draw_count: 0, + region_mut_count: 0, + mov_count: 0, + shape_count: 0, + epoch: Instant::now(), + frame_no: 0, + last_layout: Duration::ZERO, + last_redraw_kind: RedrawKind::None, + last_input_at: Mutex::new(None), } } + /// Reads and zeroes the (draws, region_mut rewrites, move_offsets + /// writes, text shapes) counters -- call once per frame before + /// `update()` to measure exactly that frame, per LAYOUT.md section 8. + /// + /// The fourth is the one a draw count cannot stand in for: a widget + /// can be redrawn without re-shaping (`TextView::render` memoizes by + /// width) and re-shaped without any extra draw, and it is re-shaping + /// that the per-block transcript row exists to avoid -- see + /// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`. + pub fn take_counters(&mut self) -> (u64, u64, u64, u64) { + ( + std::mem::take(&mut self.draw_count), + std::mem::take(&mut self.region_mut_count), + std::mem::take(&mut self.mov_count), + std::mem::take(&mut self.shape_count), + ) + } + + /// Writes a primitive into the arena and, unless it is + /// [`Drawn::No`], into `layer`'s draw order. + pub(super) fn write_primitive( + &mut self, + layer: usize, + drawn: Drawn, + inst: PrimitiveInst

, + ) -> PrimitiveHandle { + let (slot, data_idx) = self.primitives.alloc(inst); + let pos = match drawn { + Drawn::Yes => self.layers[layer].push(slot, false), + Drawn::No => NOT_DRAWN, + }; + PrimitiveHandle { + layer, + pos, + slot, + data_idx, + binding: P::BINDING, + } + } + + /// A standalone image, which draws with its own bind group rather + /// than sharing the layer's one instanced draw. + pub(super) fn write_image( + &mut self, + layer: usize, + id: WidgetId, + texture_idx: u32, + region: UiRegion, + mask_idx: MaskIdx, + move_idx: MoveIdx, + ) -> PrimitiveHandle { + let slot = self + .primitives + .alloc_image(id, texture_idx, region, mask_idx, move_idx); + let pos = self.layers[layer].push(slot, true); + PrimitiveHandle { + layer, + pos, + slot, + data_idx: 0, + binding: crate::render::IMAGE_BINDING, + } + } + + /// Compacts every layer's draw order around the primitives freed + /// this frame, corrects the handles that moved, and only then hands + /// the arena slots back for reuse -- that order is the whole reason + /// `Primitives::freed` exists. Once per frame, at the end of + /// [`Self::update`], so the harness (which has no renderer) applies + /// it exactly as a real backend does. + fn apply_free(&mut self) { + for (layer, order) in self.layers.iter_mut() { + for change in order.apply_free() { + // Straight to the handle, never a scan of everything the + // owner drew: a widget freed and redrawn in one frame has + // *every* one of its primitives renumbered here, so a scan + // makes this pass quadratic in that widget's primitive + // count -- 1.37s for one 51,200-glyph text block, against + // 20ms to shape and rasterise the same text (measured + // 2026-09-08). `Primitives::handle_index` is written where + // the handle is taken, in `Painter::own`. + let owner = self.primitives.owner(change.slot); + let Some(idx) = self.primitives.handle_index(change.slot) else { + continue; + }; + if let Some(active) = self.active.get_mut(&owner) + && let Some(h) = active.primitives.get_mut(idx) + { + debug_assert!( + h.layer == layer && h.slot == change.slot, + "slot {} says it is handle {idx} of {owner:?}, which is slot {} in layer {}", + change.slot, + h.slot, + h.layer, + ); + h.pos = change.pos; + } + } + } + self.primitives.release_freed(); + } + pub fn resize(&mut self, size: impl Into) { self.output_size = size.into(); self.resized = true; } + /// Sets the physical-pixels-per-dp ratio every `Len::dp` in the tree + /// resolves against from the next layout pass on -- see `density`'s + /// field doc. Not folded into `resize` because the two change on + /// different triggers (a surface resize on every rotation or keyboard + /// open; a density change only if the app follows the display to a + /// different screen, which Android surfaces separately). + /// + /// Marks the tree for a full redraw when the value actually changes: + /// every `Len::dp` already resolved and every glyph already shaped + /// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs + /// to the old one, and nothing else would ask for them again + /// (docs/REVIEW-2026-09-07.md's R5). + pub fn set_density(&mut self, density: f32) { + if density != self.density { + self.resized = true; + } + self.density = density; + } + + pub fn density(&self) -> f32 { + self.density + } + pub fn update<'a>(&mut self, root: impl Into>, rsc: &mut dyn UiRsc) { // safety mechanism for memory leaks; might wanna return a result instead so user can // decide whether to panic or not @@ -52,23 +282,135 @@ impl UiRenderState { ); } let root = root.into(); - if self.root_changed(root) || self.resized { + debug_assert!( + self.draw_started.is_empty(), + "a previous frame left {} widget(s) marked as mid-draw", + self.draw_started.len(), + ); + // Timed unconditionally -- an `Instant::now()` pair is cheap enough + // not to move the `--phone` bench's frame time (checked when this + // was added), and gating it behind the trace toggle would leave + // `iris::frame` with nothing to report the one frame somebody just + // turned tracing on to look at. + let layout_start = Instant::now(); + let kind = if self.needs_redraw_all(root) { self.redraw_all(root, rsc); self.old_root = root.map(|r| r.id()); self.resized = false; + RedrawKind::All } else if rsc.widgets().has_updates() { self.redraw_updates(rsc); + RedrawKind::Updates + } else { + RedrawKind::None + }; + self.last_layout = layout_start.elapsed(); + self.last_redraw_kind = kind; + self.frame_no += 1; + // After the redraw and before anything reads the frame: every + // slot freed above is still named by its layer's draw order until + // this runs. + self.apply_free(); + #[cfg(debug_assertions)] + debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),); + } + + /// `Instant::now()` at construction -- see the field's own doc. + pub fn epoch(&self) -> Instant { + self.epoch + } + + /// How many times [`Self::update`] has run, counting from 1. + pub fn frame_number(&self) -> u64 { + self.frame_no + } + + /// How long the last [`Self::update`]'s redraw phase took. + pub fn last_layout_duration(&self) -> Duration { + self.last_layout + } + + /// What the last [`Self::update`] did -- see [`RedrawKind`]. + pub fn last_redraw_kind(&self) -> RedrawKind { + self.last_redraw_kind + } + + /// Records that a real input sample was just dispatched, dated by the + /// sample's own clock -- called once per sensor pass, so `iris::frame`'s + /// `since_input` can answer "how stale was the input + /// this frame drew" instead of a caller guessing from the frame + /// interval. `&self` because `run_sensors` only ever has that -- see + /// `last_input_at`'s field doc. + pub fn note_input(&self, at: Instant) { + if let Ok(mut guard) = self.last_input_at.lock() { + *guard = Some(at); } } + /// `now - ` the last input sample's own timestamp, or `None` if no + /// input has ever reached this render state (a cold start, or a screen + /// that only ever animates on its own). Saturates to zero rather than + /// panicking if `now` is earlier than the input sample somehow was -- + /// a diagnostic reading wrong is not worth a crash over. + pub fn time_since_input(&self, now: Instant) -> Option { + let at = *self.last_input_at.lock().ok()?; + at.map(|at| now.saturating_duration_since(at)) + } + + /// Primitive instances every currently-active widget owns, summed -- + /// what `iris::frame`'s `primitives=` reports. Not a per-frame delta: + /// `redraw_updates` only rewrites what changed, so this is "how much is + /// on screen", which is what a report reads as "did this frame have + /// more to draw than the last one", not "how much work did this frame + /// do" (`take_counters` answers that). + /// + /// A mask's shape does not count: it is a [`Drawn::No`] primitive + /// that is never rasterized, so including it would put one extra on + /// the line for every masked widget and make a number Iris reads off + /// a phone report disagree with what is drawn. + pub fn active_primitive_count(&self) -> usize { + self.active + .values() + .map(|a| a.primitives.iter().filter(|h| h.pos != NOT_DRAWN).count()) + .sum() + } + fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) { self.clear(rsc); // free all resources & cache if let Some(id) = root { - self.draw_inner(0, id.id(), UiRegion::FULL, None, MaskIdx::NONE, None, rsc); + self.draw_inner( + 0, + id.id(), + UiRegion::FULL, + None, + MoveOffset::NONE_PARENT, + MaskIdx::NONE, + None, + None, + MaskIdx::NONE, + rsc, + ); } } + /// The slot an *already-active* widget's `move_offsets` entry chains + /// to, read back from `self.active`. Only valid where the parent is + /// guaranteed to already be in `self.active` -- true for `redraw()`, + /// which targets a widget that was fully drawn on some earlier update, + /// but **not** for a widget being drawn as part of its own parent's + /// `Widget::draw` call: that parent's `ActiveData` is not inserted + /// until its `draw` returns (below), so a child drawn partway through + /// it would always read back "no parent" here. `Painter::widget_at` + /// avoids that trap by passing its own already-known `move_slot` + /// straight through instead of asking `self.active` to look it up. + fn move_parent_of(&self, parent: Option) -> u32 { + parent + .and_then(|p| self.active.get(&p)) + .map(|p| p.move_slot.idx() as u32) + .unwrap_or(MoveOffset::NONE_PARENT) + } + // TODO: should prolly make a DrawInfo struct or smth for everything other than rsc #[allow(clippy::too_many_arguments)] pub(super) fn draw_inner( @@ -77,13 +419,31 @@ impl UiRenderState { id: WidgetId, region: UiRegion, parent: Option, + parent_move_slot: u32, mask: MaskIdx, old_children: Option>, + old_move_slot: Option, + old_own_mask: MaskIdx, rsc: &mut dyn UiRsc, ) { let mut old_children = old_children.unwrap_or_default(); + let mut old_move_slot = old_move_slot; + let mut own_mask = old_own_mask; + // Consumed here, not merely read: this call *is* the redraw the mark + // asked for, and leaving the mark set is what stranded a widget's + // primitives. `Painter::draw_twice` calls this twice for the same id + // in one frame (`LazySpan::place`'s measurement pass), and on the second + // call the still-set mark took the whole `if let` below -- including + // the `remove` that frees the first draw's primitives -- out of play, + // so `active.insert` at the end overwrote the only handles that could + // ever have freed them. The result is a full second copy of the row, + // drawn every frame from then on at the oversized measurement region + // and, with `LazySpan` setting no mask, outside the list's own bounds: + // the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md. + // The same shape reaches any dirty widget an ancestor redraws first. + let dirty = rsc.widgets_mut().needs_redraw.remove(&id); if let Some(active) = self.active.get_mut(&id) - && !rsc.widgets().needs_redraw.contains(&id) + && !dirty { // check to see if we can skip drawing first if active.region == region { @@ -91,21 +451,103 @@ impl UiRenderState { } else if active.region.size() == region.size() { // TODO: epsilon? let from = active.region; - self.mov(id, from, region); + self.mov(id, from, region, rsc); + return; + } else if rsc + .widgets() + .get_dyn(id) + .map(|w| w.is_size_independent()) + .unwrap_or(false) + { + // The offered region changed shape, but this widget's own + // drawn output does not depend on it (a fixed-size leaf) -- + // rewrite its own primitives' regions in place (O(primitives + // owned directly by this widget, which for a leaf is O(1)) + // instead of redrawing. See LAYOUT.md section 3. + let from = active.region; + for h in &active.primitives { + let r = self.primitives.region_mut(h); + *r = r.outside(&from).within(®ion); + self.region_mut_count += 1; + } + // `move_applied` is deliberately **not** touched here, + // unlike in `mov`: it counts the part of this widget's own + // move-slot delta that `region` has already absorbed, and + // this branch writes no delta at all -- the primitives were + // moved directly. Counting one would make + // `resolved_region` subtract a distance the chain never + // held, putting the hit box short of the drawing by + // exactly this step. See `ActiveData::move_applied`, and + // `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`. + active.region = region; return; } // if not, then maintain resize and track old children to remove unneeded let active = self.remove(id, false, rsc).unwrap(); old_children = active.children; + old_move_slot = Some(active.move_slot); + own_mask = active.own_mask; + } else if dirty && self.active.contains_key(&id) { + // Dirty and already drawn: none of the fast paths above may be + // taken (the widget's own content changed, so its old primitives + // say nothing about its new ones), but they are also the only + // thing that frees them. Same two lines, reached the other way. + let active = self.remove(id, false, rsc).unwrap(); + old_children = active.children; + old_move_slot = Some(active.move_slot); + own_mask = active.own_mask; } // draw widget - self.draw_started.insert(id); + let reentrant = !self.draw_started.insert(id); + debug_assert!( + !reentrant, + "widget {id:?} is being drawn while its own draw is already on the stack; \ + the second draw's primitives would orphan the first's" + ); + let move_slot = match old_move_slot { + // Reused across a real redraw of the same id: the fresh + // geometry this draw is about to write is placed at its + // correct absolute position by `region` itself, so any delta + // accumulated before this redraw is now stale and would + // double-offset it if left in place. The chain link (`parent`) + // is untouched -- the logical parent has not changed. + Some(slot) => { + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + entry.delta = [0.0, 0.0]; + slot + } + None => { + let slot = rsc + .ui_mut() + .move_offsets + .push(MoveOffset::new([0.0, 0.0], parent_move_slot)); + rsc.ui_mut().move_offsets.push_ref(slot); + if parent_move_slot != MoveOffset::NONE_PARENT { + rsc.ui_mut() + .move_offsets + .push_ref(Id::preset(parent_move_slot)); + } + slot + } + }; + + // The mask this widget was drawn *under*, kept aside because + // `Painter::set_mask` overwrites `painter.mask` with the widget's + // own new one -- and `ActiveData::mask`'s only consumer is + // `redraw`, which feeds it back in as the *inherited* mask. Storing + // the set one instead handed a `Masked` its own mask on every + // targeted redraw -- an abort the first time the composer's scroll + // area was redrawn on the emulator, and now (masks nest) a mask + // whose parent is itself, which `set_mask`'s own assert names. + let inherited_mask = mask; let mut painter = Painter { state: self, region, mask, + move_slot, + own_mask, layer, id, textures: Vec::new(), @@ -115,14 +557,28 @@ impl UiRenderState { }; let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); - widget.draw(&mut painter); + painter.state.draw_count += 1; + let size = widget.draw(&mut painter); + // A reported length is consumed by containers that read `abs`, + // `rel` and `rest` straight off it (`Span`'s placement, `Pad`'s + // addition), so an unresolved `dp` in one is silently worth zero + // -- see `Len::fold_dp`, which is what a widget reporting a + // caller-declared size has to put it through. + debug_assert!( + size.x.dp == 0.0 && size.y.dp == 0.0, + "widget {id:?} reported an unresolved `dp` size ({size:?}); \ + report `Len::fold_dp(painter.density())` instead" + ); drop(widget); + painter.state.draw_started.remove(&id); let Painter { state: _, rsc: _, region, - mask, + mask: _, + move_slot, + own_mask, textures, primitives, children, @@ -138,8 +594,13 @@ impl UiRenderState { textures, primitives, children, - mask, + mask: inherited_mask, layer, + size, + move_slot, + own_mask, + move_applied: Vec2::ZERO, + repositioned: Vec2::ZERO, }; // remove old children that weren't kept @@ -153,18 +614,88 @@ impl UiRenderState { self.active.insert(id, active); } - fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) { - let active = self.active.get_mut(&id).unwrap(); - for h in &active.primitives { - let region = self.layers[h.layer].region_mut(h); - *region = region.outside(&from).within(&to); - } - active.region = active.region.outside(&from).within(&to); - // SAFETY: children cannot be recursive - let children = unsafe { forget_ref(&active.children) }; - for child in children { - self.mov(*child, from, to); + /// O(1): write the delta for this widget's own slot in + /// `move_offsets`. No primitive is touched and there is no recursion -- + /// every descendant's primitive references this slot transitively + /// through the parent chain the shader walks (`resolve_move`), so it + /// picks the new delta up for free. See LAYOUT.md section 2. + fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) { + let Some(active) = self.active.get_mut(&id) else { + return; + }; + let slot = active.move_slot; + active.region = to; + let from_px = from.top_left().to_abs(self.output_size); + let to_px = to.top_left().to_abs(self.output_size); + let delta = to_px - from_px; + active.move_applied += delta; + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + entry.delta[0] += delta.x; + entry.delta[1] += delta.y; + self.mov_count += 1; + } + + /// Move an already-active widget to `to`. Used by `Painter::reposition`, + /// for a parent that drew a child provisionally (at the whole region it + /// was offered) and now knows where the child actually belongs. + /// + /// Unlike `mov` (called by `draw_inner`'s own dispatch, where the + /// *offered* region really did move and `active.region` already tracks + /// it), the child here was not offered a smaller region -- it was + /// offered everything and chose, on its own, to occupy only + /// `active.size` of it. By convention every widget in this crate that + /// does that anchors its own content at the top-left of whatever it + /// was given (`Rect`/`Image`/`Sized`/`MaxSize` -- see their `draw` + /// bodies), so that is where this assumes the child was actually + /// painted, not `active.region` itself (which is the *offered* box, + /// usually bigger). A nested `Aligned` whose own child is not top-left + /// anchored -- i.e. `Aligned` wrapping `Aligned` -- is the one shape + /// this does not cover; none of iris's widgets or examples build that + /// today. See LAYOUT.md's "Rejected, and why" / deviations for the + /// full reasoning. + /// + /// The delta is overwritten, not accumulated like `mov`'s: `from` is + /// recomputed fresh from `active.size`/`active.region` every call, so + /// repeating the same `reposition` (e.g. an unrelated redraw elsewhere + /// re-running this widget's parent without its own layout changing) + /// must land on the same answer, not drift further each time. + pub(super) fn reposition(&mut self, id: WidgetId, to: UiRegion, rsc: &mut dyn UiRsc) { + let Some(active) = self.active.get(&id) else { + return; + }; + let move_applied = active.move_applied; + let repositioned = active.repositioned; + let from = active + .size + .to_uivec2(self.density) + .align(RegionAlign::TOP_LEFT) + .within(&active.region); + let slot = active.move_slot; + let from_px = from.top_left().to_abs(self.output_size); + let to_px = to.top_left().to_abs(self.output_size); + let delta = to_px - from_px; + // Not `delta` alone: a parent may have `mov`ed this widget to a + // region that itself moved earlier in the same frame, and that + // part of the slot is `move_applied`'s, not this call's. Writing + // `delta` on its own dropped it and put the content back at the + // pre-move position. `from` is computed against `active.region`, + // which `mov` already updated, so `delta` is purely the placement + // inside the region and the two summands never overlap. + let entry = rsc.ui_mut().move_offsets.get_mut(slot); + debug_assert_eq!( + entry.delta, + [ + move_applied.x + repositioned.x, + move_applied.y + repositioned.y + ], + "widget {id:?}'s move slot was written by something other than `mov`/`reposition`; \ + the slot is theirs and means `move_applied + repositioned` -- see `ActiveData`" + ); + entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y]; + if let Some(active) = self.active.get_mut(&id) { + active.repositioned = delta; } + self.mov_count += 1; } /// NOTE: instance textures are cleared and self.textures freed @@ -172,22 +703,105 @@ impl UiRenderState { let mut active = self.active.remove(&id); if let Some(active) = &mut active { for h in &active.primitives { - let mask = self.layers.free(h); + let mask = self.primitives.free(h); + if h.pos != NOT_DRAWN { + self.layers[h.layer].free(h.pos, h.is_image()); + } if mask != MaskIdx::NONE { rsc.ui_mut().masks.remove(mask); } } + Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc); active.textures.clear(); rsc.ui_mut().textures.free(); if undraw { + // A captured widget that goes away mid-gesture (LazySpan's + // virtualisation retiring a row, a rebuild) must not leave + // the pointer captured by an id nothing will ever draw + // again. That path out is the sensor pass's, not this + // one's: `iris::sense::SensorUi::run_sensors` releases a + // capture whose widget no longer resolves to a region, + // which covers this case and every other way an id can + // stop being drawn. + // Permanent removal: retire this widget's own move slot + // (the self-ownership ref taken when it was allocated) and + // the up-link ref it held on its parent's slot -- read from + // the arena entry itself, not from `active.parent`, since + // the parent's own `ActiveData` may already be gone by the + // time a deep descendant is retired (see LAYOUT.md + // section 2's lifecycle note). + if active.own_mask != MaskIdx::NONE { + // The self-ownership ref `Painter::set_mask` took when + // it allocated this widget's own mask slot, and the + // chain link's ref on the mask this one nests inside + // -- read from the arena entry, for the same reason + // the move slot's parent is. + let outer = rsc.ui().masks[active.own_mask.idx()].parent; + rsc.ui_mut().masks.remove(active.own_mask); + if outer != MaskIdx::NONE { + rsc.ui_mut().masks.remove(outer); + } + } + let parent_slot = rsc.ui_mut().move_offsets[active.move_slot.idx()].parent; + rsc.ui_mut().move_offsets.remove(active.move_slot); + if parent_slot != MoveOffset::NONE_PARENT { + rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot)); + } rsc.on_undraw(active); } } active } + /// A mask whose shape primitive was just freed clips to a slot that + /// now holds something else, so the widget that owns it is marked for + /// redraw -- its own `set_mask` is the only thing that resolves the + /// slot, and it is the same mechanism a dirty widget already goes + /// through. + /// + /// `own` is the mask belonging to the widget being removed and is + /// skipped: this runs in the middle of that widget's own redraw, + /// which sets its mask again on the way out, and a mark left on + /// itself would redraw it every frame from then on. Skipping it is + /// also what keeps the O(active) scan off the ordinary path -- a + /// plain `.masked()` frees exactly its own shape, so `stale` is empty + /// and this returns before touching `active`. + /// + /// Both `Vec`s start empty and stay unallocated in that case, and + /// membership is a linear scan of two lists that are a handful long + /// (a widget's own primitives, and the live masks): this runs once + /// per widget removed, which is once per dirty widget per frame, and + /// a set built there would be an allocation on the phone's frame + /// path in exchange for nothing at these sizes. + fn remask_shape_users( + active: &HashMap, + id: WidgetId, + own: MaskIdx, + freed: &[PrimitiveHandle], + rsc: &mut dyn UiRsc, + ) { + let mut stale: Vec = Vec::new(); + for (i, mask) in rsc.ui().masks.iter().enumerate() { + let idx = Id::preset(i as u32); + if idx != own && freed.iter().any(|h| h.slot == mask.primitive) { + stale.push(idx); + } + } + if stale.is_empty() { + return; + } + let mut owners: Vec = Vec::new(); + for (widget, data) in active { + if *widget != id && stale.contains(&data.own_mask) { + owners.push(*widget); + } + } + for owner in owners { + rsc.widgets_mut().needs_redraw.insert(owner); + } + } + fn remove_rec(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option { - self.cache.remove(id); let inst = self.remove(id, true, rsc); if let Some(inst) = &inst { for c in &inst.children { @@ -201,8 +815,8 @@ impl UiRenderState { for (_, active) in self.active.drain() { rsc.on_undraw(&active); } - self.cache.clear(); self.layers.clear(); + self.primitives.clear(); rsc.widgets_mut().needs_redraw.clear(); rsc.free(); } @@ -218,18 +832,99 @@ impl UiRenderState { root.into().map(|r| r.id()) != self.old_root } + /// What `update` will redraw everything for. Named and shared with + /// `needs_redraw` rather than written out twice, because the two must + /// agree: `needs_redraw` is what asks for the frame that `update` would + /// draw, so a condition in one and not the other is a frame nobody + /// requests and a stale window. `resized` was missing from `needs_redraw`, + /// which is latent on Wayland only because winit asks for a redraw after a + /// resize by itself -- a resize changes neither the root nor any widget, + /// so nothing else here would have asked. + fn needs_redraw_all<'a>(&self, root: impl Into>) -> bool { + self.root_changed(root) || self.resized + } + pub fn needs_redraw<'a>( &self, root: impl Into>, widgets: &Widgets, ) -> bool { - self.root_changed(root) || widgets.has_updates() + self.needs_redraw_all(root) || widgets.has_updates() } pub fn active_widgets(&self) -> usize { self.active.len() } + /// Primitive instances still bound for the GPU whose owner is no + /// longer in `active`, or whose owner's `ActiveData` no longer names + /// them: a copy nothing can move, clip, resize or free, redrawn every + /// frame at whatever position it last had. `(slot, owner)` each -- + /// the arena knows which primitive, not which layer's draw order still + /// names it. + /// + /// Asserted empty at the end of every [`Self::update`], because this + /// is exactly the shape of the duplicated transcript row on Iris's + /// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting + /// `active` alone cannot see it, since the orphan's owner is very + /// much alive -- it is the *earlier* set of primitives that got + /// stranded when the widget was drawn a second time without the first + /// draw being freed. O(primitives), debug builds only. + pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> { + let mut orphans = Vec::new(); + for (slot, owner, _) in self.primitives.live_instances() { + let owned = self + .active + .get(&owner) + .is_some_and(|a| a.primitives.iter().any(|h| h.slot == slot)); + if !owned { + orphans.push((slot, owner)); + } + } + orphans + } + + /// Whether every primitive still bound for the GPU is owned by a live + /// widget, decided by counting rather than by walking: an orphan is a + /// live instance no `ActiveData` names, so it can only ever make the + /// live count exceed the owned one. O(active widgets) -- a few dozen -- + /// against [`Self::orphaned_primitives`]'s O(primitives), which on a + /// transcript is tens of thousands and made a debug build on a phone + /// too slow to finish a benchmark run. + fn primitive_counts_agree(&self) -> bool { + let live: usize = self.primitives.live_count(); + let owned: usize = self.active.values().map(|a| a.primitives.len()).sum(); + live == owned + } + + /// The message [`Self::update`]'s orphan assert prints -- built here + /// rather than inline so the (allocating, O(primitives)) work only + /// happens on the failing path. + #[cfg(debug_assertions)] + fn orphan_report(&self, rsc: &dyn UiRsc) -> String { + let orphans = self.orphaned_primitives(); + let mut lines: Vec = orphans + .iter() + .take(8) + .map(|(slot, owner)| { + let alive = self.active.contains_key(owner); + format!( + " instance {slot}: owner '{}' ({owner:?}), owner still active: {alive}", + rsc.widgets().label(*owner), + ) + }) + .collect(); + if orphans.len() > lines.len() { + lines.push(format!(" ... and {} more", orphans.len() - lines.len())); + } + format!( + "{} primitive(s) are drawn but owned by nobody -- a stale copy \ + nothing will ever move or free:\n{}", + orphans.len(), + lines.join("\n"), + ) + } + pub fn debug(&self, widgets: &Widgets, label: &str) -> impl Iterator { self.active.iter().filter_map(move |(&id, inst)| { let l = widgets.label(id); @@ -238,41 +933,186 @@ impl UiRenderState { } pub fn debug_layers(&self) { - for ((idx, depth), primitives) in self.layers.iter_depth() { + for ((idx, depth), order) in self.layers.iter_depth() { let indent = " ".repeat(depth * 2); - let len = primitives.instances().len(); + let len = order.order().len(); print!("{indent}{idx}: {len} primitives"); if len >= 1 { - print!(" ({})", primitives.instances()[0].binding); + print!(" ({})", self.primitives.instance(order.order()[0]).binding); } println!(); } } - pub fn window_region(&self, id: &impl IdLike) -> Option { - let region = self.active.get(&id.id())?.region; + /// `active[id].region`, corrected by every `move_offsets` delta between + /// `id` and the root -- the CPU-side twin of the vertex shader's chain + /// walk, over the same arena, so the two cannot disagree about where a + /// widget is. O(chain depth), not O(primitives). See LAYOUT.md + /// section 2b. + pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { + let active = self.active.get(&id.id())?; + // The chain sum is what the shader adds to this widget's + // *primitives*, which were written before any of those moves. + // `region`, unlike them, has already been shifted by whatever + // part of this widget's own slot `mov` put there -- see + // `ActiveData::move_applied`, which is exactly that part. + let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied; + Some(active.region.offset(UiVec2::abs(delta))) + } + + /// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the + /// pixel delta along the parent chain starting at `slot`. Both walks + /// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree + /// about where the chain ends. + fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 { + let offsets = &rsc.ui().move_offsets; + let mut delta = Vec2::ZERO; + let mut at = slot; + for i in 0..PARENT_CHAIN_LIMIT { + let entry = &offsets[at.idx()]; + delta.x += entry.delta[0]; + delta.y += entry.delta[1]; + if entry.parent == MoveOffset::NONE_PARENT { + return delta; + } + at = Id::preset(entry.parent); + // The chain itself, not just the fact that it was too long: a + // cycle and a tree genuinely nested deeper than the shader can + // follow are different faults with different fixes, and the + // slot numbers are the only thing that tells them apart. + debug_assert!( + i + 1 < PARENT_CHAIN_LIMIT, + "move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \ + -- a \ + repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \ + nests deeper than shader.wgsl's own walk of the same bound", + chain = Self::move_chain_debug(slot, offsets) + ); + } + delta + } + + /// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice + /// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number + /// rather than as a chain that merely stops. Only ever called from the + /// failed assertion above. + fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String { + let mut parts = Vec::new(); + let mut at = slot; + for _ in 0..PARENT_CHAIN_LIMIT * 2 { + let entry = &offsets[at.idx()]; + parts.push(format!( + "{}({}, {})", + at.idx(), + entry.delta[0], + entry.delta[1] + )); + if entry.parent == MoveOffset::NONE_PARENT { + break; + } + at = Id::preset(entry.parent); + } + parts.join(" -> ") + } + + /// One primitive's corners in window pixels -- the transliteration of + /// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is + /// the whole reason this is not `region.to_px()`: the shader floors + /// each half separately before adding the move delta, and a hit test + /// that skipped it would disagree with the pixels by up to one along + /// each edge -- invisible in every test written against a whole-pixel + /// layout and wrong on the phone, whose 2.55 density makes nothing + /// land on a whole pixel. + pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion { + let inst = self.primitives.instance(slot); + let delta = self.resolve_move_chain(inst.move_idx, rsc); + let size = self.output_size; + let corner = |c: UiVec2| (c.get_rel() * size).floor() + c.get_abs().floor() + delta; + PixelRegion { + top_left: corner(inst.region.top_left()), + bot_right: corner(inst.region.bot_right()), + } + } + + /// Where a mask's clip actually is on screen: the box of the + /// primitive it references. Its *shape* within that box is + /// [`Self::mask_coverage`]'s -- this is the bounding box, which is + /// what a test asking "is the clip over the right part of the screen" + /// wants and all a square-cornered mask has ever had. + pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion { + self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc) + } + + /// How much of the pixel at `pos` (window pixels) survives `mask` and + /// every mask it nests inside: the referenced primitives' own + /// coverage, multiplied along the chain. The CPU half of + /// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same + /// `rounded_rect_coverage` -- so a corner that cannot be tapped and a + /// corner that is not drawn are the same corner (LAYOUT.md's "Masks + /// with a shape", point 4). + /// + /// A mask whose shape is not a rect covers everything, exactly as the + /// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects + /// those by name, so this is the unreachable half of the same + /// agreement rather than a second policy. + pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 { + let mut coverage = 1.0; + let mut at = mask; + for i in 0..PARENT_CHAIN_LIMIT { + if at == MaskIdx::NONE { + return coverage; + } + let m = rsc.ui().masks[at.idx()]; + if let Some(rect) = self.primitives.primitive_data::(m.primitive) { + let c = self.primitive_corners(m.primitive, rsc); + coverage *= rounded_rect_coverage(pos, c.top_left, c.bot_right, rect.radius); + } + at = m.parent; + debug_assert!( + i + 1 < PARENT_CHAIN_LIMIT || at == MaskIdx::NONE, + "mask chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}) from {mask:?} -- a \ + repeated slot means a `parent` link is cyclic, all-distinct slots mean the tree \ + nests deeper than shader.wgsl's own walk of the same bound", + ); + } + coverage + } + + /// Whether `pos` is inside `mask` at all -- more than half covered, + /// which is where the drawn edge is (`rounded_rect_coverage`'s doc). + /// What a hit test asks. + pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool { + self.mask_coverage(mask, pos, rsc) > 0.5 + } + + /// The first primitive `id`'s subtree wrote this frame, depth first + /// in draw order -- what a mask pointed at a widget clips to + /// (`Painter::set_mask_to_widget`). A widget that draws more than one + /// (a bordered rect is one primitive; a card with a stripe is two) + /// gives its first; a widget that wants another names it. + pub fn first_primitive(&self, id: WidgetId) -> Option { + let active = self.active.get(&id)?; + if let Some(h) = active.primitives.first() { + return Some(h.slot); + } + active + .children + .iter() + .find_map(|child| self.first_primitive(*child)) + } + + pub fn window_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { + let region = self.resolved_region(id, rsc)?; Some(region.to_px(self.output_size)) } /// redraws a widget that's currently active (drawn) pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { rsc.widgets_mut().needs_redraw.remove(&id); - self.draw_started.remove(&id); - // check if parent depends on the desired size of this, if so then redraw it first - for axis in [Axis::X, Axis::Y] { - if let Some(&(outer, old)) = self.cache.size.axis_dyn(axis).get(&id) - && let Some(current) = self.active.get(&id) - && let Some(pid) = current.parent - { - self.cache.size.axis_dyn(axis).remove(&id); - let new = self.size_ctx(id, outer, rsc).len_axis(id, axis); - self.cache.size.axis_dyn(axis).insert(id, (outer, new)); - if new != old { - self.redraw(pid, rsc); - } - } - } - + // An ancestor is drawing this widget right now, and that draw is + // about to write fresh primitives for it. Drawing it a second time + // here would leave one of the two copies on screen with nothing + // owning it -- see `draw_started`'s own doc. if self.draw_started.contains(&id) { return; } @@ -280,34 +1120,35 @@ impl UiRenderState { let Some(active) = self.remove(id, false, rsc) else { return; }; + let old_size = active.size; + let parent = active.parent; + // `old_move_slot` being `Some` below means the slot is reused in + // place rather than freshly parented, so this is only reached for + // logging/clarity's sake, never actually used to link a new slot. + let parent_move_slot = self.move_parent_of(parent); self.draw_inner( active.layer, id, active.region, - active.parent, + parent, + parent_move_slot, active.mask, Some(active.children), + Some(active.move_slot), + active.own_mask, rsc, ); - } - - pub(super) fn size_ctx<'b>( - &'b mut self, - source: WidgetId, - outer: UiVec2, - rsc: &'b mut dyn UiRsc, - ) -> SizeCtx<'b> { - let ui = rsc.ui_mut(); - SizeCtx { - source, - cache: &mut self.cache, - text: &mut ui.text, - textures: &mut ui.textures, - widgets: &ui.widgets, - outer, - output_size: self.output_size, - id: source, + // If this widget's own reported size changed, its parent's layout + // (which placed it using the old size) is now stale and needs to + // relay out too. Checked after the real draw, not before it -- + // there is no query left that answers "what size would this be" + // without actually drawing (LAYOUT.md section 5). + if let Some(pid) = parent { + let new_size = self.active.get(&id).map(|a| a.size); + if new_size != Some(old_size) { + self.redraw(pid, rsc); + } } } } diff --git a/core/src/ui/size.rs b/core/src/ui/size.rs deleted file mode 100644 index 6f82492..0000000 --- a/core/src/ui/size.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::{ - Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures, - UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2, -}; - -pub struct SizeCtx<'a> { - pub text: &'a mut TextData, - pub textures: &'a mut Textures, - pub(super) source: WidgetId, - pub(super) widgets: &'a Widgets, - pub(super) cache: &'a mut Cache, - /// TODO: should this be pub? rn used for sized - pub outer: UiVec2, - pub(super) output_size: Vec2, - pub(super) id: WidgetId, -} - -impl SizeCtx<'_> { - pub fn id(&self) -> &WidgetId { - &self.id - } - - pub fn source(&self) -> &WidgetId { - &self.source - } - - pub(super) fn len_inner(&mut self, id: WidgetId) -> Len { - if let Some((_, len)) = self.cache.size.axis::().get(&id) { - return *len; - } - let len = self - .widgets - .get_dyn_dynamic(id) - .desired_len::(&mut SizeCtx { - text: self.text, - textures: self.textures, - source: self.source, - widgets: self.widgets, - cache: self.cache, - outer: self.outer, - output_size: self.output_size, - id, - }); - self.cache.size.axis::().insert(id, (self.outer, len)); - len - } - - pub fn width(&mut self, id: impl IdLike) -> Len { - self.len_inner::(id.id()) - } - - pub fn height(&mut self, id: impl IdLike) -> Len { - self.len_inner::(id.id()) - } - - pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len { - match axis { - Axis::X => self.width(id), - Axis::Y => self.height(id), - } - } - - pub fn size(&mut self, id: impl IdLike) -> Size { - let id = id.id(); - Size { - x: self.width(id), - y: self.height(id), - } - } - - pub fn px_size(&mut self) -> Vec2 { - self.outer.to_abs(self.output_size) - } - - pub fn output_size(&mut self) -> Vec2 { - self.output_size - } - - pub fn draw_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText { - self.text.draw(buffer, attrs, self.textures) - } - - pub fn label(&self, id: WidgetId) -> &String { - self.widgets.label(id) - } -} diff --git a/core/src/util/arena.rs b/core/src/util/arena.rs index 9ddfd99..f224634 100644 --- a/core/src/util/arena.rs +++ b/core/src/util/arena.rs @@ -71,6 +71,15 @@ impl TrackedArena { self.refs[i.idx()] += 1; } + /// Mutable access to an existing entry, for the rare case (the move + /// offset chain) where an already-allocated slot is updated in place + /// rather than replaced. Marks the arena changed so the GPU copy is + /// re-uploaded. + pub fn get_mut(&mut self, id: Id) -> &mut T { + self.changed = true; + &mut self.inner.data[id.idx()] + } + pub fn remove(&mut self, id: Id) -> T where T: Copy, diff --git a/core/src/util/math.rs b/core/src/util/math.rs index 4bcdbce..4bf4f3a 100644 --- a/core/src/util/math.rs +++ b/core/src/util/math.rs @@ -9,15 +9,16 @@ pub const trait DivOr { fn div_or(self, rhs: Self, other: Self) -> Self; } -impl const DivOr for f32 { +const impl DivOr for f32 { fn div_or(self, rhs: Self, other: Self) -> Self { let res = self / rhs; if res.is_nan() { other } else { res } } } -impl + const Sub + const Mul + const DivOr + Copy> const - LerpUtil for T +const impl< + T: const Add + const Sub + const Mul + const DivOr + Copy, +> LerpUtil for T { /// linear interpolation /// from * (1.0 - self) + to * self @@ -37,7 +38,7 @@ macro_rules! impl_op { use super::*; #[allow(unused_imports)] use std::ops::*; - impl const $op for $T { + const impl $op for $T { type Output = Self; fn $fn(self, rhs: Self) -> Self::Output { @@ -46,12 +47,12 @@ macro_rules! impl_op { } } } - impl const $opa for $T { + const impl $opa for $T { fn $fna(&mut self, rhs: Self) { *self = self.$fn(rhs); } } - impl const $op for $T { + const impl $op for $T { type Output = Self; fn $fn(self, rhs: f32) -> Self::Output { @@ -60,7 +61,7 @@ macro_rules! impl_op { } } } - impl const $op<$T> for f32 { + const impl $op<$T> for f32 { type Output = $T; fn $fn(self, rhs: $T) -> Self::Output { @@ -69,7 +70,7 @@ macro_rules! impl_op { } } } - impl const $opa for $T { + const impl $opa for $T { fn $fna(&mut self, rhs: f32) { *self = self.$fn(rhs); } diff --git a/core/src/util/slot.rs b/core/src/util/slot.rs index 94fd2a1..498a084 100644 --- a/core/src/util/slot.rs +++ b/core/src/util/slot.rs @@ -4,6 +4,17 @@ pub struct SlotId { genr: u32, } +impl SlotId { + /// A stable, collision-free `u64` encoding of this id -- for a caller + /// (accesskit's `NodeId`, today) that wants a flat integer key rather + /// than the two `u32`s. `idx` is offset by one so no real id ever + /// encodes to 0, which callers can then reserve for their own + /// out-of-band root/window node. + pub fn as_u64(&self) -> u64 { + ((self.idx as u64) + 1) << 32 | self.genr as u64 + } +} + pub struct SlotVec { data: Vec<(u32, Option)>, free: Vec, diff --git a/core/src/util/vec2.rs b/core/src/util/vec2.rs index 7609861..ca67c68 100644 --- a/core/src/util/vec2.rs +++ b/core/src/util/vec2.rs @@ -67,7 +67,7 @@ impl_op!(Vec2 Sub sub; x y); impl_op!(Vec2 Mul mul; x y); impl_op!(Vec2 Div div; x y); -impl const DivOr for Vec2 { +const impl DivOr for Vec2 { fn div_or(self, rhs: Self, other: Self) -> Self { Self { x: self.x.div_or(rhs.x, other.x), diff --git a/core/src/widget/mod.rs b/core/src/widget/mod.rs index a0f084c..9b180bd 100644 --- a/core/src/widget/mod.rs +++ b/core/src/widget/mod.rs @@ -1,4 +1,4 @@ -use crate::{Axis, AxisT, Len, Painter, SizeCtx}; +use crate::{Painter, Size}; use std::any::Any; mod data; @@ -16,31 +16,59 @@ pub use view::*; pub use widgets::*; pub trait Widget: Any { - fn draw(&mut self, painter: &mut Painter); - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len; - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len; -} + /// Draw within `painter.region()` (the space the parent offered) and + /// report how much of it was actually used, per axis. + fn draw(&mut self, painter: &mut Painter) -> Size; -pub trait WidgetAxisFns { - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len; -} + /// True if `draw`'s output (both the primitives it writes and the + /// `Size` it returns) is the same for any `painter.region()` of the + /// same *content* -- an icon, a fixed-size rect, an already-decoded + /// image at its natural size. Default `false` (redraw on any change to + /// the offered region) because assuming independence wrongly produces + /// a stale draw; a widget must opt in. See LAYOUT.md. + fn is_size_independent(&self) -> bool { + false + } -impl WidgetAxisFns for W { - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len { - match A::get() { - Axis::X => self.desired_width(ctx), - Axis::Y => self.desired_height(ctx), - } + /// What kind of control this is, for the AccessKit tree `ui::access` + /// builds (RUST.md's I4). Only consulted for a widget that also has an + /// explicit `.label()` -- an unnamed widget is never visited by that + /// tree at all, named or not, so the default here costs nothing except + /// at the handful of call sites that opt in. Default `Unknown` (a + /// generic control with no more specific semantics); a widget with a + /// real platform equivalent -- `TextEdit`'s `MultilineTextInput` -- + /// overrides it. + fn access_role(&self) -> accesskit::Role { + accesskit::Role::Unknown + } + + /// Advance whatever this widget is animating to `now`, and say whether + /// it is still animating afterwards. Default: nothing is, so a widget + /// opts in by overriding this *and* by something calling + /// [`crate::UiData::animate`] with its id when the animation starts -- + /// which is that animation's path out, since the driver + /// ([`crate::UiData::tick_animations`]) drops every id whose `tick` + /// answers `false`. + /// + /// Called once per frame, before the frame's draw, by whichever + /// backend owns the surface; a `true` answer is what makes that + /// backend ask for another frame. So this is the only thing in iris + /// that moves without an input event, and a widget that animates + /// without registering simply never moves -- which is exactly how a + /// finger fling looked on Iris's phone before this existed. + #[allow(unused_variables)] + fn tick(&mut self, now: std::time::Instant) -> bool { + false } } impl Widget for () { - fn draw(&mut self, _: &mut Painter) {} - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::ZERO + fn draw(&mut self, _: &mut Painter) -> Size { + Size::ZERO } - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::ZERO + + fn is_size_independent(&self) -> bool { + true } } diff --git a/core/src/widget/widgets.rs b/core/src/widget/widgets.rs index 6098aa6..8ea9c2e 100644 --- a/core/src/widget/widgets.rs +++ b/core/src/widget/widgets.rs @@ -11,6 +11,11 @@ pub struct Widgets { send: Sender, recv: Receiver, pub(crate) waiting: HashSet, + /// Every widget that has ever been given an explicit `.label()` -- + /// `ui::access::AccessTree` walks exactly this set, not the whole + /// arena, so a widget nobody named costs it nothing. Symmetric with + /// `free_next` below, which is this set's one removal path. + named: HashSet, } impl Widgets { @@ -20,6 +25,7 @@ impl Widgets { needs_redraw: Default::default(), vec: Default::default(), waiting: Default::default(), + named: Default::default(), send, recv, } @@ -95,9 +101,20 @@ impl Widgets { &self.data(id.id()).unwrap().label } - /// useful for debugging + /// Also the one place a widget opts into `ui::access`'s AccessKit tree + /// (RUST.md's I4) -- see `named`'s doc comment. pub fn set_label(&mut self, id: impl IdLike, label: String) { - self.data_mut(id.id()).unwrap().label = label; + let id = id.id(); + self.data_mut(id).unwrap().label = label; + self.named.insert(id); + } + + /// Every widget with an explicit name, for `ui::access::AccessTree` to + /// walk. Order is unspecified; `AccessTree` doesn't need one; a screen + /// reader's own traversal is worked out by uiautomator from each + /// node's on-screen bounds instead. + pub fn named(&self) -> impl Iterator + '_ { + self.named.iter().copied() } pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> { @@ -107,6 +124,7 @@ impl Widgets { pub fn free_next(&mut self) -> Option { let next = self.recv.try_recv().ok()?; self.vec.free(next); + self.named.remove(&next); Some(next) } diff --git a/examples/bench_images.rs b/examples/bench_images.rs new file mode 100644 index 0000000..0ab403f --- /dev/null +++ b/examples/bench_images.rs @@ -0,0 +1,104 @@ +//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that +//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike +//! the counters in `benches/message_lazy_span.rs` -- goes to zero once every +//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`, +//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a +//! plain binary; run it through `iris/run-headless.sh bench_images`, which +//! gives it a real (headless, GPU-accelerated) compositor and surface. See +//! `run-bench.sh` for the wrapper that greps its output into one line. +//! +//! Each `RedrawRequested` prints the frame number and +//! `UiRenderNode::take_image_bind_group_creates()` for that frame, then +//! requests another redraw (nothing else marks the scene dirty, so without +//! this the app would only ever draw once). The first frame is expected to +//! report 1,000 (one create per image, on first load); the steady state +//! IRIS_TODO.md asks this scenario to prove is every frame after settling +//! down to 0. +//! +//! After `SETTLE_FRAMES` it appends one *new* image row (a transcript +//! receiving one more message) and keeps counting -- a chat transcript's +//! real access pattern is "one more image arrives," not "reload the whole +//! list," so the steady-state question that actually matters is the +//! *incremental* cost of that one append, not just whether an untouched +//! scene costs zero. It exits after `FRAMES`. + +use iris::prelude::*; + +const ROWS: usize = 1000; +const SETTLE_FRAMES: usize = 4; +const FRAMES: usize = 6; + +#[derive(DefaultUiState)] +struct State { + ui_state: DefaultUiState, + span: WeakWidget, + frame: usize, + appended: bool, +} + +impl DefaultAppState for State { + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let mut span = Span::empty(Dir::DOWN); + for _ in 0..ROWS { + let img = image::DynamicImage::new_rgba8(32, 32); + let widget = image::>(img)(rsc); + let widget = rsc.ui.widgets.add_strong(widget); + span.push(widget.any()); + } + let span = rsc.ui.widgets.add_strong(span); + let span_weak = span.weak(); + let root = rsc + .ui + .widgets + .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End)); + ui_state.set_root(root.any()); + Self { + ui_state, + span: span_weak, + frame: 0, + appended: false, + } + } + + fn window_event( + &mut self, + event: winit::event::WindowEvent, + rsc: &mut DefaultRsc, + _render: &mut UiRenderState, + ) { + if !matches!(event, winit::event::WindowEvent::RedrawRequested) { + return; + } + self.frame += 1; + let creates = self.ui_state.renderer.ui.take_image_bind_group_creates(); + println!( + "BENCH_IMAGES frame={} bind_group_creates={creates}", + self.frame + ); + if self.frame == SETTLE_FRAMES && !self.appended { + self.appended = true; + let img = image::DynamicImage::new_rgba8(32, 32); + let widget = image::>(img)(rsc); + let widget = rsc.ui.widgets.add_strong(widget); + rsc.ui + .widgets + .get_mut(&self.span) + .unwrap() + .push(widget.any()); + println!("BENCH_IMAGES appended one image after settling"); + } + if self.frame < FRAMES { + self.ui_state.window.request_redraw(); + } else { + std::process::exit(0); + } + } +} + +fn main() { + DefaultApp::::run(); +} diff --git a/examples/message_list.rs b/examples/message_list.rs new file mode 100644 index 0000000..4b63859 --- /dev/null +++ b/examples/message_list.rs @@ -0,0 +1,122 @@ +//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length +//! wrapped text, one in twelve carrying a small image, scrollable with the +//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot +//! /tmp/message_list.png` -- there is no display on this machine, so that +//! is the only way to see it rendered; `run-tests.sh`/`cargo test` never +//! touch this file. +//! +//! Rows alternate two background tints so a screenshot can show the +//! boundary between adjacent rows even where the text itself wraps to a +//! different number of lines -- exactly the "variable-height rows" I3 +//! asks for, and the thing a virtualised list gets wrong first if it is +//! wrong at all (a gap, an overlap, a row the wrong colour). This example +//! is also what found `LazySpan::place`'s oversized-background bug (see +//! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_ +//! oversized` test) -- a plain unit test could have (and now does) catch +//! it directly, but it was this screenshot rendering as a single blank +//! tinted rectangle that pointed at it first. + +use iris::prelude::*; +use winit::{dpi::LogicalSize, window::WindowAttributes}; + +fn main() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +struct State { + ui_state: DefaultUiState, +} + +const ROWS: usize = 800; +const IMAGE_EVERY: usize = 12; + +/// Repeats a short sentence a varying number of times per row so real +/// wrapping happens at every row height from one line to several, rather +/// than every row being identically tall (which would render correctly +/// even with a broken height measurement). +fn row_text(i: usize) -> String { + const SENTENCE: &str = + "Iris lays out this row once and moves it on scroll, never re-laying it out. "; + let repeats = 1 + (i * 7) % 5; + format!("Message {i}: {}", SENTENCE.repeat(repeats)) +} + +/// A small solid-colour square standing in for a real decoded image -- +/// what matters for I3 is that a row can carry an `Image` widget at all, +/// not what the picture shows. +fn row_image(i: usize) -> image::DynamicImage { + let hue = ((i * 47) % 255) as u8; + image::RgbaImage::from_pixel(48, 48, image::Rgba([hue, 128, 255 - hue, 255])).into() +} + +fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { + let tint = if i.is_multiple_of(2) { + Color::rgb(120, 130, 170) + } else { + Color::rgb(70, 80, 140) + }; + let text_color = Color::BLACK; + if i.is_multiple_of(IMAGE_EVERY) { + let text = wtext(row_text(i)) + .wrap(true) + .color(text_color) + .add_strong(rsc) + .any(); + let img = image::(row_image(i))(rsc); + let img = rsc.widgets_mut().add_strong(img).any(); + let mut span = Span::empty(Dir::DOWN); + span.push(text); + span.push(img); + span.pad(dp(8.0)) + .background(rect(tint)) + .add_strong(rsc) + .any() + } else { + wtext(row_text(i)) + .wrap(true) + .color(text_color) + .pad(dp(8.0)) + .background(rect(tint)) + .add_strong(rsc) + .any() + } +} + +impl DefaultAppState for State { + // A phone-plausible portrait shape (the transcript screen this is + // standing in for). The tiling headless compositor `run-headless.sh` + // uses ignores this and fills its own 1920x1200 output regardless, but + // it's a correct hint for any other backend (a real window manager, or + // android-view) and costs nothing to state. + fn window_attributes() -> WindowAttributes { + WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)) + } + + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + for i in 0..ROWS { + let row = build_row(rsc, i); + list.push_back(LazyItem::new(i as u64, row)); + } + + // `.scrollable()`, like anything else that scrolls -- here the + // span's own inherent one, which registers the wheel and the drag + // against the controller it already owns rather than wrapping it + // in a `ScrollArea`. Masked outside it, since a `LazySpan` draws + // the row straddling each edge in full and asserts something clips + // it. + let root = list + .scrollable() + .masked() + .background(rect(Color::WHITE)) + .add_strong(rsc); + ui_state.set_root(root.any()); + + Self { ui_state } + } +} diff --git a/examples/tabs/main.rs b/examples/tabs/main.rs index d9789f1..1860264 100644 --- a/examples/tabs/main.rs +++ b/examples/tabs/main.rs @@ -1,14 +1,14 @@ -use cosmic_text::Family; -use std::{cell::RefCell, rc::Rc}; -use winit::event::WindowEvent; - use iris::prelude::*; -type ClientRsc = DefaultRsc; +use winit::event::WindowEvent; fn main() { DefaultApp::::run(); } +/// The tabs example: five demo panes plus a message composer, built by +/// `tabs_ui::build` and driven here through the winit backend. The same +/// widget tree also runs on the android-view backend, through +/// `iris-android-app` -- see RUST.md's I2. #[derive(DefaultUiState)] pub struct Client { ui_state: DefaultUiState, @@ -21,189 +21,11 @@ impl DefaultAppState for Client { rsc: &mut DefaultRsc, _: Proxy, ) -> Self { - let rrect = rect(Color::WHITE).radius(20); - let pad_test = ( - rrect.color(Color::BLUE), - ( - rrect - .color(Color::RED) - .sized((100, 100)) - .center() - .width(rest(2)), - ( - rrect.color(Color::ORANGE), - rrect.color(Color::LIME).pad(10.0), - ) - .span(Dir::RIGHT) - .width(rest(2)), - rrect.color(Color::YELLOW), - ) - .span(Dir::RIGHT) - .pad(10) - .width(rest(3)), - ) - .span(Dir::RIGHT) - .add(rsc); - - let span_test = ( - rrect.color(Color::GREEN).width(100), - rrect.color(Color::ORANGE), - rrect.color(Color::CYAN), - rrect.color(Color::BLUE).width(rel(0.5)), - rrect.color(Color::MAGENTA).width(100), - rrect.color(Color::RED).width(100), - ) - .span(Dir::LEFT) - .add(rsc); - - let span_add = Span::empty(Dir::RIGHT).add(rsc); - - let add_button = rect(Color::LIME) - .radius(30) - .on(CursorSense::click(), move |_, rsc| { - let child = image(include_bytes!("assets/sungals.png")) - .center() - .add_strong(rsc); - span_add(rsc).push(child); - }) - .sized((150, 150)) - .align(Align::BOT_RIGHT); - - let del_button = rect(Color::RED) - .radius(30) - .on(CursorSense::click(), move |_, rsc| { - span_add(rsc).pop(); - }) - .sized((150, 150)) - .align(Align::BOT_LEFT); - - let span_add_test = (span_add, add_button, del_button).stack().add(rsc); - - let btext = |content| wtext(content).size(30); - - let text_test = ( - btext("this is a").align(Align::LEFT), - btext("teeeeeeeest").align(Align::RIGHT), - btext("okkk\nokkkkkk!").align(Align::LEFT), - btext("hmm"), - btext("a"), - ( - btext("'").family(Family::Monospace).align(Align::TOP), - btext("'").family(Family::Monospace), - btext(":gamer mode").family(Family::Monospace), - rect(Color::CYAN).sized((10, 10)).center(), - rect(Color::RED).sized((100, 100)).center(), - rect(Color::PURPLE).sized((50, 50)).align(Align::TOP), - ) - .span(Dir::RIGHT) - .center(), - wtext("pretty cool right?").size(50), - ) - .span(Dir::DOWN) - .add(rsc); - - let texts = Span::empty(Dir::DOWN).gap(10).add(rsc); - let msg_area = texts.scrollable().masked().background(rect(Color::SKY)); - let add_text = wtext("add") - .editable(EditMode::MultiLine) - .text_align(Align::LEFT) - .size(30) - .attr::(()) - .on(Submit, move |ctx, rsc| { - let w = ctx.widget; - let content = w.edit(rsc).take(); - let text = wtext(content) - .editable(EditMode::MultiLine) - .size(30) - .text_align(Align::LEFT) - .wrap(true) - .attr::(()); - let msg_box = text - .background(rect(Color::WHITE.darker(0.5))) - .add_strong(rsc); - texts(rsc).push(msg_box); - }) - .add(rsc); - - let text_edit_scroll = ( - msg_area.height(rest(1)), - ( - Rect::new(Color::WHITE.darker(0.9)), - ( - add_text.width(rest(1)), - Rect::new(Color::GREEN) - .on(CursorSense::click(), move |ctx, rsc: &mut ClientRsc| { - rsc.run_event::(add_text, (), ctx.state); - }) - .sized((40, 40)), - ) - .span(Dir::RIGHT) - .pad(10), - ) - .stack() - .size(StackSize::Child(1)) - .layer_offset(1) - .align(Align::BOT), - ) - .span(Dir::DOWN) - .add(rsc); - - let main = WidgetPtr::new().add(rsc); - - let vals = Rc::new(RefCell::new((0, Vec::new()))); - let mut switch_button = |color, to: WeakWidget, label| { - let to = to.upgrade(rsc); - let vec = &mut vals.borrow_mut().1; - let i = vec.len(); - if vec.is_empty() { - vec.push(None); - main(rsc).set(to); - } else { - vec.push(Some(to)); - } - let vals = vals.clone(); - let rect = rect(color) - .on(CursorSense::click(), move |ctx, rsc| { - let (prev, vec) = &mut *vals.borrow_mut(); - if let Some(h) = vec[i].take() { - vec[*prev] = main(rsc).replace(h); - *prev = i; - } - ctx.widget(rsc).color = color.darker(0.3); - }) - .on( - CursorSense::HoverStart | CursorSense::unclick(), - move |ctx, rsc| { - ctx.widget(rsc).color = color.brighter(0.2); - }, - ) - .on(CursorSense::HoverEnd, move |ctx, rsc| { - ctx.widget(rsc).color = color; - }); - (rect, wtext(label).size(30).text_align(Align::CENTER)).stack() - }; - - let tabs = ( - switch_button(Color::RED, pad_test, "pad"), - switch_button(Color::GREEN, span_test, "span"), - switch_button(Color::BLUE, span_add_test, "image span"), - switch_button(Color::MAGENTA, text_test, "text layout"), - switch_button( - Color::YELLOW.mul_rgb(0.5), - text_edit_scroll, - "text edit scroll", - ), - ) - .span(Dir::RIGHT); - - let info = wtext("").add(rsc); - let info_sect = info.pad(10).align(Align::RIGHT); - - ((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect) - .stack() - .set_root(rsc, &mut ui_state); - - Self { ui_state, info } + let widgets = tabs_ui::build(rsc, &mut ui_state); + Self { + ui_state, + info: widgets.info, + } } fn window_event( diff --git a/headless.conf b/headless.conf new file mode 100644 index 0000000..1f755ca --- /dev/null +++ b/headless.conf @@ -0,0 +1,14 @@ +# The compositor `run-headless.sh` starts, because this machine has no +# display. Nothing here is meant to be looked at directly; `grim` is. +# +# No Xwayland: winit talks Wayland natively, and starting an X server is a +# second thing to go wrong for no gain. (`emu`'s config forces it because the +# Android emulator's renderer speaks GLX.) +xwayland disable + +# A desktop-shaped output, since this is the desktop half of the port. Larger +# than the window an example opens, so nothing is scaled or clipped. +output HEADLESS-1 mode 1920x1200@60Hz + +default_border none +focus_follows_mouse no diff --git a/macro/Cargo.toml b/macro/Cargo.toml index 42d8dab..cdcc486 100644 --- a/macro/Cargo.toml +++ b/macro/Cargo.toml @@ -4,9 +4,9 @@ version.workspace = true edition.workspace = true [dependencies] -proc-macro2 = "1.0.103" -quote = "1.0.42" -syn = { version = "2.0.111", features = ["full"] } +proc-macro2 = "1.0.107" +quote = "1.0.47" +syn = { version = "3.0.5", features = ["full"] } [lib] proc-macro = true diff --git a/macro/src/lib.rs b/macro/src/lib.rs index 98b13cf..6263369 100644 --- a/macro/src/lib.rs +++ b/macro/src/lib.rs @@ -18,6 +18,12 @@ struct Input { } struct InputFn { + /// Everything written above the `fn` -- in practice a `///` doc + /// comment, which is why this exists: `masked_by` and its siblings + /// are public API and rustdoc is where their contract is read, so a + /// macro that silently rejected `///` sent the explanation into an + /// ordinary `//` comment nobody generating docs ever sees. + attrs: Vec, sig: Signature, body: Block, } @@ -32,9 +38,10 @@ impl Parse for Input { input.parse::()?; let mut fns = Vec::new(); while !input.is_empty() { + let attrs = input.call(Attribute::parse_outer)?; let sig = input.parse()?; let body = input.parse()?; - fns.push(InputFn { sig, body }) + fns.push(InputFn { attrs, sig, body }) } if !input.is_empty() { input.error("function expected"); @@ -59,10 +66,15 @@ pub fn widget_trait(input: TokenStream) -> TokenStream { fns, } = parse_macro_input!(input as Input); - let sigs: Vec<_> = fns.iter().map(|f| f.sig.clone()).collect(); + // The attributes go on the trait's own signature, which is the one + // rustdoc renders; the impl gets the bare `fn`. + let sigs: Vec<_> = fns + .iter() + .map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig }) + .collect(); let impls: Vec<_> = fns .iter() - .map(|InputFn { sig, body }| quote! { #sig #body }) + .map(|InputFn { sig, body, .. }| quote! { #sig #body }) .collect(); let Some(GenericParam::Type(state)) = generics.params.first() else { diff --git a/rig-input/Cargo.toml b/rig-input/Cargo.toml new file mode 100644 index 0000000..d63ab26 --- /dev/null +++ b/rig-input/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "rig-input" +version.workspace = true +edition.workspace = true + +# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one +# of the `.touch` files the headless tests use into whatever window is +# under a Wayland compositor, so the *same recording* drives the +# assertion layer and the layer a person looks at. +# +# It exists because this machine's compositor has no pointer to move. +# `run-headless.sh` starts sway on the headless backend with no input +# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`), +# so `swaymsg seat - cursor press` reports success and nothing reaches +# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots +# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would +# be ignored by a compositor that is not reading libinput. The +# virtual-pointer protocol is what is left, and it is a client protocol, +# so it needs no devices and no root. + +# Named for what it does rather than for the crate, since the crate may +# grow a keyboard replay beside it. +[[bin]] +name = "replay-touch" +path = "src/main.rs" + +[dependencies] +# `TouchScript` -- the same parser the harness uses, so a file that +# replays here and one that replays headless can never disagree. +iris = { path = ".." } +wayland-client = "0.31.15" +wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } diff --git a/rig-input/src/main.rs b/rig-input/src/main.rs new file mode 100644 index 0000000..a200b3f --- /dev/null +++ b/rig-input/src/main.rs @@ -0,0 +1,164 @@ +//! Replays a `.touch` file into the compositor as a left-button drag -- +//! see this crate's `Cargo.toml` for why it exists rather than +//! `swaymsg seat - cursor`. +//! +//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE +//! +//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual +//! pointer protocol positions absolutely against an extent rather than +//! in pixels; passing the output size makes a script's coordinates mean +//! the same pixels they mean in the headless tests. +//! +//! Replayed in real time (the sleeps between samples are the gaps in the +//! file), because winit has no timestamp on a pointer event and dates +//! each one when it arrives -- so a 20ms flick has to actually take +//! 20ms here, unlike layer 1 where the sample carries its own time. + +use iris::harness::{TouchAction, TouchScript}; +use std::time::Duration; +use wayland_client::protocol::wl_pointer::ButtonState; +use wayland_client::protocol::{wl_registry, wl_seat}; +use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; +use wayland_protocols_wlr::virtual_pointer::v1::client::{ + zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1, + zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, +}; + +/// `linux/input-event-codes.h`. The protocol takes the kernel's own +/// button code, not a wayland enum. +const BTN_LEFT: u32 = 0x110; + +/// How long the pointer sits at the gesture's first position before the +/// script starts -- see the comment at the pre-step in `main`. +const SETTLE: Duration = Duration::from_millis(200); + +#[derive(Default)] +struct Globals { + seat: Option, + manager: Option, +} + +impl Dispatch for Globals { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "zwlr_virtual_pointer_manager_v1" => { + state.manager = Some(registry.bind(name, version.min(2), qh, ())); + } + _ => {} + } + } +} + +delegate_noop!(Globals: ignore wl_seat::WlSeat); +delegate_noop!(Globals: ZwlrVirtualPointerManagerV1); +delegate_noop!(Globals: ZwlrVirtualPointerV1); + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let [width, height, path] = args.as_slice() else { + eprintln!("usage: replay-touch WIDTH HEIGHT FILE"); + std::process::exit(2); + }; + let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT")); + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| fail(&format!("could not read {path}: {e}"))); + let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e)); + + let conn = Connection::connect_to_env().unwrap_or_else(|e| { + fail(&format!( + "no wayland display ({e}); is WAYLAND_DISPLAY set?" + )) + }); + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let display = conn.display(); + display.get_registry(&qh, ()); + let mut globals = Globals::default(); + queue + .roundtrip(&mut globals) + .unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}"))); + + let manager = globals.manager.as_ref().unwrap_or_else(|| { + fail( + "this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \ + be synthesised; sway and every wlroots compositor do", + ) + }); + let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ()); + + // Put the pointer where the gesture starts and let the compositor + // settle before anything is pressed. Without this the press is + // dropped: sway has just learned about this pointer, and a button + // sent in the same breath as the motion that first puts it over a + // window arrives before there is a focused surface to send it to -- + // winit sees `CursorEntered`, the moves and the *release*, never the + // press, so the gesture reads as a hover and nothing scrolls. Found + // by printing winit's own events; the settle is what fixed it. + if let Some(first) = script.samples.first() { + pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height); + pointer.frame(); + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + std::thread::sleep(SETTLE); + } + + let mut previous = 0; + for sample in &script.samples { + std::thread::sleep(Duration::from_millis(sample.t_ms - previous)); + previous = sample.t_ms; + let t = sample.t_ms as u32; + pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height); + // One frame per sample, so the compositor delivers them as + // separate pointer frames rather than coalescing the whole + // gesture -- the shape the file recorded is the point. + pointer.frame(); + // The button goes in a frame of its own, *after* the motion has + // been committed. Sent in the same frame as the motion that + // first puts the pointer over the window, sway drops it: the + // client sees `CursorEntered` and the moves but never a + // `MouseInput { state: Pressed }`, so the whole gesture reads as + // a hover and nothing scrolls. Found exactly that way, by + // printing winit's events. + let state = match sample.action { + TouchAction::Down => Some(ButtonState::Pressed), + TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released), + TouchAction::Move => None, + }; + if let Some(state) = state { + pointer.button(t, BTN_LEFT, state); + pointer.frame(); + } + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + } + pointer.destroy(); + conn.flush().ok(); +} + +fn parse(text: &str, what: &str) -> u32 { + text.parse() + .unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}"))) +} + +fn fail(message: &str) -> ! { + eprintln!("replay-touch: {message}"); + std::process::exit(1); +} diff --git a/run-bench.sh b/run-bench.sh new file mode 100755 index 0000000..21a4a1e --- /dev/null +++ b/run-bench.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Runs iris's on-demand benchmark suite (IRIS_TODO.md's "Benchmarks" item). +# Never run by `cargo test`; run this by hand or before/after a layout +# change. Always release -- see AGENTS.md's own rule against reading a +# frame time from a debug build. +# +# ./run-bench.sh # everything +# ./run-bench.sh list # just the CPU-only message-list scenarios +# ./run-bench.sh images # just the GPU bind-group-creation scenario +set -eu +here=$(cd "$(dirname "$0")" && pwd) +cd "$here" + +what="${1:-all}" + +if [ "$what" = "all" ] || [ "$what" = "list" ]; then + echo "=== message_list (CPU-only, no window) ===" + cargo bench --bench message_list +fi + +if [ "$what" = "all" ] || [ "$what" = "images" ]; then + echo "=== bench_images (real wgpu device, via run-headless.sh) ===" + timeout 60 ./run-headless.sh bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES" +fi diff --git a/run-headless.sh b/run-headless.sh new file mode 100755 index 0000000..9243de2 --- /dev/null +++ b/run-headless.sh @@ -0,0 +1,200 @@ +#!/bin/sh +# Run an iris example on this machine, which has no display. +# +# ./run-headless.sh tabs [-- cargo args] +# ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 +# ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png +# ./run-headless.sh phone --phone --dir ../app-rust \ +# --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png +# +# `--dir DIR` names the workspace to build in, defaulting to `iris/` (this +# script's own directory). The app's examples -- the phone-sized transcript +# screen and everything else that is about *this product* -- live in +# `app-rust/`, which is a workspace of its own; `replay-touch` is still +# built from iris, since it is part of the rig rather than of either app. +# +# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output +# and the window take Iris's phone's own size and density (1080x2424 at +# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md, +# carried in `ai_app::ui::fixture::PHONE_*`), and `IRIS_SCALE` hands that +# density to iris the way `DisplayMetrics.density` does on Android +# (`iris::default::content_scale`). So a screenshot from here and one +# from the phone are the same layout at the same density, and what +# differs is only the renderer. Without it the output stays desktop- +# shaped, which is what every other example wants. +# +# `--replay FILE` drives one of the `.touch` recordings the headless +# tests use (`app-rust/touch/`) into the window through +# `rig-input`'s `replay-touch` -- one recording, both layers. With +# `--shot` it also writes `-before.png` from just before the +# gesture, since "the list moved" is a claim about two pictures. +# +# `--bin` runs a real crate binary instead of an example (E4's +# `ai-app-desktop`, which is a window a person runs, not a demo) -- +# `cargo build --bin NAME` instead of `--example NAME`, and +# `target/debug/NAME` instead of `target/debug/examples/NAME`. Its own +# argv (the CLI flags a real binary takes, as opposed to `cargo build`'s +# own flags after `--`) comes through `$RUN_HEADLESS_ARGS`, word-split on +# purpose -- an example never needed one, so there was nowhere to plumb it +# through positionally without disturbing the existing `-- cargo args` +# convention above. +# +# The VM has a real GPU and no display (the `this-machine-graphics` skill +# says what it is and how it fails), so what is missing here is only a +# compositor to give winit a surface. So: a headless sway, the same trick +# `emu` uses for the Android emulator, and `grim` to see the result. +# +# It is deliberately *not* `emu`'s compositor. sway tiles, so adding a window +# to the one an emulator is sitting in resizes that emulator's window, and a +# peer session's `emu up` could join at any moment. This one has its own +# socket and its own runtime directory and goes away with the machine. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +# The workspace `--dir` selects; see the header. `$here` is iris itself. +workdir="$here" +run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" +seconds=3 +shot="" +replay="" +example="" +kind=example +phone=no + +# The phone Iris runs the bench on. Not typed from memory: these are +# `ai_app::ui::fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which +# in turn come from her own reports -- keep the three in step. +PHONE_MODE=1080x2424@120Hz +PHONE_SCALE=2.55 +DESKTOP_MODE=1920x1200@60Hz + +while [ $# -gt 0 ]; do + case "$1" in + --shot) shot=$2; shift 2 ;; + --seconds) seconds=$2; shift 2 ;; + --bin) kind=bin; shift ;; + --phone) phone=yes; shift ;; + --replay) replay=$2; shift 2 ;; + --dir) workdir=$(cd "$2" && pwd); shift 2 ;; + --) shift; break ;; + *) example=$1; shift ;; + esac +done +[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--dir DIR] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } +[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; } + +mkdir -p "$run" +export SWAYSOCK="$run/sway.sock" + +# Named rather than left to sway's pid-based default, so a second run reuses +# this compositor instead of starting another beside it. +if ! swaymsg -t get_version >/dev/null 2>&1; then + rm -f "$SWAYSOCK" + WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 LIBSEAT_BACKEND=noop \ + setsid sway -c "$here/headless.conf" >"$run/sway.log" 2>&1 & + i=0 + while [ $i -lt 20 ]; do + swaymsg -t get_version >/dev/null 2>&1 && break + i=$((i + 1)); sleep 0.5 + done + swaymsg -t get_version >/dev/null 2>&1 || { + echo "run-headless: compositor did not start; see $run/sway.log" >&2 + exit 1 + } +fi + +# Asked of the compositor rather than guessed: sway takes the first free +# wayland-N, and this machine may already have one. +rm -f "$run/display" +swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null +i=0 +while [ $i -lt 20 ]; do + [ -s "$run/display" ] && break + i=$((i + 1)); sleep 0.5 +done +[ -s "$run/display" ] || { echo "run-headless: could not read WAYLAND_DISPLAY" >&2; exit 1; } +WAYLAND_DISPLAY=$(cat "$run/display") +export WAYLAND_DISPLAY + +echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 + +# Set every run rather than only when it changes: this compositor is +# reused across runs (see the socket comment above), so a desktop-shaped +# run after a phone-shaped one would otherwise inherit the phone's output +# and silently screenshot the wrong size. +if [ "$phone" = yes ]; then + mode=$PHONE_MODE + export IRIS_SCALE="$PHONE_SCALE" + echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2 +else + mode=$DESKTOP_MODE +fi +swaymsg output HEADLESS-1 mode "$mode" >/dev/null +# The extent `replay-touch` positions against, so a script's coordinates +# are the output's own pixels. +out_w=${mode%x*} +out_h=${mode#*x}; out_h=${out_h%@*} + +# Built before the app starts, so a compile error is not reported as a +# window that failed to move. +[ -z "$replay" ] || (cd "$here" && cargo build --bin replay-touch -p rig-input) >&2 + +cd "$workdir" +if [ "$kind" = bin ]; then + cargo build --bin "$example" "$@" >&2 + bin="$workdir/target/debug/$example" +else + cargo build --example "$example" "$@" >&2 + bin="$workdir/target/debug/examples/$example" +fi + +# shellcheck disable=SC2086 -- deliberately word-split: this is the +# binary's own argv, not a single path. +"$bin" ${RUN_HEADLESS_ARGS:-} >"$run/$example.log" 2>&1 & +pid=$! +trap 'kill "$pid" 2>/dev/null || true' EXIT INT TERM + +# Wait for the window to be mapped rather than for a number of seconds. A +# fixed sleep took an all-black screenshot the first time this ran, when sway +# had started in the same invocation and had not composited its output yet -- +# which is indistinguishable from an app that draws nothing. +i=0 +while [ $i -lt 40 ]; do + kill -0 "$pid" 2>/dev/null || break + swaymsg -t get_tree --raw 2>/dev/null | grep -q "\"pid\":$pid," && break + i=$((i + 1)); sleep 0.25 +done + +# Then settle, for whatever the example does after its first frame. +i=0 +while [ $i -lt "$((seconds * 2))" ]; do + kill -0 "$pid" 2>/dev/null || break + i=$((i + 1)); sleep 0.5 +done + +if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then + if [ -n "$shot" ]; then + grim "${shot%.png}-before.png" + echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2 + fi + "$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay" + # A fling outlives the finger: the gesture's own last sample is not + # when the list stops. Long enough for Android's spline to settle + # (`FlingCalculator::duration` tops out around a second and a half). + sleep 2 +fi + +if kill -0 "$pid" 2>/dev/null; then + [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + status=0 +else + wait "$pid" 2>/dev/null || status=$? + echo "run-headless: $example exited early (status ${status:-0})" >&2 + status=${status:-1} +fi + +echo "--- $example output ---" >&2 +cat "$run/$example.log" >&2 +exit "$status" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7fd6ab2 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,11 @@ +# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs). +# The pin is dated rather than "nightly" because the const-traits feature set +# changes shape between nightlies: on 2026-09-04 the vendored January tree would +# not parse at all, because `impl const Trait for T` had become +# `const impl Trait for T`. A rolling channel turns that into a build that +# breaks unattended on whatever machine Dev Updater happens to build on. +# Advance this deliberately, with the feature list in RUST.md's I0b. +[toolchain] +channel = "nightly-2026-09-03" +components = ["clippy", "rustfmt"] +targets = ["aarch64-linux-android", "x86_64-linux-android"] diff --git a/src/access_tests.rs b/src/access_tests.rs new file mode 100644 index 0000000..6a16292 --- /dev/null +++ b/src/access_tests.rs @@ -0,0 +1,129 @@ +//! Pass conditions for RUST.md's I4, exercised the same way +//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches +//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so +//! it can be driven directly against `layout_tests::TestRsc`. + +use crate::layout_tests::TestRsc; +use crate::prelude::*; + +#[test] +fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let leaf: WeakWidget = rect(UiColor::WHITE).label("Add task").add(&mut rsc); + let root = leaf.upgrade(&mut rsc).any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + let mut access = AccessTree::new(); + let update = access + .update(rsc.widgets(), &render, &rsc) + .expect("a first draw with a named widget must produce a tree"); + + // One node for the widget, one for the synthetic window root. + assert_eq!(update.nodes.len(), 2); + let (_, node) = update + .nodes + .iter() + .find(|(_, n)| n.role() != accesskit::Role::Window) + .expect("the named widget's own node"); + assert_eq!(node.label(), Some("Add task")); + assert_eq!(node.role(), accesskit::Role::Unknown); + let bounds = node.bounds().expect("a drawn widget reports its bounds"); + let region = render + .window_region(&leaf, &rsc) + .expect("the widget is active after render.update"); + assert_eq!(bounds.x0, region.top_left.x as f64); + assert_eq!(bounds.y0, region.top_left.y as f64); + assert_eq!(bounds.x1, region.bot_right.x as f64); + assert_eq!(bounds.y1, region.bot_right.y as f64); +} + +#[test] +fn a_widget_with_no_label_never_reaches_the_tree() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let root = rsc.ui.widgets.add_strong(rect(UiColor::WHITE)); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root.any(), &mut rsc); + + let mut access = AccessTree::new(); + assert!( + access.update(rsc.widgets(), &render, &rsc).is_none(), + "no widget was ever `.label()`ed, so there is nothing to report -- \ + not even an empty tree change" + ); +} + +/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region` +/// (which `window_region` sits on) walks the move-offset chain, so a +/// widget moved via `Offset` -- not redrawn from scratch -- must still +/// report where it actually ended up. +#[test] +fn bounds_follow_a_moved_widget_and_updates_stay_incremental() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let leaf: WeakWidget = rect(UiColor::WHITE).label("thing").add(&mut rsc); + let leaf_strong = leaf.upgrade(&mut rsc).any(); + let offset = rsc.ui.widgets.add_strong(Offset { + inner: leaf_strong, + amt: UiVec2::ZERO, + }); + let offset_id = offset.weak(); + let root = offset.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + let mut access = AccessTree::new(); + access + .update(rsc.widgets(), &render, &rsc) + .expect("the first draw is always a change"); + assert_eq!(access.take_rebuilds(), 1); + + // Unchanged frame: nothing moved, nothing renamed -- `update` must + // report no change, and the rebuild counter (I4's twin of + // `take_counters`) must stay at 0. + render.update(&root, &mut rsc); + assert!(access.update(rsc.widgets(), &render, &rsc).is_none()); + assert_eq!(access.take_rebuilds(), 0); + + // Move the child via `Offset` (a move-offset write, not necessarily a + // full redraw of the leaf -- see `resolve_move_chain`) and confirm the + // reported bounds shifted by exactly that amount, in exactly one more + // rebuild. + let before = render + .window_region(&leaf, &rsc) + .expect("active before the move"); + rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(50.0, 0.0)); + render.update(&root, &mut rsc); + let update = access + .update(rsc.widgets(), &render, &rsc) + .expect("a moved named widget is a change"); + assert_eq!(access.take_rebuilds(), 1); + + let after = render + .window_region(&leaf, &rsc) + .expect("still active after the move"); + // Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping + // is that widget's business, not this tree's. What I4 owns is that + // `AccessTree` reports whatever `window_region` says *now* -- so the + // node must have moved, and in the direction the offset moved it. + assert!( + after.top_left.x > before.top_left.x, + "the leaf's reported bounds must move right along with its offset" + ); + + let (_, node) = update + .nodes + .iter() + .find(|(_, n)| n.role() != accesskit::Role::Window) + .unwrap(); + let bounds = node.bounds().unwrap(); + assert_eq!(bounds.x0, after.top_left.x as f64); +} diff --git a/src/android/access.rs b/src/android/access.rs new file mode 100644 index 0000000..9b59269 --- /dev/null +++ b/src/android/access.rs @@ -0,0 +1,86 @@ +//! I4 (RUST.md): the Android half of the AccessKit push, over +//! `accesskit_android::Adapter` and android-view's +//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's +//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0 +//! alike) never moves back to `Inactive` once a client attaches, so once +//! one has, every later `QueuedEvents::raise` reaches +//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if +//! accessibility has since been switched off (or the client detached), +//! and android-view's `panic = "abort"` turns that Java exception into a +//! process kill. `raise_if_enabled` is the gate: ask +//! `AccessibilityManager.isEnabled()` immediately before every `raise` +//! and drop the events instead of calling it when the answer is no. See +//! RUST.md's E1 box for the full repro. +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; +use accesskit_android::QueuedEvents; +use android_view::{ + View, + jni::{JNIEnv, objects::JObject}, +}; +use iris_core::{AccessTree, UiRenderState, UiRsc, Widgets}; + +/// The `ActivationHandler` `accesskit_android::Adapter` asks for its +/// initial tree from -- unlike `accesskit_winit`'s handlers (see +/// `default/access.rs`), this one is only ever invoked synchronously from +/// inside a JNI callback that already holds everything it needs, so it can +/// just borrow `IrisViewPeer`'s own fields for the length of one call +/// rather than going through a channel. +pub(super) struct AndroidAccessSource<'a> { + pub widgets: &'a Widgets, + pub render: &'a UiRenderState, + pub rsc: &'a dyn UiRsc, +} + +impl ActivationHandler for AndroidAccessSource<'_> { + fn request_initial_tree(&mut self) -> Option { + Some(AccessTree::build_full(self.widgets, self.render, self.rsc)) + } +} + +/// Every AccessKit action request is inert here -- see this module's doc +/// comment and `default/access.rs`'s matching handler for why: a screen +/// reader's tap on a named node is a real touch delivered at that node's +/// bounds, which the ordinary pointer path already handles once the +/// bounds `AccessTree` reports are right. +pub(super) struct NullActionHandler; +impl ActionHandler for NullActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} +} + +fn is_accessibility_enabled<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) -> bool { + let context = view.context(env); + let name = env.new_string("accessibility").unwrap(); + let manager: JObject = env + .call_method( + &context.0, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[(&name).into()], + ) + .unwrap() + .l() + .unwrap(); + if manager.is_null() { + return false; + } + env.call_method(&manager, "isEnabled", "()Z", &[]) + .unwrap() + .z() + .unwrap() +} + +/// The one place `QueuedEvents::raise` may be called -- see this module's +/// doc comment. Every call site pushes this as a deferred callback rather +/// than calling it inline, matching android-view's own demo: `raise` +/// itself asks not to be called while the caller holds locks a framework +/// callback might, and a deferred callback runs after the current one has +/// returned them. +pub(super) fn raise_if_enabled<'local>( + env: &mut JNIEnv<'local>, + view: &View<'local>, + events: QueuedEvents, +) { + if is_accessibility_enabled(env, view) { + events.raise(env, &view.0); + } +} diff --git a/src/android/attr.rs b/src/android/attr.rs new file mode 100644 index 0000000..e980dfa --- /dev/null +++ b/src/android/attr.rs @@ -0,0 +1,29 @@ +use crate::attr::{FocusHost, recent_click}; +use crate::prelude::*; + +use super::view::HasAndroidUiState; + +impl FocusHost for T { + fn recent_click(&mut self) -> bool { + recent_click(&mut self.android_state_mut().last_click) + } + + fn set_focus(&mut self, id: Option>) { + self.android_state_mut().focus = id; + } + + fn is_focused(&self, id: WeakWidget) -> bool { + self.android_state().focus == Some(id) + } + + fn focus_gained(&mut self, region: Option) { + // Showing the keyboard is a JNI call (`InputMethodManager.showSoftInput`), + // and this runs deep inside the platform-agnostic sensor dispatch + // with no `CallbackCtx` in reach -- `IrisViewPeer::after_input` + // (`view.rs`) is what actually makes the call, right after the + // sensor pass that got here returns. + if region.is_some() { + self.android_state_mut().pending_show_keyboard = true; + } + } +} diff --git a/src/android/ime.rs b/src/android/ime.rs new file mode 100644 index 0000000..7f9a3b6 --- /dev/null +++ b/src/android/ime.rs @@ -0,0 +1,318 @@ +//! `InputConnection`, implemented directly against a focused `TextEdit` +//! rather than against a stand-in editor the way android-view's own demo +//! does over its `parley::PlainEditor` -- I1 already put parley behind +//! `TextEdit`, so this is that same bridge, just wired to iris's widget +//! instead of a bespoke one. Follows `demo/src/lib.rs`'s +//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1 +//! found the shape this needs (`text_before_cursor` is what gets Gboard's +//! suggestion strip to read real words out of the buffer). +//! +//! Two things the demo tracks that this does not, both noted rather than +//! silently dropped: a real "composing region" distinct from the +//! selection (`set_composing_region` here just moves the caret, since +//! `TextEdit` has no third range to hold one), and batch-edit coalescing +//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame +//! it does not need to, not correctness). + +use crate::prelude::*; +use android_view::{ + CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI, + IME_FLAG_NO_FULLSCREEN, INPUT_TYPE_CLASS_TEXT, INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT, + INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES, INPUT_TYPE_TEXT_FLAG_MULTI_LINE, InputConnection, + caps_mode, +}; +use std::borrow::Cow; + +use super::view::{AndroidAppState, IrisViewPeer}; + +/// Byte offset -> UTF-16 code unit offset, the unit every `InputConnection` +/// method speaks in (Java strings are UTF-16). `TextEdit` is byte-indexed +/// throughout since I1 moved it to parley -- see `edit.rs`'s doc comment on +/// `text()` -- so every crossing of this boundary goes through here rather +/// than through ad hoc counting at each call site. +fn byte_to_utf16(text: &str, byte_idx: usize) -> usize { + text[..byte_idx].encode_utf16().count() +} + +fn utf16_to_byte(text: &str, utf16_idx: usize) -> usize { + let mut utf16_len = 0; + for (byte_idx, ch) in text.char_indices() { + if utf16_len >= utf16_idx { + return byte_idx; + } + utf16_len += ch.len_utf16(); + } + text.len() +} + +impl IrisViewPeer { + fn focus(&self) -> Option> { + self.state.android_state().focus + } + + /// Tell Gboard where the caret/selection and the composing region + /// actually are, via `InputMethodManager.updateSelection` -- every one + /// of android-view's own demo's `set_composing_text_internal`/`render` + /// calls this, and this bridge never did, which is what left Gboard's + /// own model of the field diverging from `TextEdit`'s real one after + /// the very first edit (RUST.md's P0 box, "doesn't enter it until I + /// hit space, and also doesn't move cursor forward" -- Gboard holds + /// its composing keystrokes back until it believes the app has caught + /// up, and without this call it never does). Called from + /// [`IrisViewPeer::after_input`], the one tail every touch/key/IME + /// callback already runs through, rather than duplicated at each of + /// this file's mutating methods. + /// + /// `candidates_start`/`candidates_end` report the composing region; + /// `-1, -1` when nothing is composing, matching `EditorInfo`'s own + /// convention. `compose_len` is tracked in `char`s (this module's doc + /// comment), so this reports it as that many UTF-16 units back from the + /// caret -- exact for the common BMP case, the same approximation + /// `set_composing_text` already makes. + pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) { + let Some(focus) = self.focus() else { return }; + let text = &self.rsc[focus]; + let Some(sel) = text.selection_range() else { + return; + }; + let content = text.text(); + let sel_start = byte_to_utf16(content, sel.start) as i32; + let sel_end = byte_to_utf16(content, sel.end) as i32; + let compose_len = self.state.android_state().compose_len; + let (comp_start, comp_end) = if compose_len > 0 { + let caret = byte_to_utf16(content, text.caret().unwrap_or(sel.end)) as i32; + (caret - compose_len as i32, caret) + } else { + (-1, -1) + }; + let imm = ctx.view.input_method_manager(&mut ctx.env); + imm.update_selection( + &mut ctx.env, + &ctx.view, + sel_start, + sel_end, + comp_start, + comp_end, + ); + } +} + +impl InputConnection for IrisViewPeer { + fn on_create_input_connection<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + out_attrs: &EditorInfo<'local>, + ) { + // Set once per `InputConnection`, not per field -- Android calls + // this when the view (not a particular widget) attaches to an + // IME. `MULTI_LINE`/`AUTO_CORRECT`/`CAP_SENTENCES` cover both the + // tabs example's composer and a plain single-line field well + // enough that no per-field variant is worth the extra state yet. + out_attrs.set_input_type( + &mut ctx.env, + INPUT_TYPE_CLASS_TEXT + | INPUT_TYPE_TEXT_FLAG_CAP_SENTENCES + | INPUT_TYPE_TEXT_FLAG_AUTO_CORRECT + | INPUT_TYPE_TEXT_FLAG_MULTI_LINE, + ); + out_attrs.set_ime_options( + &mut ctx.env, + IME_FLAG_NO_FULLSCREEN | IME_FLAG_NO_EXTRACT_UI | IME_FLAG_NO_ENTER_ACTION, + ); + if let Some(focus) = self.focus() { + let text = &self.rsc[focus]; + let sel = text.selection_range().unwrap_or(0..0); + let start = byte_to_utf16(text.text(), sel.start) as i32; + let end = byte_to_utf16(text.text(), sel.end) as i32; + out_attrs.set_initial_sel_start(&mut ctx.env, start); + out_attrs.set_initial_sel_end(&mut ctx.env, end); + let caps = caps_mode( + &mut ctx.env, + text.text(), + start as usize, + CAP_MODE_SENTENCES, + ); + out_attrs.set_initial_caps_mode(&mut ctx.env, caps); + } + } + + fn text_before_cursor<'slf>( + &'slf mut self, + _ctx: &mut CallbackCtx, + n: i32, + ) -> Option> { + if n < 0 { + return None; + } + let focus = self.focus()?; + let text = &self.rsc[focus]; + let sel = text.selection_range()?; + let end_16 = byte_to_utf16(text.text(), sel.start); + let start_16 = end_16.saturating_sub(n as usize); + let start = utf16_to_byte(text.text(), start_16); + Some(Cow::Borrowed(&text.text()[start..sel.start])) + } + + fn text_after_cursor<'slf>( + &'slf mut self, + _ctx: &mut CallbackCtx, + n: i32, + ) -> Option> { + if n < 0 { + return None; + } + let focus = self.focus()?; + let text = &self.rsc[focus]; + let sel = text.selection_range()?; + let len_16 = byte_to_utf16(text.text(), text.text().len()); + let start_16 = byte_to_utf16(text.text(), sel.end); + let end_16 = (start_16 + n as usize).min(len_16); + let end = utf16_to_byte(text.text(), end_16); + Some(Cow::Borrowed(&text.text()[sel.end..end])) + } + + fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option> { + let focus = self.focus()?; + Some(Cow::Owned(self.rsc[focus].selected_text()?)) + } + + fn cursor_caps_mode(&mut self, ctx: &mut CallbackCtx, req_modes: u32) -> u32 { + let Some(focus) = self.focus() else { + return 0; + }; + let text = &self.rsc[focus]; + let Some(caret) = text.caret() else { + return 0; + }; + let off = byte_to_utf16(text.text(), caret); + caps_mode(&mut ctx.env, text.text(), off, req_modes) + } + + fn delete_surrounding_text( + &mut self, + ctx: &mut CallbackCtx, + before_length: i32, + after_length: i32, + ) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + let text = &self.rsc[focus]; + let Some(sel) = text.selection_range() else { + return false; + }; + let content = text.text(); + let start_16 = + byte_to_utf16(content, sel.start).saturating_sub(before_length.max(0) as usize); + let len_16 = byte_to_utf16(content, content.len()); + let end_16 = (byte_to_utf16(content, sel.end) + after_length.max(0) as usize).min(len_16); + let start = utf16_to_byte(content, start_16); + let end = utf16_to_byte(content, end_16); + focus.edit(&mut self.rsc).delete_byte_range(start, end); + self.after_input(ctx); + true + } + + fn delete_surrounding_text_in_code_points( + &mut self, + ctx: &mut CallbackCtx, + before_length: i32, + after_length: i32, + ) -> bool { + // Approximated as UTF-16 units rather than Unicode scalar values -- + // the two differ only outside the Basic Multilingual Plane, which + // this widget tree does not exercise today. Worth revisiting if a + // field ever needs to edit emoji or other astral-plane text well. + self.delete_surrounding_text(ctx, before_length, after_length) + } + + fn set_composing_text( + &mut self, + ctx: &mut CallbackCtx, + text: &str, + _new_cursor_position: i32, + ) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + // The IME re-sends its whole composition on every keystroke; + // `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit) + // is what lets `replace` remove exactly what it inserted last time. + // The same shape as `default::DefaultApp`'s `Ime::Preedit` handling + // for winit. + let compose_len = self.state.android_state().compose_len; + focus.edit(&mut self.rsc).replace(compose_len, text); + self.state.android_state_mut().compose_len = text.chars().count(); + self.after_input(ctx); + true + } + + fn set_composing_region(&mut self, _ctx: &mut CallbackCtx, _start: i32, _end: i32) -> bool { + // `TextEdit` has no separate composing range to move -- see this + // module's doc comment. Declining (rather than moving the caret, + // which would surprise a caller expecting only a style change) + // is the safer approximation. + false + } + + fn finish_composing_text(&mut self, ctx: &mut CallbackCtx) -> bool { + self.state.android_state_mut().compose_len = 0; + self.after_input(ctx); + true + } + + fn set_selection(&mut self, ctx: &mut CallbackCtx, start: i32, end: i32) -> bool { + let Some(focus) = self.focus() else { + return false; + }; + let text = &self.rsc[focus]; + let content = text.text(); + // Collapsed to `end`: `TextEditCtx` has no range-selection setter + // yet (nothing before I2 needed one), so an IME-driven selection + // lands the caret at its focus end rather than spanning both. + let byte = utf16_to_byte(content, end.max(0) as usize); + focus.edit(&mut self.rsc).set_cursor_byte(byte); + let _ = start; + self.after_input(ctx); + true + } + + fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool { + // `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one; + // nothing here needs handling it yet. + false + } + + fn begin_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool { + true + } + + fn end_batch_edit(&mut self, _ctx: &mut CallbackCtx) -> bool { + true + } + + fn send_key_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &android_view::KeyEvent<'local>, + ) -> bool { + let key_code = event.key_code(&mut ctx.env); + let handled = super::input::on_key( + &mut self.rsc, + &mut self.state, + &mut ctx.env, + key_code, + event, + ); + if handled { + self.after_input(ctx); + } + handled + } + + fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool { + // No cursor-anchor UI to feed -- see RUST.md's I2 notes on what + // this backend does not do yet. + false + } +} diff --git a/src/android/input.rs b/src/android/input.rs new file mode 100644 index 0000000..963675e --- /dev/null +++ b/src/android/input.rs @@ -0,0 +1,39 @@ +use crate::prelude::*; +use android_view::{jni::JNIEnv, ndk::event::Keycode}; + +use super::view::{AndroidAppState, AndroidRsc}; + +/// Hardware/synthesized key handling for the field that currently has +/// focus. Most typing on Android goes through the IME's `InputConnection` +/// (`android/ime.rs`) instead -- this only sees what a soft keyboard still +/// sends as a real `KeyEvent` in "not fullscreen" mode (Backspace, Enter, +/// the arrow keys on a physical keyboard) plus whatever `unicode_char` +/// reports for a plain key press. Returns whether anything used the event. +pub(super) fn on_key<'local, State: AndroidAppState>( + rsc: &mut AndroidRsc, + state: &mut State, + env: &mut JNIEnv<'local>, + key_code: Keycode, + event: &android_view::KeyEvent<'local>, +) -> bool { + let Some(focus) = state.android_state().focus else { + return false; + }; + let mut text = focus.edit(rsc); + match key_code { + Keycode::Del => text.backspace(false), + Keycode::ForwardDel => text.delete(false), + Keycode::DpadLeft => text.motion(Motion::Left, false), + Keycode::DpadRight => text.motion(Motion::Right, false), + Keycode::DpadUp => text.motion(Motion::Up, false), + Keycode::DpadDown => text.motion(Motion::Down, false), + Keycode::MoveHome => text.motion(Motion::LineStart, false), + Keycode::MoveEnd => text.motion(Motion::LineEnd, false), + Keycode::Enter | Keycode::NumpadEnter => text.newline(), + _ => match event.unicode_char(env) { + Some(c) if !c.is_control() => text.insert(&c.to_string()), + _ => return false, + }, + } + true +} diff --git a/src/android/insets.rs b/src/android/insets.rs new file mode 100644 index 0000000..95ae6cb --- /dev/null +++ b/src/android/insets.rs @@ -0,0 +1,155 @@ +//! Window insets, fed in from outside `ViewPeer`. +//! +//! android-view's registered native methods (`view.rs` in that crate) cover +//! touch, keys, focus, the surface and the IME -- there is nothing for +//! `View.onApplyWindowInsets`, because android-view's own demo does not +//! need it. The back gesture needed no new plumbing at all: with no +//! `OnBackPressedCallback` registered, Android still delivers it as an +//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see +//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets +//! by default and is enough for "the back gesture as an event". Insets have +//! no such stand-in, so this module registers one more native method by +//! hand, on the app's own `View` subclass rather than on android-view's. +//! +//! The peer id android-view hands back from `register_view_peer` is opaque +//! outside that crate (`with_peer` is `pub(crate)` there), so there is no +//! way to reach an existing `IrisViewPeer` from a JNI entry point we define +//! ourselves. Instead of forking android-view to add a hook, `new_peer` +//! (`view.rs`) inserts the *same* id into this module's own map, pointing +//! at a plain `Rc>` cloned into `AndroidUiState` too -- +//! so writing here is reading there, with no dependency in either +//! direction on the other's internals. + +use android_view::{ + View, + jni::{ + JNIEnv, NativeMethod, + descriptors::Desc, + objects::JClass, + sys::{jint, jlong}, + }, +}; +use std::{ + cell::RefCell, + collections::HashMap, + ffi::c_void, + rc::Rc, + sync::{Mutex, OnceLock}, +}; + +use send_wrapper::SendWrapper; + +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] +pub struct Insets { + pub left: i32, + pub top: i32, + pub right: i32, + pub bottom: i32, + /// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical + /// pixels, separate from `bottom` (the system bars): a layout wants to + /// know about the keyboard specifically, since it usually means "make + /// room" rather than "stay clear of a corner". + pub ime_bottom: i32, + /// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which + /// is **not** the same question as `ime_bottom > 0` and is why the two + /// are carried separately. They disagree for the frames the keyboard + /// spends sliding: visible, with a height still on its way to the full + /// one. Anything asking "make how much room" reads `ime_bottom`; + /// anything asking "is the keyboard up" reads this. See + /// `MainActivity.java`'s comment for the history -- the height used to + /// be sent *as* this boolean, which is what left the composer padded by + /// one pixel on Iris's phone. + pub ime_visible: bool, +} + +#[derive(Default)] +pub struct Shared { + pub insets: Insets, + /// How many times Java has called `applyWindowInsetsNative` for this + /// peer, whether or not the numbers changed. Deliberately **not** a + /// field of `Insets`, which is compared for equality each frame to + /// decide whether to re-run `on_insets_changed`; a counter in there + /// would make every dispatch look like a change. + /// + /// It exists because "the keyboard does not push anything up" has two + /// completely different causes that look identical on screen -- the + /// listener never fired, or it fired with a zero `ime_bottom` -- and + /// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is + /// in the `Diagnostics` overlay, so one screenshot separates them. + pub updates: u64, +} + +type SharedMap = HashMap>>>; + +fn map() -> &'static Mutex { + static MAP: OnceLock> = OnceLock::new(); + MAP.get_or_init(Default::default) +} + +/// Called from `view::new_peer` with the same id android-view's +/// `register_view_peer` returned, so a later `apply_window_insets` call +/// (keyed on that id by Java, which only ever sees the one long) reaches +/// the same `Shared` cell `AndroidUiState` reads from. +pub(super) fn register(id: jlong, shared: Rc>) { + map().lock().unwrap().insert(id, SendWrapper::new(shared)); +} + +extern "system" fn unregister_insets<'local>( + _env: JNIEnv<'local>, + _view: View<'local>, + peer: jlong, +) { + map().lock().unwrap().remove(&peer); +} + +extern "system" fn apply_window_insets<'local>( + mut env: JNIEnv<'local>, + view: View<'local>, + peer: jlong, + left: jint, + top: jint, + right: jint, + bottom: jint, + ime_bottom: jint, + ime_visible: jint, +) { + if let Some(shared) = map().lock().unwrap().get(&peer) { + let mut shared = shared.borrow_mut(); + shared.insets = Insets { + left, + top, + right, + bottom, + ime_bottom, + ime_visible: ime_visible != 0, + }; + shared.updates += 1; + } + // Insets can change (the keyboard opening) with no resize and no + // touch, so nothing else here would otherwise ask for a frame. + view.post_frame_callback(&mut env); +} + +/// Registers `applyWindowInsetsNative` on the app's own `View` subclass. +/// Called once from `JNI_OnLoad` alongside `android_view::register_view_class`. +pub fn register_native_methods<'local, 'other_local>( + env: &mut JNIEnv<'local>, + class: impl Desc<'local, JClass<'other_local>>, +) { + env.register_native_methods( + class, + &[ + NativeMethod { + name: "applyWindowInsetsNative".into(), + sig: "(JIIIIII)V".into(), + fn_ptr: apply_window_insets as *mut c_void, + }, + NativeMethod { + name: "unregisterInsetsNative".into(), + sig: "(J)V".into(), + fn_ptr: unregister_insets as *mut c_void, + }, + ], + ) + .unwrap(); +} diff --git a/src/android/mod.rs b/src/android/mod.rs new file mode 100644 index 0000000..2bc7392 --- /dev/null +++ b/src/android/mod.rs @@ -0,0 +1,45 @@ +//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a +//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists +//! as a second backend rather than winit's own (unfinished, and blocked on +//! `android-activity`'s backend-feature requirement) Android support, and +//! for the pass condition this was built against. +//! +//! Structured to mirror `default/` module for module: `view.rs` is that +//! module's `app.rs` + `state.rs` combined (android-view has one harness +//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the +//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`, +//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit +//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit` +//! (RUST.md's E1) and has no concept of Android's window insets at all. + +mod access; +mod attr; +mod ime; +mod input; +mod insets; +mod platform; +mod render; +mod view; + +pub use insets::Insets; +pub use render::AndroidRenderer; +pub use view::{ + AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState, IrisViewPeer, WindowInsets, + new_peer, +}; + +/// Registers the extra native methods this backend needs beyond what +/// `android_view::register_view_class` covers (window insets -- see +/// `insets.rs`'s doc comment for why that one could not ride along on an +/// existing android-view callback the way the back gesture does). Call +/// from `JNI_OnLoad` alongside `register_view_class`, on the same `View` +/// subclass. +pub fn register_native_methods<'local, 'other_local>( + env: &mut android_view::jni::JNIEnv<'local>, + class: impl android_view::jni::descriptors::Desc< + 'local, + android_view::jni::objects::JClass<'other_local>, + >, +) { + insets::register_native_methods(env, class); +} diff --git a/src/android/platform.rs b/src/android/platform.rs new file mode 100644 index 0000000..df5f867 --- /dev/null +++ b/src/android/platform.rs @@ -0,0 +1,86 @@ +use crate::platform::OpenUrl; +use android_view::{ + View, + jni::{JNIEnv, objects::JValue}, +}; + +use super::view::HasAndroidUiState; + +/// Android's URL opener. Like `FocusHost::focus_gained`'s keyboard, the +/// real work is a JNI call and this runs deep inside the sensor dispatch +/// with no `CallbackCtx` in reach -- so it raises a flag that +/// `IrisViewPeer::after_input` consumes, exactly as +/// `pending_show_keyboard` does. +/// +/// Last request wins: two links cannot be tapped in one frame, and a URL +/// left queued from a frame that somehow never reached `after_input` +/// would open at some unrelated later tap, which is worse than dropping +/// it. +impl OpenUrl for T { + fn open_url(&mut self, url: &str) { + self.android_state_mut().pending_open_url = Some(url.to_string()); + } +} + +/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's +/// own context. +/// +/// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which +/// may be an application context rather than the activity's -- Android +/// throws `AndroidRuntimeException` for a non-activity context without it, +/// and it is harmless when the context *is* an activity's. +/// +/// Every failure is logged with the URL and returns; there is nothing to +/// fall back to, and the reader will see that nothing happened. +pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) { + match try_open_url(env, view, url) { + Ok(()) => {} + Err(e) => { + // A pending Java exception makes every later JNI call fail in + // ways nowhere near here, so it is cleared at the boundary. + let _ = env.exception_clear(); + log::warn!("could not open {url}: {e}"); + } + } +} + +fn try_open_url<'local>( + env: &mut JNIEnv<'local>, + view: &View<'local>, + url: &str, +) -> Result<(), android_view::jni::errors::Error> { + let context = env + .call_method(&view.0, "getContext", "()Landroid/content/Context;", &[])? + .l()?; + let jurl = env.new_string(url)?; + let uri = env.call_static_method( + "android/net/Uri", + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[JValue::Object(jurl.as_ref())], + )?; + let action = env.new_string("android.intent.action.VIEW")?; + let intent = env.new_object( + "android/content/Intent", + "(Ljava/lang/String;Landroid/net/Uri;)V", + &[JValue::Object(action.as_ref()), JValue::Object(&uri.l()?)], + )?; + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(FLAG_ACTIVITY_NEW_TASK)], + )?; + env.call_method( + &context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&intent)], + )?; + Ok(()) +} + +/// `android.content.Intent.FLAG_ACTIVITY_NEW_TASK`. A constant rather than +/// a static-field read: it is part of the platform's stable ABI and +/// reading it costs two more JNI calls that can each fail. +const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000; diff --git a/src/android/render.rs b/src/android/render.rs new file mode 100644 index 0000000..f01662f --- /dev/null +++ b/src/android/render.rs @@ -0,0 +1,526 @@ +use crate::task::RequestRedraw; +use android_view::{ + View, + jni::{JavaVM, objects::GlobalRef}, + ndk::native_window::NativeWindow, +}; +use iris_core::{UiData, UiRenderNode, UiRenderState}; +use pollster::FutureExt; +use std::time::{Duration, Instant}; +use wgpu::{ + rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle}, + *, +}; + +pub const CLEAR_COLOR: Color = Color::BLACK; + +/// `NativeWindow` (from the surface android-view hands over in +/// `surfaceChanged`) has a window handle but not a display one -- there is +/// exactly one display on Android and `rwh` has a unit variant for it. +/// Mirrors android-view's own demo (`demo/src/lib.rs`'s +/// `AndroidWindowHandle`). +struct AndroidWindowHandle { + window: NativeWindow, +} + +impl HasDisplayHandle for AndroidWindowHandle { + fn display_handle(&self) -> Result, HandleError> { + Ok(DisplayHandle::android()) + } +} + +impl HasWindowHandle for AndroidWindowHandle { + fn window_handle(&self) -> Result, HandleError> { + self.window.window_handle() + } +} + +/// The android-view surface, unlike winit's window, does not outlive a +/// backgrounding of the activity: `surfaceDestroyed`/`surfaceCreated` (via +/// `SurfaceHolder.Callback`) recreate it, so this holds everything that +/// depends on that surface rather than being built once at startup -- +/// `AndroidUiState` holds it as `Option`, `None` exactly +/// when there is no surface to draw into. +pub struct AndroidRenderer { + surface: Surface<'static>, + device: Device, + queue: Queue, + config: SurfaceConfiguration, + encoder: CommandEncoder, + pub ui: UiRenderNode, + /// The adapter identity, kept past `new()` for the Diagnostics page -- + /// `Adapter` itself is not `Clone`, so the three fields the page shows + /// are copied out once here rather than holding the adapter. + pub adapter_name: String, + pub adapter_backend: Backend, + pub adapter_driver: String, + /// Every uncaptured wgpu error since this renderer was created -- see + /// `iris_core::WgpuErrorLog`'s doc comment. Installed on `device` in + /// `new()`, kept here so the Diagnostics page and the per-frame log in + /// `update()` can both read it without a global. + pub wgpu_errors: iris_core::WgpuErrorLog, + /// Frames drawn on this surface -- what gates the first-10-frames log + /// `update()` writes (RUST.md's P0 box, "the first input frame" + /// investigation): a fresh surface is exactly what Iris's own report + /// says renders correctly at first, so the frames that matter are the + /// first several after each `surface_changed`, not an arbitrary window + /// during a long-running session. + frame_count: u64, + /// Physical pixels per dp -- see `android::view::AndroidUiState:: + /// content_scale`'s field comment for what this feeds. + content_scale: f32, +} + +/// One frame's worth of the counters `render/mod.rs`'s doc comments on +/// `FrameUpdateStats`/`take_image_bind_group_creates`/ +/// `take_atlas_pages_grown` describe -- assembled here because the three +/// live on two different calling conventions (`FrameUpdateStats` from this +/// exact `update()` call; the other two describe the *previous* frame, +/// same as `bench_images`' existing use of them) and a diagnostic reader +/// should not have to know that split. +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameDiagnostics { + pub masks_resized: bool, + pub moves_resized: bool, + /// From the previous frame's `update()` -- see the struct doc. + pub atlas_pages_grown_prev: u64, + pub image_bind_group_creates_prev: u64, +} + +impl AndroidRenderer { + /// `Err` holds a full, human-readable report for **every** way this + /// can fail -- no surface, no adapter, no device, or wgpu's own error + /// text (`UiRenderNode::new`'s doc comment) plus the adapter identity + /// and the limits/downlevel flags bind-group-layout validation checks + /// against -- rather than the panic wgpu's default error handler would + /// otherwise raise with no caller able to see it. This is what aborted + /// the P0 bench APK on Iris's phone with only "wgpu error: Validation + /// Error" surviving into the crash report (RUST.md's P0 box, "iris + /// bench crash on the phone, 2026-09-06"): `create_bind_group_layout` + /// validates against *this* adapter's downlevel capabilities and + /// limits, which a desktop GPU and the emulator's software renderers + /// never exercised. The caller (`android::view::IrisViewPeer:: + /// surface_changed`) logs this one-line-flattened and shows it on + /// screen instead of aborting the process. + pub fn new( + window: NativeWindow, + width: u32, + height: u32, + content_scale: f32, + ) -> Result { + // `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins + // the build to GLES, to isolate whether the backend itself explains + // the frame time gap against Compose. `cfg!` rather than a runtime + // switch: there is no way to hand an env var to an already-launched + // Android process on this machine (see the feature's doc in + // Cargo.toml). + // + // Otherwise: **Vulkan where it has an adapter at all, GLES where it + // has none.** `Backends::PRIMARY` leaves `GL` out, so a device + // offering only a GLES adapter had no adapter at all and this + // function aborted the process -- this checkout's emulator, whose + // Vulkan ICD carries no adapter behind it (`NotFound { + // active_backends: VULKAN, no_adapter_backends: VULKAN, + // supported_backends: VULKAN | GL }`), and the crash loop in + // RUST.md's queue. + // + // The choice is made *before any surface exists*, with an instance + // that never touches the window, because **an Android window can be + // connected to one graphics API only**. One instance carrying both + // backends does not work: `create_surface` builds a raw surface per + // backend, Vulkan's `vkCreateAndroidSurfaceKHR` claims the window + // first, and the GLES surface made from the same window then fails + // `configure` as lost -- measured here as "In Surface::configure / + // Invalid surface" followed by an abort in + // `Surface::get_current_texture_view`, "Surface is not configured + // for presentation". + let mut backends = if cfg!(feature = "force-gles") { + Backends::GL + } else { + Backends::PRIMARY + }; + // No display handle: an Android surface is built from the + // `NativeWindow` below, and there is no platform connection to hand + // wgpu here the way there is on Wayland. + let mut instance = Instance::new(InstanceDescriptor { + backends, + ..InstanceDescriptor::new_without_display_handle() + }); + // A build already pinned to GLES has nowhere to fall back to. + if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() { + log::warn!( + "iris renderer: no {backends:?} adapter on this device, falling back to GLES" + ); + backends = Backends::GL; + instance = Instance::new(InstanceDescriptor { + backends, + ..InstanceDescriptor::new_without_display_handle() + }); + } + + // SAFETY: the `NativeWindow` outlives the surface built from it -- + // android-view drops the old renderer (and this surface with it) + // before handing over a new window, in `surface_changed` below. + let surface = instance + .create_surface(SurfaceTarget::from(AndroidWindowHandle { window })) + .map_err(|error| format!("Could not create the android surface: {error}"))?; + + // Every step from here to a live device reports rather than + // panics, for the one reason: on the phone these builds run on + // there is no `adb`, so an abort's message reaches a tombstone + // nobody can read and the launcher simply restarts the app -- + // which is what a crash loop with no explanation is. The caller + // (`android::view::IrisViewPeer::surface_changed`) puts this + // string on screen and in the app's own log ring instead. + let adapter = instance + .request_adapter(&RequestAdapterOptions { + power_preference: PowerPreference::default(), + compatible_surface: Some(&surface), + force_fallback_adapter: false, + ..Default::default() + }) + .block_on() + .map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?; + + // Same request as the winit backend's `UiRenderer::new` -- no + // binding-array features, see TEXTURES.md's "Recommended shape". + // `iris_core::device_limits()` is shared between the two backends; + // see its own doc for why it is not simply `Limits::default()`. + let (device, queue) = adapter + .request_device(&DeviceDescriptor { + required_limits: iris_core::device_limits(), + ..Default::default() + }) + .block_on() + .map_err(|error| { + format!( + "The adapter {} ({:?}) refused a device: {error}", + adapter.get_info().name, + adapter.get_info().backend, + ) + })?; + + // wgpu's default handler for an error raised outside `UiRenderNode:: + // new`'s own error scopes (i.e. everything past device creation -- + // an ordinary frame's `update`/`draw`) is `panic!`, unconditionally, + // with no caller able to intervene: the same mechanism that aborted + // the P0 bench APK once already, just at a different call site. Log + // and record instead of letting that default stand -- RUST.md's P0 + // box, "every wgpu uncaptured error ... it must never panic in + // release". + let wgpu_errors = iris_core::WgpuErrorLog::default(); + let wgpu_errors_for_handler = wgpu_errors.clone(); + device.on_uncaptured_error(std::sync::Arc::new(move |error| { + log::error!("iris wgpu uncaptured error: {error}"); + wgpu_errors_for_handler.record(error); + })); + + let info = adapter.get_info(); + let adapter_name = info.name.clone(); + let adapter_backend = info.backend; + // Either half can be empty -- the emulator's GLES adapter reports + // no `driver` and a long `driver_info`, so joining unconditionally + // left a leading space in every log line it appears in. + let adapter_driver = [info.driver.as_str(), info.driver_info.as_str()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join(" "); + // Say which adapter won, in the same words `default::render` uses, + // and at startup rather than only on the Diagnostics page: the + // backend alone (logged by `view.rs` when a renderer is built) does + // not separate the cases that matter. In this checkout's emulator + // `Gl` is the host's real GPU through virgl, and `Gl` under + // `EMU_GPU=software` is SwiftShader on the CPU; on a phone `Vulkan` + // is the device's own driver. A frame time or a screenshot with no + // record of which of those produced it cannot be read, and the + // fallback above is silent by design. + log::info!( + "iris renderer: {adapter_name} ({adapter_backend:?}, {adapter_driver}) on \ + {backends:?}" + ); + + let surface_caps = surface.get_capabilities(&adapter); + let surface_format = surface_caps + .formats + .iter() + .copied() + .find(|f| f.is_srgb()) + .unwrap_or(surface_caps.formats[0]); + + let config = SurfaceConfiguration { + usage: TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + // wgpu 30's new field; `Auto` is what every earlier version did. + color_space: SurfaceColorSpace::Auto, + width, + height, + present_mode: PresentMode::AutoVsync, + alpha_mode: surface_caps.alpha_modes[0], + desired_maximum_frame_latency: 2, + view_formats: vec![], + }; + surface.configure(&device, &config); + + let encoder = Self::create_encoder(&device); + // Physical pixels, matching the swapchain's own `width`/`height` + // exactly -- see `android::view::AndroidUiState::content_scale`'s + // field comment for why this is no longer divided into a separate + // logical space (that stopgap is what made text blurry, RUST.md's + // P0 box). `Len::dp` folds the density in at layout time instead, + // so nothing here needs to know it at all. + let window_size = iris_core::util::Vec2::new(width as f32, height as f32); + let ui = match UiRenderNode::new(&device, &queue, &config, window_size) { + Ok(ui) => ui, + Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), + }; + + Ok(Self { + surface, + device, + queue, + config, + encoder, + ui, + adapter_name, + adapter_backend, + adapter_driver, + wgpu_errors, + frame_count: 0, + content_scale, + }) + } + + /// The adapter identity plus every limit and downlevel flag + /// `create_bind_group_layout` validates a storage buffer or texture + /// binding against, followed by wgpu's own error text -- everything a + /// person reading this off a screenshot needs to tell "this adapter + /// lacks X" from "this is a bug in the layout." Named explicitly rather + /// than `{limits:?}`/`{flags:?}` wholesale, because `Limits` alone is + /// dozens of fields nobody asked for -- these are exactly the ones + /// `UiRenderNode::new`'s layouts (`rsc_layout`, `masks_layout`, + /// `primitive_layout`) can fail against, per `CreateBindGroupLayoutError` + /// (`wgpu-core::binding_model`) and its downlevel-flag checks + /// (`wgpu-core::device::resource`, `VERTEX_STORAGE` in particular -- + /// the one storage buffer here, `move_offsets`, that is visible to the + /// vertex stage). + fn diagnostic(adapter: &Adapter, wgpu_error: &str) -> String { + let info = adapter.get_info(); + let limits = adapter.limits(); + let downlevel = adapter.get_downlevel_capabilities(); + format!( + "iris could not start rendering. Copy this text and send it to Iris.\n\n\ + adapter: {name} ({backend:?}), driver: {driver} {driver_info}\n\ + limits: max_storage_buffers_per_shader_stage={max_storage_buffers} \ + max_sampled_textures_per_shader_stage={max_sampled_textures} \ + max_bind_groups={max_bind_groups} \ + max_bindings_per_bind_group={max_bindings} \ + max_storage_buffer_binding_size={max_storage_binding} \ + min_storage_buffer_offset_alignment={min_storage_align}\n\ + downlevel flags: {flags:?}\n\n\ + {wgpu_error}", + name = info.name, + backend = info.backend, + driver = info.driver, + driver_info = info.driver_info, + max_storage_buffers = limits.max_storage_buffers_per_shader_stage, + max_sampled_textures = limits.max_sampled_textures_per_shader_stage, + max_bind_groups = limits.max_bind_groups, + max_bindings = limits.max_bindings_per_bind_group, + max_storage_binding = limits.max_storage_buffer_binding_size, + min_storage_align = limits.min_storage_buffer_offset_alignment, + flags = downlevel.flags, + ) + } + + /// The Diagnostics page's whole report: adapter identity, font + /// resolution, the atlas's own view count, every uncaptured wgpu error + /// so far, and the frame report -- RUST.md's P0 box, "a named + /// `Diagnostics` control ... adapter info, limits, fonts found, atlas + /// format/pages, wgpu errors so far, frame report". One string rather + /// than a struct the caller formats, since the only consumer is a + /// plain `TextView` with a "copy this and send it to Iris" affordance, + /// the same shape `surface_changed`'s crash report already uses + /// (UI_RULES.md: a failure -- or here, a state worth reporting -- + /// carries enough to act on where it's shown). + pub fn diagnostics_report( + &self, + font: &iris_core::FontDiagnostics, + frame_report: &str, + ) -> String { + let errors = self.wgpu_errors.snapshot(); + let errors_text = if errors.is_empty() { + "none".to_string() + } else { + errors.join("\n ") + }; + format!( + "iris diagnostics. Copy this text and send it to Iris.\n\n\ + adapter: {name} ({backend:?}), driver: {driver}\n\ + content_scale: {content_scale}\n\ + atlas format: Rgba8Unorm, views live: {views}\n\ + fonts: {families_found} families found, default={default_family:?} \ + mono={default_mono_family:?}\n\ + fonts resolved: regular={regular:?} bold={bold:?} italic={italic:?} \ + mono={mono:?}\n\ + icon font: {icons:?}\n\ + wgpu errors since surface creation:\n {errors_text}\n\n\ + {frame_report}", + name = self.adapter_name, + backend = self.adapter_backend, + driver = self.adapter_driver, + content_scale = self.content_scale, + views = self.ui.view_count(), + families_found = font.families_found, + default_family = font.default_family, + default_mono_family = font.default_mono_family, + regular = font.regular_resolved, + bold = font.bold_resolved, + italic = font.italic_resolved, + mono = font.mono_resolved, + icons = font.icon_family, + ) + } + + fn create_encoder(device: &Device) -> CommandEncoder { + device.create_command_encoder(&CommandEncoderDescriptor { + label: Some("Render Encoder"), + }) + } + + /// Returns what changed this frame -- see `FrameDiagnostics`'s doc + /// comment for why two of its four fields describe the *previous* + /// frame rather than this one. `IrisViewPeer::render` logs this for + /// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`, + /// per RUST.md's P0 box ("the first input frame" investigation): the + /// glyph-wipe Iris reported happens on the first tap or scroll after a + /// fresh surface, so that is exactly the window a report needs to + /// cover, not an arbitrary slice of a long session. + pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics { + let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown(); + let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates(); + let stats = self.ui.update(&self.device, &self.queue, ui, render); + self.frame_count += 1; + FrameDiagnostics { + masks_resized: stats.masks_resized, + moves_resized: stats.moves_resized, + atlas_pages_grown_prev, + image_bind_group_creates_prev, + } + } + + /// Frames drawn on this surface so far -- see `frame_count`'s field + /// comment. + pub fn frame_count(&self) -> u64 { + self.frame_count + } + + /// Draws and presents one frame, returning the time spent in + /// `queue.submit` plus `present()` -- wherever a driver/GPU/compositor + /// wait would actually show up. The caller (`android::view::render`) + /// already times the whole frame from its own `redraw_to_submit` start; + /// subtracting this from that total is `redraw_to_submit` itself + /// (layout, text, primitive building, and this method's own render-pass + /// recording). RUST.md's I5 "Where iris's frame time goes" diagnosis, + /// added 2026-09-05 -- see `iris_core::FrameReport::record_split`'s own + /// doc for the caveat this shares: `present()` is not fenced against + /// the GPU actually finishing, so this is "how long the CPU was blocked + /// handing the frame off", not confirmed GPU time. + pub fn draw(&mut self) -> Duration { + let output = match self.surface.get_current_texture() { + CurrentSurfaceTexture::Success(texture) + | CurrentSurfaceTexture::Suboptimal(texture) => texture, + // wgpu 30 turned this Result into an enum; every arm here was an + // `Err` the previous `.unwrap()` panicked on, except `Occluded`, + // which is new. + other => panic!("no surface texture to draw into: {other:?}"), + }; + let view = output + .texture + .create_view(&TextureViewDescriptor::default()); + + let mut encoder = std::mem::replace(&mut self.encoder, Self::create_encoder(&self.device)); + { + let render_pass = &mut encoder.begin_render_pass(&RenderPassDescriptor { + color_attachments: &[Some(RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: Operations { + load: LoadOp::Clear(CLEAR_COLOR), + store: StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + self.ui.draw(render_pass); + } + + let submit_start = Instant::now(); + self.queue.submit(std::iter::once(encoder.finish())); + self.queue.present(output); + submit_start.elapsed() + } + + /// Physical pixels -- the unit layout and hit-testing use, matching + /// the window uniform's own units. See + /// `android::view::AndroidUiState::content_scale`'s field comment. + pub fn size(&self) -> iris_core::util::Vec2 { + iris_core::util::Vec2::new(self.config.width as f32, self.config.height as f32) + } + + /// Reconfigures the surface and rewrites the window uniform for a new + /// physical size -- deliberately the *only* two things this does. + /// `device`, `ui`'s atlas, buffers and bind groups are untouched, so a + /// call here (as opposed to a fresh `AndroidRenderer::new`) never + /// invalidates a glyph the CPU-side cache already placed in the atlas. + /// See `android::view::IrisViewPeer::surface_changed`'s doc comment for + /// why that distinction matters -- it is what keeps text on screen + /// across an IME resize. + pub fn resize(&mut self, width: u32, height: u32) { + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + let size = iris_core::util::Vec2::new(width as f32, height as f32); + self.ui.resize(size, &self.queue); + } +} + +/// `Tasks`' redraw handle on Android: a background task finishes on the +/// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so +/// asking for a frame means attaching first. The global ref is what +/// survives past the JNI call that handed the `View` to us. +/// +/// **Goes through `View::post_delayed`, not `post_frame_callback` +/// directly** -- found the hard way (RUST.md's I5 Android integration): +/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`, +/// which throws `IllegalStateException` unless the *calling* thread already +/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio +/// worker thread, even freshly attached to the JVM, has none -- the crash +/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`, +/// aborting the process on the second `redraw.request_redraw()` any +/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)` +/// is the ordinary Android answer to "queue work onto a View's own UI +/// thread from any thread" and needs no Looper of its own; `delayed_callback` +/// below is what that Runnable resolves to on the UI thread, where a real +/// `post_frame_callback` is safe again. +pub struct AndroidRedrawHandle { + vm: JavaVM, + view: GlobalRef, +} + +impl AndroidRedrawHandle { + pub fn new(vm: JavaVM, view: GlobalRef) -> Self { + Self { vm, view } + } +} + +impl RequestRedraw for AndroidRedrawHandle { + fn request_redraw(&self) { + let Ok(mut env) = self.vm.attach_current_thread() else { + return; + }; + let local = env.new_local_ref(&self.view).unwrap(); + View(local).post_delayed(&mut env, 0); + } +} diff --git a/src/android/view.rs b/src/android/view.rs new file mode 100644 index 0000000..07e3bad --- /dev/null +++ b/src/android/view.rs @@ -0,0 +1,1080 @@ +use crate::prelude::*; +use crate::task::RequestRedraw; +use accesskit_android::Adapter as AccessAdapter; +use android_view::{ + AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context, + InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer, + jni::{ + JNIEnv, JavaVM, + objects::{GlobalRef, JValue}, + sys::jint, + }, + ndk::event::{Axis, Keycode, MotionAction}, +}; +// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the +// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified +// glob import shadows the language prelude -- `default/mod.rs` has the same +// explicit import for the same reason. +use std::{ + cell::RefCell, + marker::{PhantomData, Sized}, + rc::Rc, + sync::Arc, + time::Instant, +}; + +use super::{ + access::{AndroidAccessSource, NullActionHandler, raise_if_enabled}, + insets::{Insets, Shared}, + render::{AndroidRedrawHandle, AndroidRenderer}, +}; + +/// The android-view analogue of `default::DefaultUiState`. `renderer` is an +/// `Option` because a `SurfaceView`'s surface does not outlive backgrounding +/// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can +/// happen any number of times over the life of one `IrisViewPeer`. +/// How many frames after each `surface_changed` `render()` logs a full +/// diagnostic line for -- see the log site's own comment. +const DIAGNOSTIC_FRAMES: u64 = 10; + +pub struct AndroidUiState { + pub root: Option, + pub renderer: Option, + pub focus: Option>, + pub cursor: CursorState, + pub last_click: Instant, + /// The IME preedit's previous length, in `char`s -- the same + /// re-send-the-whole-composition bookkeeping `default::DefaultUiState` + /// keeps for winit's `Ime::Preedit`, since android-view's + /// `setComposingText` has the identical shape (see `android/ime.rs`). + pub compose_len: usize, + /// Set by `attr::FocusHost::focus_gained` when a `TextEdit` is focused; + /// consumed by the touch handler after the sensor pass finishes, since + /// showing the keyboard is a JNI call and `focus_gained` runs deep + /// inside the platform-agnostic sensor dispatch with no `CallbackCtx` + /// in reach. + pub pending_show_keyboard: bool, + /// A URL a tapped link asked the platform to open, for the same + /// reason `pending_show_keyboard` is a flag rather than a call -- + /// see `android/platform.rs`. + pub pending_open_url: Option, + /// Window insets, filled in from outside the normal `ViewPeer` callback + /// path -- see `android/insets.rs` for why they need a registry of + /// their own. + shared: Rc>, + /// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by + /// the `AccessibilityNodeProvider` impl below; see `android/access.rs` + /// for the abort mitigation every `raise` on it goes through. + pub access_adapter: AccessAdapter, + /// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc + /// comment. + pub access: AccessTree, + /// iris's own frame-time report (RUST.md's I5 box, "Measurements + /// taken" (b)) -- `render()` below records into it once per frame, + /// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own + /// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc. + pub frame_report: FrameReport, + /// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels + /// per dp on this device, read once at view construction and carried + /// on `UiRenderState::density` (`render.set_density`, `new_peer`) from + /// then on -- every `Len::dp` in the widget tree resolves against it at + /// layout time (`Len::dp`'s field doc, IRIS_TODO.md's + /// "density-independent length unit" item, 2026-09-06). + /// + /// **Everything else in this module is physical pixels, matching the + /// real wgpu surface/swapchain resolution** -- window size, touch + /// coordinates, insets. That is a correction from an earlier version + /// of this comment, which had `window_size`/`surface_changed`'s + /// `UiRenderState::resize` call divide by `content_scale` into a + /// *logical* coordinate space instead, as a global stopgap for + /// RUST.md's P0 box's phone report ("text is far too small"). That + /// stopgap fixed the size but not the *sharpness*: dividing to logical + /// units meant a `16.0`-sized glyph rasterised at 16 physical px and + /// then implicitly upscaled ~3x by the NDC mapping onto the real + /// physical framebuffer -- the exact "blurry ... glyphs drawn at + /// logical size and stretched by the scale" Iris reported next. + /// Resolving `dp` at layout time replaces it: a widget author writes + /// `dp(16)` for a size that should look the same physical size on any + /// density, and everything downstream (layout, hit-testing, the window + /// uniform, and the font size handed to the text shaper) works in the + /// display's own physical pixels throughout, so nothing is + /// rasterised at one resolution and displayed at another. + pub content_scale: f32, + /// The last insets `render()` saw -- compared each frame so + /// `AndroidAppState::on_insets_changed` fires only when they actually + /// change (once at startup for the status bar, again if the device + /// rotates), not every frame. + last_insets: Insets, +} + +impl AndroidUiState { + fn new(shared: Rc>, content_scale: f32) -> Self { + Self { + root: None, + renderer: None, + focus: None, + cursor: Default::default(), + last_click: Instant::now(), + compose_len: 0, + pending_show_keyboard: false, + pending_open_url: None, + shared, + access_adapter: Default::default(), + access: AccessTree::new(), + frame_report: FrameReport::new(), + content_scale, + last_insets: Insets::default(), + } + } + + pub fn insets(&self) -> Insets { + self.shared.borrow().insets + } + + /// The insets state as one line for a diagnostics pane, including how + /// many times the platform has delivered any -- see + /// `insets::Shared::updates` for why the count is the load-bearing + /// part. `dispatches=0` says the listener has never run and the + /// numbers beside it are defaults rather than measurements, which is + /// the distinction a screenshot otherwise cannot make (UI_RULES.md, + /// "design the unknown state first"). + pub fn insets_report(&self) -> String { + let shared = self.shared.borrow(); + let i = shared.insets; + if shared.updates == 0 { + return "insets: dispatches=0 -- the platform has never called \ + onApplyWindowInsets, so nothing below was measured" + .to_string(); + } + format!( + "insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \ + ime_visible={}", + shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible, + ) + } +} + +impl HasRoot for AndroidUiState { + fn set_root(&mut self, root: StrongWidget) { + self.root = Some(root); + } +} + +pub trait HasAndroidUiState: Sized + 'static { + fn android_state(&self) -> &AndroidUiState; + fn android_state_mut(&mut self) -> &mut AndroidUiState; +} + +pub trait AndroidAppState: HasAndroidUiState { + fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self; + /// The system back gesture/button. `true` means handled -- nothing + /// further happens; `false` lets the activity finish as it would with + /// no view at all. The default declines, since most screens have + /// nothing to intercept it for. + #[allow(unused_variables)] + fn back_pressed(&mut self, rsc: &mut AndroidRsc, render: &mut UiRenderState) -> bool { + false + } + /// Called once, right after `new`, with a fresh `JavaVM` handle and a + /// global reference to this app's own `View` -- for a caller that + /// needs to call into Java itself beyond what a [`RequestRedraw`] + /// handle already covers (P0's bench build calling + /// `BatteryManager`/`ClipboardManager` through the view's `Context`, + /// docs/RUST.md). Not folded into `new` itself: most implementors need + /// nothing here, and `new`'s job is building the widget tree, not + /// holding a platform handle -- the default does nothing. `vm`/`view` + /// are independent handles from the ones `new_peer` keeps for its own + /// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so + /// storing them has no effect on that mechanism. + #[allow(unused_variables)] + fn platform_ready(&mut self, rsc: &mut AndroidRsc, vm: JavaVM, view: GlobalRef) {} + /// Called from `render()` whenever `AndroidUiState::insets()` differs + /// from what it was last frame -- once at startup for the status bar + /// (RUST.md's P0 box: "the status-bar inset is not applied" reported + /// the two top buttons sitting under it, because nothing read `.top` + /// at all), and again on a rotation or the keyboard opening/closing. + /// `insets` is in the same physical-pixel units everything else in the + /// tree now uses (`AndroidUiState::content_scale`'s field comment), so + /// a widget can add it to a layout size directly -- `dp(...) + + /// abs(insets.top)` if the widget wants a density-independent size + /// plus the system bar's own (already-physical) height. The default + /// does nothing -- most screens have no chrome that sits under a + /// system bar. + #[allow(unused_variables)] + fn on_insets_changed(&mut self, rsc: &mut AndroidRsc, insets: WindowInsets) {} +} + +/// `insets::Insets` as `f32`, for the widget-facing callback above -- a +/// distinct type from `insets::Insets` so a caller of `on_insets_changed` +/// is not coupled to that module's own (`i32`, JNI-shaped) representation. +/// Both are physical pixels; this used to divide by `content_scale` into a +/// separate *logical* unit (hence the old name, `LogicalInsets`), back when +/// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s +/// field comment for why that stopgap is gone. +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct WindowInsets { + pub left: f32, + pub top: f32, + pub right: f32, + pub bottom: f32, + /// How much of the window the keyboard covers, in physical pixels -- + /// what a layout pads by. See `insets::Insets::ime_visible` for why + /// "is the keyboard up" is a separate field rather than this one + /// compared against zero. + pub ime_bottom: f32, + pub ime_visible: bool, +} + +impl WindowInsets { + fn from_physical(insets: Insets) -> Self { + Self { + left: insets.left as f32, + top: insets.top as f32, + right: insets.right as f32, + bottom: insets.bottom as f32, + ime_bottom: insets.ime_bottom as f32, + ime_visible: insets.ime_visible, + } + } +} + +/// The android-view analogue of `default::DefaultRsc` -- identical in +/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState` +/// mention winit. Kept as a separate type rather than shared code because +/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold +/// their harness state differently (see RUST.md's I2). +pub struct AndroidRsc { + pub ui: UiData, + pub events: EventManager, + pub tasks: Tasks, + pub state: WidgetState, + _state: PhantomData, +} + +impl AndroidRsc { + pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { + self.state.add(id.id(), data) + } +} + +impl UiRsc for AndroidRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.state.remove(id); + } +} + +impl HasState for AndroidRsc { + type State = State; +} + +impl HasEvents for AndroidRsc { + fn events(&self) -> &EventManager { + &self.events + } + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for AndroidRsc { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for AndroidRsc { + fn widget_state(&self) -> &WidgetState { + &self.state + } + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.state + } +} + +/// The `ViewPeer` android-view dispatches every callback to. One per +/// `RustView` instance; `new_peer` (below) builds it and hands the id to +/// Java the same way android-view's own demo does. +pub struct IrisViewPeer { + pub(super) rsc: AndroidRsc, + pub(super) render: UiRenderState, + pub(super) state: State, + task_recv: TaskMsgReceiver>, + /// Anchored on the first `MotionEvent` this view receives and never + /// re-anchored after -- how `on_touch_event` dates every touch sample. + /// Its path out is the peer's own drop: it holds nothing but three + /// numbers and is meaningless to any other view. + input_clock: Option, +} + +impl>> std::ops::Index for AndroidRsc { + type Output = I::Output; + + fn index(&self, index: I) -> &Self::Output { + index.get(self) + } +} + +impl>> std::ops::IndexMut for AndroidRsc { + fn index_mut(&mut self, index: I) -> &mut Self::Output { + index.get_mut(self) + } +} + +impl IrisViewPeer { + fn drain_tasks(&mut self) { + while let Ok(update) = self.task_recv.try_recv() { + update(&mut self.state, &mut self.rsc); + } + } + + /// One pointer sample through the sensors, plus the platform calls a + /// handler can only ask for by raising a flag. Split out of + /// [`Self::after_input`] because a batched `MotionEvent` carries + /// several samples that all belong to the same *frame* + /// (`on_touch_event`): each one is a real input frame the widgets must + /// see, but only the last one ends the frame and asks for a redraw. + fn run_input_frame(&mut self, ctx: &mut CallbackCtx) { + let window_size = self.window_size(); + let ui_state = self.state.android_state_mut(); + let cursor = ui_state.cursor.clone(); + let old_focus = ui_state.focus; + self.render + .run_sensors(&mut self.rsc, &mut self.state, cursor, window_size); + + let ui_state = self.state.android_state_mut(); + if old_focus != ui_state.focus + && let Some(old) = old_focus + { + old.edit(&mut self.rsc).deselect(); + } + if std::mem::take(&mut ui_state.pending_show_keyboard) { + show_soft_input(&mut ctx.env, &ctx.view); + } + if let Some(url) = ui_state.pending_open_url.take() { + super::platform::open_url(&mut ctx.env, &ctx.view, &url); + } + } + + /// Common tail for every callback that might have changed the cursor, + /// the text focus, or the widget tree: run the sensors that touch + /// input feeds, then ask for a frame if the result needs drawing. + /// Mirrors `default::DefaultApp::window_event`'s tail, split across + /// android-view's several entry points instead of winit's one. + pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { + self.run_input_frame(ctx); + + // RUST.md's P0 box, "doesn't enter it until I hit space, and also + // doesn't move cursor forward": Gboard needs `updateSelection` + // after every edit to keep its own model of the field in sync, or + // it holds keystrokes back rather than trusting a screen it + // believes is stale. See `update_ime_selection`'s own doc. + self.update_ime_selection(ctx); + + let ui_state = self.state.android_state_mut(); + ui_state.cursor.end_frame(); + if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) { + ctx.view.post_frame_callback(&mut ctx.env); + } + } + + fn window_size(&self) -> Vec2 { + let ui_state = self.state.android_state(); + match &ui_state.renderer { + Some(r) => r.size(), + None => Vec2::ZERO, + } + } + + /// The `log::debug!` calls here are a live diagnostic for a still-open + /// finding (RUST.md's I2): layout runs and reports the right pixel + /// region for the root (confirmed via `window_region`, logged below), + /// and the clear colour reaches the screen (confirmed by swapping it to + /// magenta and screenshotting), but no primitive ever appears on top of + /// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave + /// these in until that is root-caused; removing them loses the exact + /// evidence a `logcat` capture needs to reproduce the state. Gated on + /// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's + /// review, D1): unconditional, they were two `debug!` lines every + /// rendered frame, and `client_core::log_ring`'s `RingLogger` records + /// every level the app's already-`Debug` install lets through + /// regardless of target, so they filled the whole ring in under ten + /// seconds at 120Hz and left `Copy report` nothing else to show. + fn render(&mut self, ctx: &mut CallbackCtx) { + if self.state.android_state().renderer.is_none() { + return; + } + // See `AndroidAppState::on_insets_changed`'s doc comment: fires + // exactly when insets actually differ from last frame, not every + // frame -- most frames this is one `Insets` equality check against + // a `Copy` struct. Done before `ui_state` is bound below, since + // `on_insets_changed` needs `&mut self.state`/`&mut self.rsc` both. + let ui_state = self.state.android_state(); + let current_insets = ui_state.insets(); + if current_insets != ui_state.last_insets { + let physical = WindowInsets::from_physical(current_insets); + // One line per real insets change. Iris's phone is the only + // place several of these bugs reproduce and `adb logcat` is + // the only instrument there (this-machine-android: system + // tracing is broken on that device), so the numbers a layout + // is actually fed have to reach the log -- "the composer + // floats at launch" is unanswerable from a screenshot alone. + log::info!( + "iris insets: left={} top={} right={} bottom={} ime_bottom={} \ + ime_visible={} window={:?}", + physical.left, + physical.top, + physical.right, + physical.bottom, + physical.ime_bottom, + physical.ime_visible, + self.window_size(), + ); + self.state.android_state_mut().last_insets = current_insets; + self.state.on_insets_changed(&mut self.rsc, physical); + } + + // Gated the same way `iris::frame`'s own line is (docs/RUST.md's + // "Phone logging" review, D1): a bare `log::debug!` reaches + // `client_core::log_ring`'s ring regardless of level, since + // `RingLogger::enabled` is unconditionally `true` and the app + // installs at `LevelFilter::Debug` -- two of these a rendered + // frame filled the 2000-line ring in under ten seconds at 120Hz, + // leaving `Copy report` nothing but frame spam. See + // `iris::diagnostics`'s module doc. + if crate::diagnostics::trace_enabled() { + let ui_state = self.state.android_state(); + log::debug!( + target: "iris::frame", + "render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}", + ui_state.root.is_some(), + self.rsc.widgets().len(), + self.render.active_widgets(), + ui_state + .root + .as_ref() + .and_then(|r| self.render.window_region(r, &self.rsc)), + self.window_size(), + ); + } + // iris's own frame-time report (RUST.md's I5 box, "Measurements + // taken" (b)): started here, at the same point a redraw request + // fires, and stopped after `renderer.draw()`'s `queue.submit` + + // `present()` -- the span Compose's render report and `gfxinfo` + // both count. See `iris_core::FrameReport`'s own doc for exactly + // what this does and does not measure. + let frame_start = Instant::now(); + // Anything moving on its own -- today a `LazySpan` coasting through a + // fling -- is advanced here, before the draw, and asks for the + // next frame at the end of this one. See + // `UiData::tick_animations`; `default/mod.rs`'s + // `RedrawRequested` arm is the same two lines for winit. + let animating = self.rsc.ui.tick_animations(frame_start); + let ui_state = self.state.android_state_mut(); + self.render.update(&ui_state.root, &mut self.rsc); + let ui_state = self.state.android_state_mut(); + let Some(renderer) = &mut ui_state.renderer else { + return; + }; + let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render); + // First `DIAGNOSTIC_FRAMES` frames after each `surface_changed` + // only -- RUST.md's P0 box, "the first input frame" investigation: + // the glyph-wipe Iris reported happens on the first tap or scroll + // after a fresh surface, so a report from that window is what + // would show whether an atlas grow, a masks/move_offsets resize, or + // a fresh wgpu error coincided with it. `frame_count()` was just + // incremented inside `update()`, so `<=` counts frame 1 through + // `DIAGNOSTIC_FRAMES` inclusive. + if renderer.frame_count() <= DIAGNOSTIC_FRAMES { + log::info!( + "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ + atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}", + renderer.frame_count(), + frame_diagnostics.masks_resized, + frame_diagnostics.moves_resized, + frame_diagnostics.atlas_pages_grown_prev, + frame_diagnostics.image_bind_group_creates_prev, + renderer.wgpu_errors.snapshot().len(), + ); + } + let submit_to_present = renderer.draw(); + self.state + .android_state_mut() + .frame_report + .record_split(frame_start.elapsed(), submit_to_present); + crate::diagnostics::log_frame(&self.render, frame_start, submit_to_present, animating); + // A frame callback is one-shot, so an animation that wants + // another frame has to say so every frame -- unlike `after_input`, + // which only has to ask when input dirtied something. + if animating { + ctx.view.post_frame_callback(&mut ctx.env); + } + if crate::diagnostics::trace_enabled() { + let ui_state = self.state.android_state(); + log::debug!( + target: "iris::frame", + "render(): after update active={} root_px={:?}", + self.render.active_widgets(), + ui_state + .root + .as_ref() + .and_then(|r| self.render.window_region(r, &self.rsc)), + ); + } + + // I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues + // anything to raise -- when the named set actually changed this + // frame; see `AccessTree`'s doc comment. Deferred rather than + // raised inline so it runs after this callback releases whatever + // it's holding, matching android-view's own demo and `raise`'s own + // contract. + let ui_state = self.state.android_state_mut(); + if let Some(tree_update) = + ui_state + .access + .update(self.rsc.widgets(), &self.render, &self.rsc) + { + let ui_state = self.state.android_state_mut(); + if let Some(events) = ui_state.access_adapter.update_if_active(|| tree_update) { + ctx.push_dynamic_deferred_callback(move |env, view| { + raise_if_enabled(env, view, events); + }); + } + } + } +} + +fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) { + let imm = view.input_method_manager(env); + imm.show_soft_input(env, view, 0); +} + +/// Replaces the activity's content with a plain, selectable, scrollable +/// text view holding `report` -- the on-screen half of `surface_changed`'s +/// renderer-failure path (UI_RULES.md: "a failure is reported where it +/// happened, and says what to do next," here "copy this and send it"). +/// Goes through an ordinary instance method on the Java side +/// (`IrisView.showRendererError`) rather than a new `native` method: this +/// call is Rust reaching *into* Java, the opposite direction from every +/// `native fn` android-view/`IrisView` declare, and an ordinary virtual +/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the +/// same way any other JNI method call here does. Silently does nothing on +/// any JNI failure -- there is no more-fallback screen to fall back to, +/// and the `log::error!` in `surface_changed` already reached logcat +/// first. +fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) { + let Ok(message) = env.new_string(report) else { + return; + }; + let _ = env.call_method( + &view.0, + "showRendererError", + "(Ljava/lang/String;)V", + &[JValue::Object(message.as_ref())], + ); +} + +impl ViewPeer for IrisViewPeer { + fn on_key_down<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + key_code: Keycode, + event: &KeyEvent<'local>, + ) -> bool { + self.drain_tasks(); + // With no `OnBackPressedCallback` registered on the Java side, the + // system still delivers the back gesture as a synthetic + // `KEYCODE_BACK` through this same path -- the legacy behaviour + // every view-based app gets by default, and enough for "the back + // gesture as an event" without a second JNI registry. See + // `android/insets.rs`'s doc comment for why insets could not take + // the same shortcut. + if key_code == Keycode::Back { + let handled = self.state.back_pressed(&mut self.rsc, &mut self.render); + if handled { + self.after_input(ctx); + } + return handled; + } + let handled = super::input::on_key( + &mut self.rsc, + &mut self.state, + &mut ctx.env, + key_code, + event, + ); + if handled { + self.after_input(ctx); + } + handled + } + + fn on_touch_event<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + event: &MotionEvent<'local>, + ) -> bool { + self.drain_tasks(); + let action = event.action_masked(&mut ctx.env); + // Device (physical) pixels, same space layout now uses throughout + // -- see `AndroidUiState::content_scale`'s field comment. + let x = event.x(&mut ctx.env); + let y = event.y(&mut ctx.env); + // The event's own clock, converted through one anchor taken on the + // first touch this view ever sees. Android reports sample times in + // the `SystemClock.uptimeMillis()` base, which is the same + // `CLOCK_MONOTONIC` an `Instant` reads, so a single + // `(Instant, nanos)` pair converts every later sample exactly. + // Anchoring **once** rather than per event is what keeps the times + // ordered, and anchoring on the first event's *oldest* sample + // rather than on its own time is what keeps that event's batch + // from collapsing onto one instant -- `sense::PointerClock`'s doc + // has both, and owns the arithmetic so it can be unit-tested off a + // device (`sense_tests.rs`). See `CursorState::time`. + let event_time = event.event_time_nanos(&mut ctx.env); + if self.input_clock.is_none() { + let history = event.history_size(&mut ctx.env); + let oldest = if history > 0 { + event.historical_event_time_nanos(&mut ctx.env, 0) + } else { + event_time + }; + self.input_clock = Some(PointerClock::anchored(Instant::now(), event_time, oldest)); + } + let mut clock = self.input_clock.expect("anchored just above"); + // `iris::input`'s own doc (`sense::log_input_event`): collected + // only when tracing is on, since this is otherwise a `Vec` per + // `MotionEvent` for a line nobody is reading -- the JNI reads + // themselves (`historical_axis`/`historical_event_time_nanos` + // below) already happen unconditionally, for the replay this + // function does regardless of tracing. + let trace_input = crate::diagnostics::trace_enabled(); + let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new(); + + // **Historical samples first.** A flick on a 120Hz screen is + // delivered as one or two `MotionEvent`s with the intermediate + // positions batched inside them, so reading only `x()`/`y()` threw + // away every sample but the last: the velocity tracker saw one + // `Pan` for the whole gesture, `VelocityTracker::velocity` answers + // 0.0 below two samples, and the release therefore flung at zero -- + // Iris's phone, twice ("fling still doesn't work"), while a + // `ui-trace` swipe, which is many evenly-spaced events, flung fine. + // Replayed one at a time through the sensors rather than summarised, + // so the arbiter, the tracker and any other sensor all see the same + // motion the finger actually made; only the last sample ends the + // frame (`after_input`). + if matches!(action, MotionAction::Move) { + let history = event.history_size(&mut ctx.env); + // Android documents the historical samples as oldest first and + // the event's own sample as the newest of the batch; everything + // downstream (`VelocityTracker`, `DragArbiter`'s long-press + // clock) assumes it, so say so here rather than at each reader. + // `PointerClock::sample` is what asserts it, and it carries the + // last sample seen *across* events, so the first sample of + // every event is checked against the previous event's last one + // rather than against the anchor. + for pos in 0..history { + let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos); + let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos); + let ht = event.historical_event_time_nanos(&mut ctx.env, pos); + let sample_at = clock.sample(ht); + if trace_input { + historical_ms.push((clock.ms_since_anchor(ht), hx, hy)); + } + let ui_state = self.state.android_state_mut(); + ui_state.cursor.pos = vec2(hx, hy); + ui_state.cursor.time = sample_at; + self.run_input_frame(ctx); + } + } + + let event_at = clock.sample(event_time); + let event_ms = clock.ms_since_anchor(event_time); + self.input_clock = Some(clock); + let ui_state = self.state.android_state_mut(); + ui_state.cursor.time = event_at; + match action { + MotionAction::Down => { + ui_state.cursor.pos = vec2(x, y); + ui_state.cursor.exists = true; + ui_state.cursor.buttons.left.update(true); + } + MotionAction::Move => { + ui_state.cursor.pos = vec2(x, y); + } + MotionAction::Up => { + ui_state.cursor.pos = vec2(x, y); + ui_state.cursor.buttons.left.update(false); + } + // A cancel ends the press -- a release that never arrives + // leaves whichever widget took pointer capture holding it + // forever -- but it is **not** a release, and saying so is + // `CursorState::cancelled`. It used to take the `Up` arm, so + // the system's own swipe up from the bottom edge to leave the + // app (moves, then `ACTION_CANCEL`) reached iris as a flick + // released at speed, and the transcript flung while the app + // was in the background: Iris's 2026-09-08 "leaving and + // reopening the app also randomly moved the vertical scroll". + MotionAction::Cancel => { + ui_state.cursor.pos = vec2(x, y); + ui_state.cursor.buttons.left.update(false); + ui_state.cursor.cancelled = true; + } + _ => return false, + } + if trace_input { + let action_word = match action { + MotionAction::Down => "down", + MotionAction::Move => "move", + MotionAction::Up => "up", + MotionAction::Cancel => "cancel", + _ => "other", + }; + crate::sense::log_input_event(action_word, x, y, event_ms, &historical_ms); + } + self.after_input(ctx); + true + } + + fn on_focus_changed<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + gain_focus: bool, + _direction: i32, + _previously_focused_rect: Option<&Rect<'local>>, + ) { + self.drain_tasks(); + if !gain_focus { + let ui_state = self.state.android_state_mut(); + if let Some(focus) = ui_state.focus.take() { + focus.edit(&mut self.rsc).deselect(); + } + } + self.after_input(ctx); + } + + fn on_attached_to_window(&mut self, _ctx: &mut CallbackCtx) { + self.drain_tasks(); + } + + fn surface_changed<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + holder: &android_view::SurfaceHolder<'local>, + _format: i32, + width: i32, + height: i32, + ) { + self.drain_tasks(); + // The layout engine's own notion of the canvas size is separate + // from the wgpu surface's -- winit's backend sets it from + // `WindowEvent::Resized`, and there is no equivalent automatic + // trigger here, so this is the one place android-view's surface + // size has to be told to `UiRenderState` too. Missing this drew + // nothing but the clear colour: the widget tree laid out against + // whatever size `UiRenderState::new` starts at instead of the + // surface's real one. + // + // **Physical pixels, matching `AndroidRenderer`'s own + // `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s + // field comment. This call sets `UiRenderState::output_size`, which + // every `rel`/`rest` length resolves against and every `abs` + // pixel-region compares to directly; a `dp(56)` height now folds + // in the density at `Len::apply_rest` time instead of this call + // dividing the whole window into a separate logical space, which + // is what used to make every `abs`-unit size (a fixed `.height(56)` + // in particular) mean something different from a `rest`-based one. + self.render.resize((width as f32, height as f32)); + + // **Reuse the existing renderer (device, atlas, buffers, bind + // groups) when one is already live -- only reconfigure the + // surface.** `surfaceChanged` fires on *every* size or format + // change, not only on a genuinely new `Surface`/window: showing + // the IME under `adjustResize` resizes the same `SurfaceView` and + // is reported through this exact callback. Rebuilding the whole + // `AndroidRenderer` here used to mean a fresh `UiRenderNode::new` + // -- a brand-new, empty glyph atlas and fresh GPU buffers -- while + // `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the + // atlas coordinates it had already handed out against the *old* + // atlas. Every glyph then drew from a UV rectangle that pointed + // into a texture that had just been recreated empty, so text + // vanished on the first keyboard open while rects (which never go + // through the atlas) kept drawing -- exactly the "rectangles stay, + // glyphs disappear" Iris reported. Confirmed by reading this path + // end to end (no fresh-atlas rebuild anywhere in `resize()` below, + // only in `AndroidRenderer::new`) before changing anything, per + // AGENTS.md's "verify before finishing". + // + // `AndroidRenderer::resize` only reconfigures the wgpu surface and + // rewrites the window uniform -- device, atlas, buffers and bind + // groups are untouched, so the glyph cache's coordinates stay + // valid. A genuinely new surface (after `surface_destroyed`, e.g. + // backgrounding) still goes through `AndroidRenderer::new` below, + // since `renderer` is `None` in that case. + let already_live = self.state.android_state().renderer.is_some(); + log::info!( + "iris surface: surface_changed {width}x{height} already_live={already_live} \ + glyphs_cached={} atlas_pages={}", + self.rsc.ui.text.atlas.glyph_count(), + self.rsc.ui.text.atlas.page_count(), + ); + if already_live { + let ui_state = self.state.android_state_mut(); + ui_state + .renderer + .as_mut() + .expect("checked Some above") + .resize(width as u32, height as u32); + self.render(ctx); + return; + } + + let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env); + // `AndroidRenderer::new` used to panic here through wgpu's own + // default uncaptured-error handler on a bind-group-layout + // validation failure -- exactly what aborted the P0 bench APK on + // Iris's phone with the message truncated to "wgpu error: + // Validation Error" and nothing else recoverable from the crash + // report (RUST.md's P0 box, "iris bench crash on the phone, + // 2026-09-06"). It now returns the full diagnostic instead; this is + // the one place in the app that can turn it into something a + // person can read, since `ctx.view`/`ctx.env` (needed to reach the + // Java side) are only in scope inside a `ViewPeer` callback. + // + // `content_scale` reaches `AndroidRenderer` only for the + // Diagnostics page's report text now -- window size and the + // shader's window uniform are physical pixels throughout (see the + // `resize` call above), not divided by it. + let content_scale = self.state.android_state().content_scale; + match AndroidRenderer::new(window, width as u32, height as u32, content_scale) { + Ok(renderer) => { + // A genuinely new renderer means a genuinely new GPU + // device, holding none of the textures the old one did -- + // while the CPU side of them (`UiData::textures`, and the + // glyph atlas built on it) lives on `self.rsc` and + // survives. So every slot has to be uploaded again, and + // `Textures::reupload` queues exactly that, in slot order. + // + // It replaces clearing them, which threw away the *slot + // numbering* as well as the pixels: every `TextureHandle` + // a live widget still held -- one per icon or image on + // screen, and one per folded card at the time -- then + // named a slot nothing recognised, and the next frame + // panicked in `image_bind_group` ("texture slot 89 is not + // a live standalone image: None"). Re-uploading also keeps + // the glyph atlas, so an app switch no longer re-rasterises + // every glyph on screen. This only runs on the branch that + // actually builds a new renderer, never on the reuse + // branch above, where the textures are still on the device + // that holds them. + log::info!( + "iris surface: new renderer built ({:?}), re-uploading textures: \ + glyphs={} pages={}", + renderer.adapter_backend, + self.rsc.ui.text.atlas.glyph_count(), + self.rsc.ui.text.atlas.page_count(), + ); + self.rsc.ui.textures.reupload(); + self.state.android_state_mut().renderer = Some(renderer); + self.render(ctx); + } + Err(report) => { + // One line for logcat (UI_RULES.md: "the full text for + // whoever can read the log" lives here), the multi-line + // original on screen -- `show_renderer_error` below. + log::error!("iris renderer init failed: {}", report.replace('\n', " | ")); + // Deferred, not called directly: `Activity::setContentView` + // tears the old view hierarchy down synchronously, which + // fires `IrisView`'s own `onFocusChanged` before + // `setContentView` returns -- straight back into this same + // `IrisViewPeer` through `on_focus_changed` while + // `with_peer` (android-view's dispatch, `view.rs` upstream) + // still holds this peer's `RefCell` borrow for the + // `surface_changed` call in progress. Found by inducing a + // validation error and hitting `RefCell already borrowed` + // at exactly that reentrant call (RUST.md's P0 box). + // `push_dynamic_deferred_callback` runs after `with_peer` + // drops the borrow, which is what every other callback in + // this file that reaches into Java already relies on + // (`raise_if_enabled`, above). + ctx.push_dynamic_deferred_callback(move |env, view| { + show_renderer_error(env, view, &report); + }); + } + } + } + + fn surface_destroyed<'local>( + &mut self, + _ctx: &mut CallbackCtx<'local>, + _holder: &android_view::SurfaceHolder<'local>, + ) { + log::info!( + "iris surface: surface_destroyed, tearing the renderer down \ + (glyphs_cached={} atlas_pages={})", + self.rsc.ui.text.atlas.glyph_count(), + self.rsc.ui.text.atlas.page_count(), + ); + self.state.android_state_mut().renderer = None; + } + + fn do_frame(&mut self, ctx: &mut CallbackCtx, _frame_time_nanos: i64) { + self.drain_tasks(); + self.render(ctx); + } + + /// Where `AndroidRedrawHandle::request_redraw` (`android/render.rs`) + /// actually lands: `View.postDelayed`'s Runnable resolves to this, on + /// the UI thread, which is what makes it safe to call from a background + /// task's own thread when `post_frame_callback`'s `Choreographer` + /// requirement (a `Looper` on the *calling* thread) is not. Same body + /// as `do_frame` -- draining tasks and rendering immediately is a + /// perfectly good answer to "a background fetch has new state," and + /// avoids a second frame-scheduling path to keep in sync with the real + /// one. + fn delayed_callback(&mut self, ctx: &mut CallbackCtx) { + self.drain_tasks(); + self.render(ctx); + } + + fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> { + Some(self) + } + + fn as_accessibility_node_provider(&mut self) -> Option<&mut dyn AccessibilityNodeProvider> { + Some(self) + } +} + +impl AccessibilityNodeProvider for IrisViewPeer { + fn create_accessibility_node_info<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + virtual_view_id: jint, + ) -> AccessibilityNodeInfo<'local> { + let mut source = AndroidAccessSource { + widgets: self.rsc.widgets(), + render: &self.render, + rsc: &self.rsc, + }; + let ui_state = self.state.android_state_mut(); + AccessibilityNodeInfo(ui_state.access_adapter.create_accessibility_node_info( + &mut source, + &mut ctx.env, + &ctx.view.0, + virtual_view_id, + )) + } + + fn find_focus<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + focus_type: jint, + ) -> AccessibilityNodeInfo<'local> { + let mut source = AndroidAccessSource { + widgets: self.rsc.widgets(), + render: &self.render, + rsc: &self.rsc, + }; + let ui_state = self.state.android_state_mut(); + AccessibilityNodeInfo(ui_state.access_adapter.find_focus( + &mut source, + &mut ctx.env, + &ctx.view.0, + focus_type, + )) + } + + fn perform_action<'local>( + &mut self, + ctx: &mut CallbackCtx<'local>, + virtual_view_id: jint, + action: jint, + arguments: &Bundle<'local>, + ) -> bool { + let Some(action) = + accesskit_android::PlatformAction::from_java(&mut ctx.env, action, &arguments.0) + else { + return false; + }; + let ui_state = self.state.android_state_mut(); + let Some(events) = ui_state.access_adapter.perform_action( + &mut NullActionHandler, + virtual_view_id, + &action, + ) else { + return false; + }; + ctx.push_dynamic_deferred_callback(move |env, view| { + raise_if_enabled(env, view, events); + }); + true + } +} + +/// Registers `IrisViewPeer`'s native methods and builds one on every +/// `newViewPeer` call from Java. `State`'s app crate wraps this in a +/// concrete `extern "system" fn` (a generic function cannot be handed to +/// `register_view_class`, which wants a plain function pointer) -- see +/// `iris/android-app/src/lib.rs`. +pub fn new_peer<'local, State: AndroidAppState>( + mut env: JNIEnv<'local>, + view: View<'local>, + context: Context<'local>, +) -> android_view::jni::sys::jlong { + // `DisplayMetrics.density` -- physical pixels per dp on this device. + // Read once here, at the one point in this file already handed a + // `Context`, and carried on `AndroidUiState` from then on (see + // `content_scale`'s field comment for what depends on it). + let content_scale = context + .resources(&mut env) + .display_metrics(&mut env) + .density(&mut env); + log::info!("iris: new_peer content_scale={content_scale}"); + let vm = env.get_java_vm().unwrap(); + let global_view = env.new_global_ref(&view.0).unwrap(); + let redraw: Arc = Arc::new(AndroidRedrawHandle::new(vm, global_view)); + let (tasks, task_recv) = Tasks::init(redraw); + let mut rsc = AndroidRsc { + ui: Default::default(), + events: Default::default(), + tasks, + state: Default::default(), + _state: PhantomData, + }; + // See `TextData::density`'s field doc for why this is set alongside + // `render.set_density` below rather than read from there. + rsc.ui.text.density = content_scale; + let shared = Rc::new(RefCell::new(Shared::default())); + let ui_state = AndroidUiState::new(shared.clone(), content_scale); + let mut state = State::new(ui_state, &mut rsc); + let platform_vm = env.get_java_vm().unwrap(); + let platform_view = env.new_global_ref(&view.0).unwrap(); + state.platform_ready(&mut rsc, platform_vm, platform_view); + let mut render = UiRenderState::new(); + // Every `Len::dp` in the tree resolves against this from now on -- see + // `UiRenderState::density`'s field doc and `Len::dp`'s. + render.set_density(content_scale); + let peer = IrisViewPeer { + rsc, + render, + state, + task_recv, + input_clock: None, + }; + let id = android_view::register_view_peer(peer); + super::insets::register(id, shared); + id +} diff --git a/src/attr.rs b/src/attr.rs new file mode 100644 index 0000000..c925007 --- /dev/null +++ b/src/attr.rs @@ -0,0 +1,231 @@ +use crate::prelude::*; +use std::time::{Duration, Instant}; + +/// What focusing a text field takes from whichever backend is running -- +/// tracked here rather than duplicated per backend, since `Selector` and +/// `Selectable` (below) are the *only* thing that decides which `TextEdit` +/// is the IME's target, and both platforms need the same double-click +/// timing and the same "remember which one" bookkeeping. What differs is +/// what happens *after* the focus record is set: winit tells the +/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on +/// android-view a keyboard has to be asked for explicitly, and only from a +/// JNI call this crate cannot make outside a view callback -- so +/// `focus_gained` there (`android/attr.rs`) just raises a flag the next +/// touch callback consumes. See RUST.md's I2. +pub trait FocusHost { + /// True on a click close enough in time to the previous one to grow a + /// selection instead of starting a new one, updating the clock as a + /// side effect the way a real double-click timer does. + fn recent_click(&mut self) -> bool; + fn set_focus(&mut self, id: Option>); + /// Called on every tap that should put the IME on `id`: the tap that + /// *makes* a `TextEdit` the focus target, and any later tap on one that + /// already is. `region` is where it was hit (`None` when the widget + /// could not be located, which happens for one it was just deselected + /// from). Implementations must be idempotent -- both backends' calls + /// (`showSoftInput`, `set_ime_cursor_area`) already are, which is what + /// lets the repeat tap be handled by the same call rather than by a + /// second "re-show" entry point beside it. + fn focus_gained(&mut self, region: Option); + /// Whether `id` is the current focus target -- what [`select`] uses to + /// tell a fresh press (which must wait to see whether it becomes a tap + /// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I + /// swipe over the input bar it brings up the keyboard") from a drag + /// continuing inside a field that was already focused (an ordinary + /// drag-to-select, unaffected). + fn is_focused(&self, id: WeakWidget) -> bool; +} + +/// Helper shared by every `FocusHost` impl, so the double-click window is +/// one constant rather than one per backend. +pub fn recent_click(last_click: &mut Instant) -> bool { + let now = Instant::now(); + let recent = (now - *last_click) < Duration::from_millis(300); + *last_click = now; + recent +} + +/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what +/// [`Selector`]/[`Selectable`] register instead of [`CursorSense:: +/// click_or_drag`], so their shared handler (`on_press`, below) sees every +/// frame of a gesture and can tell a completed tap from a drag itself, +/// rather than reacting to `PressStart` alone the way `click_or_drag`'s +/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it +/// brings up the keyboard"). +/// `CursorSense::Cancel` is in the set for the same reason `DragGesture` +/// registers it: if a scroll area or a list takes the pointer mid-gesture, +/// this field sees no `PressEnd`, and a `press_origin` left set is then +/// compared against the *next* press -- a stray selection, or a keyboard +/// summoned by a tap somewhere else entirely. +fn press_track() -> CursorSenses { + CursorSense::click() + | CursorSense::Pressing(CursorButton::Left) + | CursorSense::unclick() + | CursorSense::Cancel +} + +pub struct Selector; + +impl WidgetAttr for Selector +where + Rsc::State: FocusHost, +{ + type Input = WeakWidget; + + fn run(rsc: &mut Rsc, container: WeakWidget, id: Self::Input) { + rsc.register_event(container, press_track(), move |ctx, rsc| { + let region = ctx.data.render.window_region(&id, &*rsc).unwrap(); + let id_pos = region.top_left; + let container_pos = ctx + .data + .render + .window_region(&container, &*rsc) + .unwrap() + .top_left; + let pos = ctx.data.pos + container_pos - id_pos; + let size = region.size(); + on_press( + rsc, + ctx.data.render, + ctx.state, + id, + pos, + size, + ctx.data.sense, + ); + }); + } +} + +pub struct Selectable; + +impl WidgetAttr for Selectable +where + Rsc::State: FocusHost, +{ + type Input = (); + + fn run(rsc: &mut Rsc, id: WeakWidget, _: Self::Input) { + rsc.register_event(id, press_track(), move |ctx, rsc| { + on_press( + rsc, + ctx.data.render, + ctx.state, + id, + ctx.data.pos, + ctx.data.size, + ctx.data.sense, + ); + }); + } +} + +/// One press-track frame (`PressStart`, `Pressing` or `PressEnd`) over a +/// selectable field. A field that is *already* focused behaves exactly as +/// `click_or_drag` always did -- every frame updates the selection, which +/// is what lets a finger already inside a focused field drag out a +/// selection. A field that is **not** focused withholds `select`'s +/// focus-granting side effects (and so the platform-specific `focus_gained` +/// that shows the keyboard) until the press resolves as a tap: `PressEnd` +/// with no frame in between having moved past [`DRAG_SLOP`] from where the +/// press began. A drag recognised before release simply cancels the +/// pending tap and does nothing further here -- it is not consumed, so +/// whatever is behind the field (a list to pan) still sees every frame of +/// it, the same as a drag that never touched a selectable field at all. +fn on_press( + rsc: &mut impl UiRsc, + render: &UiRenderState, + state: &mut impl FocusHost, + id: WeakWidget, + pos: Vec2, + size: Vec2, + sense: CursorSense, +) { + if state.is_focused(id) { + // Already focused, so there is no keyboard to withhold -- but a + // vertical drag still is not a selection. Android's own `EditText` + // scrolls its overflowed text on a vertical drag and starts a + // selection only from a long press; a scroll area wrapping this + // field (`ScrollController::drag`) is what actually pans, and it needs the + // first frames of the gesture not to have selected anything behind + // it before it crosses `DRAG_SLOP` and takes pointer capture. + // `press_origin` carries the same meaning here as in the unfocused + // branch below -- "this gesture is still eligible", cleared the + // moment it becomes a drag -- so there is one flag, not two. + match sense { + CursorSense::PressStart(_) => { + let recent = state.recent_click(); + id.edit(rsc).text.press_origin = Some(pos); + id.edit(rsc).select(pos, size, false, recent); + } + CursorSense::Pressing(_) | CursorSense::PressEnd(_) => { + let mut ctx = id.edit(rsc); + let Some(origin) = ctx.text.press_origin else { + return; + }; + let (dx, dy) = (pos.x - origin.x, pos.y - origin.y); + if dy.abs() > DRAG_SLOP && dy.abs() >= dx.abs() { + ctx.text.press_origin = None; + return; + } + let ended = matches!(sense, CursorSense::PressEnd(_)); + if ended { + ctx.text.press_origin = None; + } + ctx.select(pos, size, true, false); + // A tap on a field that is *already* focused asks for the + // keyboard again (Iris's phone, 2026-09-06: "I can't reopen + // keyboard by tapping on message box after it already + // happened once"). Dismissing the IME -- back gesture, or + // its own hide button -- takes the keyboard away but leaves + // the field focused, so without this the one branch that + // requests it (the unfocused one below) never runs again + // and the field is permanently unable to summon it. + // Android's own `EditText` does exactly this: every tap on + // a focused field calls `showSoftInput`, which is a no-op + // when the keyboard is already up. + // + // Gated on the same tap-vs-drag test the unfocused branch + // uses, not on `PressEnd` alone, so a drag-to-select that + // happens to finish inside the field does not summon a + // keyboard the reader was not asking for. + if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP { + state.focus_gained(render.window_region(&id, &*rsc)); + } + } + CursorSense::Cancel => id.edit(rsc).text.press_origin = None, + _ => {} + } + return; + } + + match sense { + CursorSense::PressStart(_) => { + id.edit(rsc).text.press_origin = Some(pos); + } + CursorSense::Pressing(_) => { + let ctx = id.edit(rsc); + if let Some(origin) = ctx.text.press_origin + && ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP) + { + // Past the slop before release: this is a drag, not a tap + // -- give up the pending focus rather than granting it once + // the finger lifts wherever it happens to be by then. + ctx.text.press_origin = None; + } + } + // The gesture was taken by somebody else, so it is not a tap and + // must not grant focus when it ends out of this widget's sight. + CursorSense::Cancel => id.edit(rsc).text.press_origin = None, + CursorSense::PressEnd(_) => { + let was_tap = id.edit(rsc).text.press_origin.take().is_some(); + if was_tap { + let recent = state.recent_click(); + id.edit(rsc).select(pos, size, false, recent); + state.set_focus(Some(id)); + state.focus_gained(render.window_region(&id, &*rsc)); + } + } + _ => {} + } +} diff --git a/src/default/access.rs b/src/default/access.rs new file mode 100644 index 0000000..8de405b --- /dev/null +++ b/src/default/access.rs @@ -0,0 +1,28 @@ +//! I4 (RUST.md): the desktop half of the AccessKit push, over +//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the +//! platform's real accessibility tree, so this crate only has to keep that +//! tree in sync with `ui::access::AccessTree`'s output -- nothing here +//! reacts to an AccessKit action request, which is why the three handlers +//! below are inert. See RUST.md's I4 box for why: on Android (and, by the +//! same platform convention, everywhere else) a screen reader's element tap +//! is a real touch delivered at the node's own bounds, not an action +//! request synthesised in-process -- so the ordinary pointer path already +//! handles it once the bounds are right. +use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate}; + +pub struct NullActivationHandler; +impl ActivationHandler for NullActivationHandler { + fn request_initial_tree(&mut self) -> Option { + None + } +} + +pub struct NullActionHandler; +impl ActionHandler for NullActionHandler { + fn do_action(&mut self, _request: ActionRequest) {} +} + +pub struct NullDeactivationHandler; +impl DeactivationHandler for NullDeactivationHandler { + fn deactivate_accessibility(&mut self) {} +} diff --git a/src/default/app.rs b/src/default/app.rs index fe8dfaa..14ea8e5 100644 --- a/src/default/app.rs +++ b/src/default/app.rs @@ -27,6 +27,10 @@ pub struct App { impl App { pub fn run() { + // The desktop's `main` in everything but name -- see + // `super::logging`'s doc for why the logger goes here and what + // its absence hid. + super::logging::install(log::LevelFilter::Info); let event_loop = EventLoop::with_user_event().build().unwrap(); let proxy = event_loop.create_proxy(); event_loop diff --git a/src/default/attr.rs b/src/default/attr.rs index ff4e313..9d7484d 100644 --- a/src/default/attr.rs +++ b/src/default/attr.rs @@ -1,78 +1,28 @@ use crate::prelude::*; -use std::time::{Duration, Instant}; -use winit::dpi::{LogicalPosition, LogicalSize}; +use winit::dpi::{PhysicalPosition, PhysicalSize}; -pub struct Selector; - -impl WidgetAttr for Selector -where - Rsc::State: HasDefaultUiState, -{ - type Input = WeakWidget; - - fn run(rsc: &mut Rsc, container: WeakWidget, id: Self::Input) { - rsc.register_event(container, CursorSense::click_or_drag(), move |ctx, rsc| { - let region = ctx.data.render.window_region(&id).unwrap(); - let id_pos = region.top_left; - let container_pos = ctx.data.render.window_region(&container).unwrap().top_left; - let pos = ctx.data.pos + container_pos - id_pos; - let size = region.size(); - select( - rsc, - ctx.data.render, - ctx.state, - id, - pos, - size, - ctx.data.sense.is_dragging(), - ); - }); +impl FocusHost for T { + fn recent_click(&mut self) -> bool { + crate::attr::recent_click(&mut self.default_state_mut().last_click) } -} -pub struct Selectable; - -impl WidgetAttr for Selectable -where - Rsc::State: HasDefaultUiState, -{ - type Input = (); - - fn run(rsc: &mut Rsc, id: WeakWidget, _: Self::Input) { - rsc.register_event(id, CursorSense::click_or_drag(), move |ctx, rsc| { - select( - rsc, - ctx.data.render, - ctx.state, - id, - ctx.data.pos, - ctx.data.size, - ctx.data.sense.is_dragging(), - ); - }); + fn set_focus(&mut self, id: Option>) { + self.default_state_mut().focus = id; } -} -fn select( - rsc: &mut impl UiRsc, - render: &UiRenderState, - state: &mut impl HasDefaultUiState, - id: WeakWidget, - pos: Vec2, - size: Vec2, - dragging: bool, -) { - let state = state.default_state_mut(); - let now = Instant::now(); - let recent = (now - state.last_click) < Duration::from_millis(300); - state.last_click = now; - id.edit(rsc).select(pos, size, dragging, recent); - if let Some(region) = render.window_region(&id) { + fn is_focused(&self, id: WeakWidget) -> bool { + self.default_state().focus == Some(id) + } + + fn focus_gained(&mut self, region: Option) { + let state = self.default_state_mut(); + let Some(region) = region else { return }; state.window.set_ime_allowed(true); + // Physical, like everything else this backend hands winit -- + // `default::content_scale`. state.window.set_ime_cursor_area( - LogicalPosition::::from(region.top_left.tuple()), - LogicalSize::::from(region.size().tuple()), + PhysicalPosition::::from(region.top_left.tuple()), + PhysicalSize::::from(region.size().tuple()), ); } - state.focus = Some(id); } diff --git a/src/default/event.rs b/src/default/event.rs deleted file mode 100644 index b33f09e..0000000 --- a/src/default/event.rs +++ /dev/null @@ -1,9 +0,0 @@ -use iris_core::Event; - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Submit; -impl Event for Submit {} - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Edited; -impl Event for Edited {} diff --git a/src/default/input.rs b/src/default/input.rs index 07b6bbf..45d2297 100644 --- a/src/default/input.rs +++ b/src/default/input.rs @@ -1,4 +1,10 @@ +// `CursorState::time` is the sample's own time on every backend. winit +// carries no timestamp on a pointer event, so the moment it is handed to +// us is the closest measurement available here -- which is also what the +// drag code used to do for itself with `Instant::now()`, before Android's +// batched samples made the difference matter (see `sense::CursorState`). use crate::prelude::*; +use std::time::Instant; use winit::{ event::{MouseButton, MouseScrollDelta, WindowEvent}, keyboard::{Key, NamedKey}, @@ -11,13 +17,19 @@ pub struct Input { } impl Input { + /// winit's pointer coordinates are physical pixels, which is the + /// space the whole tree is laid out and hit-tested in -- see + /// `default::content_scale`. Nothing is converted here; `dp(...)` + /// resolves against the density at layout time instead. pub fn event(&mut self, event: &WindowEvent) -> bool { match event { WindowEvent::CursorMoved { position, .. } => { self.cursor.pos = Vec2::new(position.x as f32, position.y as f32); self.cursor.exists = true; + self.cursor.time = Instant::now(); } WindowEvent::MouseInput { state, button, .. } => { + self.cursor.time = Instant::now(); let buttons = &mut self.cursor.buttons; let pressed = state.is_pressed(); match button { @@ -37,6 +49,7 @@ impl Input { delta.y = 0.0; } self.cursor.scroll_delta = delta; + self.cursor.time = Instant::now(); } WindowEvent::CursorLeft { .. } => { self.cursor.exists = false; @@ -67,9 +80,12 @@ impl Input { } impl DefaultUiState { + /// Physical pixels, matching `WindowEvent::Resized` (what + /// `UiRenderState::resize` is given) and the swapchain -- see + /// `default::content_scale`. pub fn window_size(&self) -> Vec2 { let size = self.renderer.window().inner_size(); - (size.width, size.height).into() + Vec2::new(size.width as f32, size.height as f32) } pub fn cursor_state(&self) -> &CursorState { diff --git a/src/default/logging.rs b/src/default/logging.rs new file mode 100644 index 0000000..7776151 --- /dev/null +++ b/src/default/logging.rs @@ -0,0 +1,86 @@ +//! A stderr logger for the desktop entry point. +//! +//! Without one, `log::` calls on this side go nowhere: `log`'s default is +//! a no-op logger, and nothing in `desktop-app` or the examples ever +//! installed a real one. That is how iris came to have a renderer that +//! silently fell back to GLES (and, on this VM, on to llvmpipe when the +//! host took its GPU away) with **no record anywhere of what +//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the +//! host GPU are the same PNG, and the difference is exactly what a +//! screenshot is being taken to judge. +//! +//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather +//! than by a library call somewhere, because that function already takes +//! over the process -- it owns the event loop and does not return -- so +//! it is the desktop's `main` in everything but name, and one install +//! there covers `desktop-app` and every example at once. `try_init` +//! rather than `init`: a binary that installed its own logger first keeps +//! it, and a second `DefaultApp::run` in one process is not an error. +//! +//! Deliberately not `env_logger`. All this owes the reader is a level and +//! a line, which is a page of code against a dependency plus its own +//! filter dialect; the Android side is `android_logger` for the same +//! reason -- one line per platform's own convention. + +use std::io::Write; + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// Reads one level name from `RUST_LOG` -- `off`, `error`, `warn`, +/// `info`, `debug`, `trace`, case-insensitively. **Not env_logger's +/// per-module filter syntax**: anything else is ignored and the default +/// stands, rather than being silently read as "off", since a typo that +/// turned logging off would be indistinguishable from a quiet program. +fn level_from_env(default: LevelFilter) -> LevelFilter { + match std::env::var("RUST_LOG") { + Ok(text) => text.trim().parse().unwrap_or(default), + Err(_) => default, + } +} + +struct StderrLogger { + level: LevelFilter, +} + +impl Log for StderrLogger { + fn enabled(&self, metadata: &Metadata) -> bool { + metadata.level() <= self.level + } + + fn log(&self, record: &Record) { + if !self.enabled(record.metadata()) { + return; + } + // One write, not a `writeln!` per part: two threads logging at + // once interleave otherwise, and the frame and input traces are + // both written from whichever thread produced them. + let line = format!( + "{level:<5} {target}: {args}\n", + level = match record.level() { + Level::Error => "ERROR", + Level::Warn => "WARN", + Level::Info => "INFO", + Level::Debug => "DEBUG", + Level::Trace => "TRACE", + }, + target = record.target(), + args = record.args(), + ); + let _ = std::io::stderr().write_all(line.as_bytes()); + } + + fn flush(&self) { + let _ = std::io::stderr().flush(); + } +} + +/// Installs the stderr logger unless this process already has one. +/// Defaults to `info`, which is where the renderer says which adapter it +/// got; `RUST_LOG=debug` adds iris's own per-frame lines. +pub fn install(default: LevelFilter) { + let level = level_from_env(default); + let logger = Box::leak(Box::new(StderrLogger { level })); + if log::set_logger(logger).is_ok() { + log::set_max_level(level); + } +} diff --git a/src/default/mod.rs b/src/default/mod.rs index ea00eb7..b707ada 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -11,26 +11,53 @@ use winit::{ window::{Window, WindowAttributes}, }; +mod access; mod app; mod attr; -mod event; mod input; +mod logging; +mod platform; mod render; -mod sense; -mod state; -mod task; +pub use access::*; pub use app::*; -pub use attr::*; -pub use event::*; pub use input::*; pub use render::*; -pub use sense::*; -pub use state::*; -pub use task::*; pub type Proxy = EventLoopProxy; +/// The desktop's `content_scale`: physical pixels per dp, the same +/// quantity Android reads from `DisplayMetrics.density` and feeds to +/// `UiRenderState::set_density` (`android::view::AndroidUiState:: +/// content_scale`'s field comment). Everything in this backend is +/// physical pixels -- the window size, the pointer, the widget tree -- +/// and `dp(...)` is what resolves against this at layout time, exactly +/// as on the phone. That is a correction from an earlier version that +/// divided winit's coordinates into a separate "logical" space instead: +/// it left `UiRenderState::resize` (physical, from `WindowEvent:: +/// Resized`) and the window uniform (logical) disagreeing on any +/// display whose scale factor is not 1.0, and it rasterised glyphs at +/// one resolution to display them at another -- the blur the phone's own +/// stopgap produced before `dp` existed. +/// +/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop +/// window runs the phone's density (`run-headless.sh --phone`, +/// docs/RUST.md's layer 2). An unparsable value is a typo in a command +/// somebody just typed, so it says so and uses the window's own answer +/// rather than silently laying out at the wrong density. +pub fn content_scale(window: &Window) -> f32 { + match std::env::var("IRIS_SCALE") { + Err(_) => window.scale_factor() as f32, + Ok(text) => match text.trim().parse::() { + Ok(scale) if scale > 0.0 => scale, + _ => { + log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own"); + window.scale_factor() as f32 + } + }, + } +} + pub struct DefaultUiState { pub root: Option, pub renderer: UiRenderer, @@ -40,6 +67,17 @@ pub struct DefaultUiState { pub window: Arc, pub ime: usize, pub last_click: Instant, + /// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s + /// `RedrawRequested` arm, from `access`'s output. Built in + /// `DefaultApp::new`, which is the only place with the + /// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers` + /// needs -- see that constructor's doc comment on why the window must + /// still be invisible when it is called. + pub access_adapter: accesskit_winit::Adapter, + /// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc + /// comment for the flat shape and why it only rebuilds on a real + /// change. + pub access: AccessTree, } impl HasRoot for DefaultUiState { @@ -49,7 +87,7 @@ impl HasRoot for DefaultUiState { } impl DefaultUiState { - pub fn new(window: impl Into>) -> Self { + pub fn new(window: impl Into>, access_adapter: accesskit_winit::Adapter) -> Self { let window = window.into(); Self { root: None, @@ -60,6 +98,8 @@ impl DefaultUiState { ime: 0, last_click: Instant::now(), focus: None, + access_adapter, + access: AccessTree::new(), } } } @@ -188,13 +228,35 @@ impl AppState for DefaultApp { type Event = State::Event; fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> Self { + // `accesskit_winit::Adapter::with_direct_handlers` panics if the + // window is already visible when it's built, so the window is + // created hidden and only shown once the adapter exists -- the one + // extra step I4 (RUST.md) needs here. The three handlers are inert + // (see `access.rs`): a screen reader's tap is a real touch at the + // node's bounds, not an action request this process has to answer. let window = event_loop - .create_window(State::window_attributes()) + .create_window(State::window_attributes().with_visible(false)) .unwrap(); - let default_state = DefaultUiState::new(window); + let access_adapter = accesskit_winit::Adapter::with_direct_handlers( + event_loop, + &window, + NullActivationHandler, + NullActionHandler, + NullDeactivationHandler, + ); + window.set_visible(true); + let default_state = DefaultUiState::new(window, access_adapter); let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); + // Both copies of the density, set before the first widget is + // built so text shapes at the right size on the opening frame -- + // the same pair `android::view::new_peer` sets from + // `content_scale`. See `iris_core::TextData::density` for why the + // shaper keeps its own. + let scale = content_scale(default_state.window.as_ref()); + rsc.ui.text.density = scale; let state = State::new(default_state, &mut rsc, proxy); - let render = UiRenderState::new(); + let mut render = UiRenderState::new(); + render.set_density(scale); Self { rsc, state, @@ -220,6 +282,12 @@ impl AppState for DefaultApp { } let ui_state = state.default_state_mut(); + // Required by `accesskit_winit` on every window event, not just the + // ones this backend otherwise cares about -- some platform adapters + // rely on it to notice activation (a screen reader turning on). + ui_state + .access_adapter + .process_event(&ui_state.window, &event); let input_changed = ui_state.input.event(&event); let cursor_state = ui_state.cursor_state().clone(); let old = ui_state.focus; @@ -227,6 +295,31 @@ impl AppState for DefaultApp { ui_state.focus = None; } if input_changed { + // The winit half of `iris::input` (`sense::log_input_event`'s + // own doc): no batching here, so `historical` is always empty + // -- winit hands one `WindowEvent` per pointer sample, unlike + // Android's `MotionEvent`. The action is read back off the + // buttons `Input::event` just updated, the same test + // `GestureOutcome`'s callers already use to tell a press from a + // release. Computed only when tracing is on, same reasoning as + // `log_input_event` itself gating on it. + if crate::diagnostics::trace_enabled() { + let action = if cursor_state.buttons.left.is_start() { + "down" + } else if cursor_state.buttons.left.is_end() { + "up" + } else { + "move" + }; + let t_ms = cursor_state.time.duration_since(render.epoch()).as_millis() as u64; + crate::sense::log_input_event( + action, + cursor_state.pos.x, + cursor_state.pos.y, + t_ms, + &[], + ); + } let window_size = ui_state.window_size(); render.run_sensors(rsc, state, cursor_state, window_size); } @@ -239,14 +332,53 @@ impl AppState for DefaultApp { match &event { WindowEvent::CloseRequested => event_loop.exit(), WindowEvent::RedrawRequested => { + // Before the draw, so this frame shows this instant's + // position (`UiData::tick_animations`' own doc), and the + // window is asked for another frame while anything is + // still moving -- the winit half of what + // `IrisViewPeer::render`'s `post_frame_callback` does on + // Android. Nothing else in iris moves without an input + // event. + let frame_start = std::time::Instant::now(); + let animating = rsc.ui_mut().tick_animations(frame_start); + let ui_state = state.default_state_mut(); render.update(&ui_state.root, rsc); ui_state.renderer.update(&mut rsc.ui, render); + let draw_start = std::time::Instant::now(); ui_state.renderer.draw(); + crate::diagnostics::log_frame(render, frame_start, draw_start.elapsed(), animating); + if animating { + ui_state.window.request_redraw(); + } + // I4 (RUST.md): only produces a `TreeUpdate` when the named + // set actually changed this frame -- see `AccessTree`'s doc + // comment. `render` reflects the draw that just happened, + // so `resolved_region`/`window_region` inside it report a + // moved subtree's *new* position, not last frame's. + if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) { + ui_state.access_adapter.update_if_active(|| tree_update); + } } WindowEvent::Resized(size) => { render.resize((size.width, size.height)); ui_state.renderer.resize(size) } + // Dragging the window to a display with a different scale. + // Both copies again, the pair `new` sets at startup -- read + // through `content_scale` rather than from the event, so + // `IRIS_SCALE` still pins the density it was given (the + // `--phone` window must not follow the monitor). winit sends + // the matching `Resized` separately. Before 2026-09-07 this + // event was unhandled, so every `dp` and every rasterised + // glyph stayed at the density the window opened on + // (docs/REVIEW-2026-09-07.md's R5) -- invisible on this + // machine, where every display is 1.0. + WindowEvent::ScaleFactorChanged { .. } => { + let scale = content_scale(ui_state.window.as_ref()); + rsc.ui.text.density = scale; + render.set_density(scale); + ui_state.window.request_redraw(); + } WindowEvent::KeyboardInput { event, .. } => { if let Some(sel) = ui_state.focus && event.state.is_pressed() @@ -309,12 +441,6 @@ impl AppState for DefaultApp { } } -pub trait RscIdx { - type Output; - fn get(self, rsc: &Rsc) -> &Self::Output; - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output; -} - impl>> std::ops::Index for DefaultRsc { type Output = I::Output; @@ -328,27 +454,3 @@ impl>> std::ops::IndexMut for Def index.get_mut(self) } } - -impl RscIdx for WeakWidget { - type Output = W; - - fn get(self, rsc: &Rsc) -> &Self::Output { - &rsc.ui().widgets[self] - } - - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { - &mut rsc.ui_mut().widgets[self] - } -} - -impl RscIdx for WeakState { - type Output = T; - - fn get(self, rsc: &Rsc) -> &Self::Output { - rsc.widget_state().get(self) - } - - fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { - rsc.widget_state_mut().get_mut(self) - } -} diff --git a/src/default/platform.rs b/src/default/platform.rs new file mode 100644 index 0000000..75c2e7d --- /dev/null +++ b/src/default/platform.rs @@ -0,0 +1,33 @@ +use crate::platform::OpenUrl; +use crate::prelude::HasDefaultUiState; + +/// The desktop's URL opener: the platform's own "open this with whatever +/// is registered for it" command, detached so a browser starting slowly +/// cannot stall the event loop. +/// +/// A command rather than a crate: `xdg-open`/`open`/`start` is what every +/// such crate shells out to anyway, and this is one call site. +impl OpenUrl for T { + fn open_url(&mut self, url: &str) { + let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") { + ("open", &[]) + } else if cfg!(target_os = "windows") { + // `start` is a shell builtin, and its first argument is the + // window title -- an empty one, or a URL containing `&` ends + // up split. + ("cmd", &["/C", "start", ""]) + } else { + ("xdg-open", &[]) + }; + match std::process::Command::new(program) + .args(first) + .arg(url) + .spawn() + { + Ok(_) => {} + // Named with the command that failed and the link it was for, + // since neither is recoverable from the OS error alone. + Err(e) => log::warn!("could not open {url} with {program}: {e}"), + } + } +} diff --git a/src/default/render.rs b/src/default/render.rs index 625a9fa..12153e7 100644 --- a/src/default/render.rs +++ b/src/default/render.rs @@ -1,4 +1,5 @@ -use iris_core::{UiData, UiLimits, UiRenderNode, UiRenderState}; +use crate::task::RequestRedraw; +use iris_core::{UiData, UiRenderNode, UiRenderState, util::Vec2}; use pollster::FutureExt; use std::sync::Arc; use wgpu::*; @@ -6,6 +7,12 @@ use winit::{dpi::PhysicalSize, window::Window}; pub const CLEAR_COLOR: Color = Color::BLACK; +impl RequestRedraw for Window { + fn request_redraw(&self) { + Window::request_redraw(self); + } +} + pub struct UiRenderer { window: Arc, surface: Surface<'static>, @@ -22,7 +29,16 @@ impl UiRenderer { } pub fn draw(&mut self) { - let output = self.surface.get_current_texture().unwrap(); + let output = match self.surface.get_current_texture() { + CurrentSurfaceTexture::Success(texture) + | CurrentSurfaceTexture::Suboptimal(texture) => texture, + // wgpu 30 turned this Result into an enum; every arm here was an + // `Err` the previous `.unwrap()` panicked on, except `Occluded`, + // which is new. Named rather than swallowed: a window that stops + // presenting silently is the state this file's `pre_present_notify` + // comment was written about. + other => panic!("no surface texture to draw into: {other:?}"), + }; let view = output .texture .create_view(&TextureViewDescriptor::default()); @@ -45,14 +61,25 @@ impl UiRenderer { } self.queue.submit(std::iter::once(encoder.finish())); - output.present(); + // Immediately before presenting, so the windowing system can schedule + // the frame. On Wayland this is what ties the commit to the surface's + // frame callback; without it a frame drawn when nothing else follows + // could sit unpresented, and the window kept the layout it had before + // the compositor's first resize -- intermittently, on about a fifth of + // starts, with nothing left to flush it. + self.window.pre_present_notify(); + self.queue.present(output); } pub fn resize(&mut self, size: &PhysicalSize) { self.config.width = size.width; self.config.height = size.height; self.surface.configure(&self.device, &self.config); - self.ui.resize(size, &self.queue); + // Physical, matching `new`'s own seed -- see the comment there. + self.ui.resize( + Vec2::new(size.width as f32, size.height as f32), + &self.queue, + ); } fn create_encoder(device: &Device) -> CommandEncoder { @@ -64,10 +91,44 @@ impl UiRenderer { pub fn new(window: Arc) -> Self { let size = window.inner_size(); - let instance = Instance::new(&InstanceDescriptor { - backends: Backends::PRIMARY, - ..Default::default() + // `force-gles` on the desktop too, not just on Android: the + // GLES backend has behaviour of its own (a one-layer array + // texture is a `GL_TEXTURE_2D` -- see + // `GpuTextures::create_array_texture`), and a machine with a + // real GPU is where that is cheap to reproduce and screenshot. + let mut backends = if cfg!(feature = "force-gles") { + Backends::GL + } else { + Backends::PRIMARY + }; + // The display handle comes from the window rather than being left + // out: wgpu 30 asks for it whenever a GLES surface is going to be + // presented on Wayland, which is exactly what the fallback below + // produces on this machine. + let mut instance = Instance::new(InstanceDescriptor { + backends, + ..InstanceDescriptor::new_with_display_handle(Box::new(window.clone())) }); + // The same fallback the Android backend grew in 85869d0, and for + // the same reason: a machine can advertise a Vulkan ICD with no + // device behind it, and refusing to draw at all because the only + // usable adapter is a GLES one is iris's bug rather than the + // machine's. On this VM the Vulkan device disappears whenever + // the host refuses a virtio-gpu context, so `run-headless.sh` -- + // layer 2 of the test rig -- aborted with `Could not get + // adapter!` while GL was sitting there working. Probed before the + // surface exists, matching Android, where an instance carrying + // both backends fails worse than one carrying the wrong one. + if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() { + log::warn!( + "iris renderer: no {backends:?} adapter on this machine, falling back to GLES" + ); + backends = Backends::GL; + instance = Instance::new(InstanceDescriptor { + backends, + ..InstanceDescriptor::new_with_display_handle(Box::new(window.clone())) + }); + } let surface = instance .create_surface(window.clone()) @@ -78,25 +139,48 @@ impl UiRenderer { power_preference: PowerPreference::default(), compatible_surface: Some(&surface), force_fallback_adapter: false, + ..Default::default() }) .block_on() - .expect("Could not get adapter!"); + .unwrap_or_else(|error| { + panic!("No usable GPU adapter for backends {backends:?}: {error}") + }); - let ui_limits = UiLimits::default(); + // Say which adapter won, in the same words the Android backend + // uses. Without it a layer-2 screenshot or frame time from this + // window carries no record of what drew it, and the two cases that + // matter look identical in the PNG: the host's real GPU, and + // llvmpipe after this VM lost its virtio-gpu contexts. That + // happened on 2026-09-08, and the only reason anyone noticed is + // that the fallback above did not exist yet and the app aborted + // instead. A silent fallback needs this line to stay honest. + { + let info = adapter.get_info(); + log::info!( + "iris renderer: {name} ({backend:?}, {driver}{driver_info}) on {backends:?}", + name = info.name, + backend = info.backend, + driver = info.driver, + driver_info = if info.driver_info.is_empty() { + String::new() + } else { + format!(" {}", info.driver_info) + }, + ); + } + // No features beyond what wgpu asks for by default, and no + // binding-array limits: the atlas is one texture_2d_array and a + // standalone image is its own ordinary bind group, neither of which + // needs descriptor indexing. See TEXTURES.md's "Recommended shape" + // for why the old binding array asked for + // VK_EXT_descriptor_indexing unconditionally and did not survive a + // real share of Android GPUs. `iris_core::device_limits()` is + // shared with the Android backend; see its own doc for why it is + // not simply `Limits::default()`. let (device, queue) = adapter .request_device(&DeviceDescriptor { - required_features: Features::TEXTURE_BINDING_ARRAY - | Features::PARTIALLY_BOUND_BINDING_ARRAY - | Features::SAMPLED_TEXTURE_AND_STORAGE_BUFFER_ARRAY_NON_UNIFORM_INDEXING, - required_limits: Limits { - max_binding_array_elements_per_shader_stage: ui_limits - .max_binding_array_elements_per_shader_stage(), - max_binding_array_sampler_elements_per_shader_stage: ui_limits - .max_binding_array_sampler_elements_per_shader_stage(), - max_buffer_size: 1 << 30, - ..Default::default() - }, + required_limits: iris_core::device_limits(), ..Default::default() }) .block_on() @@ -113,9 +197,16 @@ impl UiRenderer { let config = SurfaceConfiguration { usage: TextureUsages::RENDER_ATTACHMENT, format: surface_format, + // wgpu 30's new field; `Auto` is what every earlier version did. + color_space: SurfaceColorSpace::Auto, width: size.width, height: size.height, - present_mode: PresentMode::AutoNoVsync, + // Vsync, because a toolkit aiming at battery life must not present + // frames a display will never show: AutoNoVsync accepts them as + // fast as the GPU will take them, so a redraw burst costs whatever + // the hardware can be made to do rather than one frame. + // AutoVsync picks Fifo, which every backend supports. + present_mode: PresentMode::AutoVsync, alpha_mode: surface_caps.alpha_modes[0], desired_maximum_frame_latency: 2, view_formats: vec![], @@ -125,7 +216,19 @@ impl UiRenderer { let encoder = Self::create_encoder(&device); - let ui = UiRenderNode::new(&device, &queue, &config, ui_limits); + // Unlike the Android backend, the desktop backend has no on-screen + // fallback to show a diagnostic through, so a renderer-creation + // failure still panics here -- but now with wgpu's full "Caused + // by:" chain as the message, since `UiRenderNode::new` returns it + // rather than letting wgpu's own default handler panic first (see + // that function's doc comment). + // Physical size, the same units the swapchain, `WindowEvent:: + // Resized`, the pointer and the widget tree all use -- see + // `default::content_scale` for why this backend stopped dividing + // into a separate logical space, and what disagreed while it did. + let physical_size = Vec2::new(size.width as f32, size.height as f32); + let ui = UiRenderNode::new(&device, &queue, &config, physical_size) + .expect("Could not create iris render node!"); Self { surface, diff --git a/src/default/sense.rs b/src/default/sense.rs deleted file mode 100644 index ee73ee9..0000000 --- a/src/default/sense.rs +++ /dev/null @@ -1,308 +0,0 @@ -use crate::prelude::*; -use std::{ - ops::{BitOr, Deref, DerefMut}, - rc::Rc, -}; - -#[derive(Clone, Copy, PartialEq)] -pub enum CursorButton { - Left, - Right, - Middle, -} - -#[derive(Clone, Copy, PartialEq)] -pub enum CursorSense { - PressStart(CursorButton), - Pressing(CursorButton), - PressEnd(CursorButton), - HoverStart, - Hovering, - HoverEnd, - Scroll, -} - -#[derive(Clone)] -pub struct CursorSenses(Vec); - -impl Event for CursorSenses { - type Data<'a> = CursorData<'a>; - type State = SensorState; - fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { - if let Some(sense) = should_run(self, &data.cursor, data.hover) { - let mut data = data.clone(); - data.sense = sense; - Some(data) - } else { - None - } - } -} - -impl CursorSense { - pub fn click() -> Self { - Self::PressStart(CursorButton::Left) - } - pub fn click_or_drag() -> CursorSenses { - Self::click() | Self::Pressing(CursorButton::Left) - } - pub fn unclick() -> Self { - Self::PressEnd(CursorButton::Left) - } - pub fn is_dragging(&self) -> bool { - matches!(self, CursorSense::Pressing(CursorButton::Left)) - } -} - -#[derive(Default, Clone)] -pub struct CursorState { - pub pos: Vec2, - pub exists: bool, - pub buttons: CursorButtons, - pub scroll_delta: Vec2, -} - -#[derive(Default, Clone)] -pub struct CursorButtons { - pub left: ActivationState, - pub middle: ActivationState, - pub right: ActivationState, -} - -impl CursorButtons { - pub fn select(&self, button: &CursorButton) -> &ActivationState { - match button { - CursorButton::Left => &self.left, - CursorButton::Right => &self.right, - CursorButton::Middle => &self.middle, - } - } - - pub fn end_frame(&mut self) { - self.left.end_frame(); - self.middle.end_frame(); - self.right.end_frame(); - } - - pub fn iter(&self) -> impl Iterator { - [ - CursorButton::Left, - CursorButton::Middle, - CursorButton::Right, - ] - .into_iter() - .map(|b| (b, self.select(&b))) - } -} - -impl CursorState { - pub fn end_frame(&mut self) { - self.buttons.end_frame(); - self.scroll_delta = Vec2::ZERO; - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq)] -pub enum ActivationState { - Start, - On, - End, - #[default] - Off, -} - -/// this and other similar stuff has a generic -/// because I kind of want to make CursorModule generic -/// or basically have some way to have custom senses -/// that depend on active widget positions -/// but I'm not sure how or if worth it -pub struct Sensor { - pub senses: CursorSenses, - pub f: Rc>, -} - -pub type SenseShape = UiRegion; - -#[derive(Default, Debug)] -pub struct SensorState { - pub hover: ActivationState, -} - -#[derive(Clone)] -pub struct CursorData<'a> { - /// where this widget was hit - pub pos: Vec2, - pub size: Vec2, - pub scroll_delta: Vec2, - pub hover: ActivationState, - pub cursor: CursorState, - /// the first sense that triggered this - pub sense: CursorSense, - pub render: &'a UiRenderState, -} - -pub trait SensorUi { - fn run_sensors( - &self, - rsc: &mut Rsc, - state: &mut Rsc::State, - cursor: CursorState, - window_size: Vec2, - ); -} - -impl SensorUi for UiRenderState { - fn run_sensors( - &self, - rsc: &mut Rsc, - state: &mut Rsc::State, - cursor: CursorState, - window_size: Vec2, - ) { - // in order to remove this take, need to store active list in UiRenderState somehow - // this would probably be done through a generic parameter that adds yet another rsc / - // state like thing, but local to render state, and is passed to UiRsc events so you can - // update it there? - let mut active = std::mem::take(&mut rsc.events_mut().get_type::().active); - for layer in self.layers.indices().rev() { - let mut sensed = false; - for (id, sensor) in active.get_mut(&layer).into_flat_iter() { - let shape = self.active.get(id).unwrap().region; - let region = shape.to_px(window_size); - let in_shape = cursor.exists && region.contains(cursor.pos); - sensor.hover.update(in_shape); - if sensor.hover == ActivationState::Off { - continue; - } - sensed = true; - - let cursor = cursor.clone(); - - let data = CursorData { - pos: cursor.pos - region.top_left, - size: region.bot_right - region.top_left, - scroll_delta: cursor.scroll_delta, - hover: sensor.hover, - cursor, - // this does not have any meaning; - // might wanna set up Event to have a prepare stage - sense: CursorSense::Hovering, - render: self, - }; - rsc.run_event::(*id, data, state); - } - if sensed { - break; - } - } - rsc.events_mut().get_type::().active = active; - } -} - -pub fn should_run( - senses: &CursorSenses, - cursor: &CursorState, - hover: ActivationState, -) -> Option { - for sense in senses.iter() { - if match sense { - CursorSense::PressStart(button) => cursor.buttons.select(button).is_start(), - CursorSense::Pressing(button) => cursor.buttons.select(button).is_on(), - CursorSense::PressEnd(button) => cursor.buttons.select(button).is_end(), - CursorSense::HoverStart => hover.is_start(), - CursorSense::Hovering => hover.is_on(), - CursorSense::HoverEnd => hover.is_end(), - CursorSense::Scroll => cursor.scroll_delta != Vec2::ZERO, - } { - return Some(*sense); - } - } - None -} - -impl ActivationState { - pub fn is_start(&self) -> bool { - *self == Self::Start - } - pub fn is_on(&self) -> bool { - *self == Self::Start || *self == Self::On - } - pub fn is_end(&self) -> bool { - *self == Self::End - } - pub fn is_off(&self) -> bool { - *self == Self::End || *self == Self::Off - } - pub fn update(&mut self, on: bool) { - *self = match *self { - Self::Start => match on { - true => Self::On, - false => Self::End, - }, - Self::On => match on { - true => Self::On, - false => Self::End, - }, - Self::End => match on { - true => Self::Start, - false => Self::Off, - }, - Self::Off => match on { - true => Self::Start, - false => Self::Off, - }, - } - } - - pub fn end_frame(&mut self) { - match self { - Self::Start => *self = Self::On, - Self::End => *self = Self::Off, - _ => (), - } - } -} - -impl EventLike for CursorSense { - type Event = CursorSenses; - fn into_event(self) -> Self::Event { - self.into() - } -} - -impl Deref for CursorSenses { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for CursorSenses { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -impl From for CursorSenses { - fn from(val: CursorSense) -> Self { - CursorSenses(vec![val]) - } -} - -impl BitOr for CursorSense { - type Output = CursorSenses; - - fn bitor(self, rhs: Self) -> Self::Output { - CursorSenses(vec![self, rhs]) - } -} - -impl BitOr for CursorSenses { - type Output = Self; - - fn bitor(mut self, rhs: CursorSense) -> Self::Output { - self.0.push(rhs); - self - } -} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..d114587 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,83 @@ +//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's +//! 2026-09-07 request: "add another button to copy input event info ... +//! instrument a lot of the code with timings"), and the one place both +//! call sites' `iris::frame` line is written from. +//! +//! **Why a crate-level flag instead of `log::log_enabled!`/ +//! `log::set_max_level`**: the app already installs its logger at +//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so +//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring +//! regardless of what this instrument would prefer -- `RingLogger::enabled` +//! is unconditionally `true` by design (its own doc: "the ring wants +//! everything"). So the level alone cannot give these two targets a +//! default-off switch; the gate has to live on this side, checked before +//! `log::debug!` is even reached. +//! +//! **Why default off matters**: the ring is 2000 lines / 256 KiB +//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a +//! 120Hz session logging both a line per touch sample and a line per frame +//! fills that in seconds -- so a caller turns this on only for the length +//! of whatever is being investigated, and the report says so at its top +//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top +//! of whatever builds the report). +//! +//! **Not yet wired to a control**: the Diagnostics pane that would hold the +//! switch is in `iris/android-app/src/bench_client.rs`, which another agent +//! has open at the same time this was written. `set_trace` is the whole +//! surface a button needs; wiring one is a follow-up. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use iris_core::UiRenderState; + +static TRACE: AtomicBool = AtomicBool::new(false); + +/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by +/// default -- see the module doc for why turning the level on alone would +/// not do it. +pub fn set_trace(on: bool) { + TRACE.store(on, Ordering::Relaxed); +} + +/// Whether the `iris::input`/`iris::frame` lines are enabled right now -- +/// what a report's header reads before deciding what to say about the +/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state +/// first"). +pub fn trace_enabled() -> bool { + TRACE.load(Ordering::Relaxed) +} + +/// One `iris::frame` line, called once per frame from each backend's own +/// frame function -- `android::view::IrisViewPeer::render`, +/// `default::DefaultApp::window_event`'s `RedrawRequested` arm, and +/// `harness::Harness::frame` -- after the draw (or, on the harness, where a +/// draw would be; `draw` is `Duration::ZERO` there since nothing is +/// actually submitted to a GPU). +/// +/// `render.update(...)` must already have run this frame: this reads back +/// what it recorded (`UiRenderState::last_layout_duration`/ +/// `last_redraw_kind`/`frame_number`) rather than timing anything itself, +/// so a caller's own measurement of the phase around `update()` and around +/// its own draw call are the only two `Instant` pairs in the whole path -- +/// see each call site's own comment for why it is not restructured to fit +/// this instead. +pub fn log_frame(render: &UiRenderState, now: Instant, draw: Duration, animating: bool) { + if !trace_enabled() { + return; + } + let since_input = render + .time_since_input(now) + .map(|d| format!("{}ms", d.as_millis())) + .unwrap_or_else(|| "none".to_string()); + log::debug!( + target: "iris::frame", + "iris frame: n={} now={}ms since_input={since_input} layout={:?} draw={:?} \ + redraw={:?} primitives={} animating={animating}", + render.frame_number(), + now.duration_since(render.epoch()).as_millis(), + render.last_layout_duration(), + draw, + render.last_redraw_kind(), + render.active_primitive_count(), + ); +} diff --git a/src/event.rs b/src/event.rs index f740f75..d79d32f 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2,7 +2,21 @@ use iris_core::*; use iris_macro::*; use std::sync::Arc; -use crate::default::{TaskCtx, TaskUpdate, Tasks}; +use crate::task::{TaskCtx, TaskUpdate, Tasks}; + +/// A field's Enter key (without a shift, in a multi-line field). Backend +/// input handling raises it directly rather than through `on`, since a +/// field does not know ahead of time whether anything is listening. +#[derive(Eq, PartialEq, Hash, Clone)] +pub struct Submit; +impl Event for Submit {} + +/// A field's content changed as a result of input the backend applied +/// directly to it (a keystroke, an IME commit) rather than through a +/// widget event handler. +#[derive(Eq, PartialEq, Hash, Clone)] +pub struct Edited; +impl Event for Edited {} pub trait Eventable: WidgetLike { fn on( @@ -30,13 +44,19 @@ impl, Rsc: HasEvents, Tag> Eventable for WL { widget_trait! { pub trait TaskEventable; - fn task_on<'a, E: EventLike, F: AsyncWidgetEventFn>( + /// No `Data: Send` bound, deliberately: the registered handler below + /// takes `|_, rsc|` and the event's data never crosses into the + /// spawned future -- `AsyncEventIdCtx` carries the widget id and the + /// task handle and nothing else. The bound used to be here anyway, and + /// it was the whole reason `CursorData`'s pointer state was behind a + /// `Mutex` rather than owned by the input handler (Iris, 2026-09-08: + /// never reach for a lock first). + fn task_on>( self, event: E, f: F, ) -> impl WidgetIdFn - where ::Data<'a>: Send, - for<'b> F::CallRefFuture<'b>: Send, + where for<'b> F::CallRefFuture<'b>: Send, { let f = Arc::new(f); move |rsc| { diff --git a/src/harness.rs b/src/harness.rs new file mode 100644 index 0000000..c34050b --- /dev/null +++ b/src/harness.rs @@ -0,0 +1,428 @@ +//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven +//! in-process with **no window, no compositor and no GPU**, on an +//! explicit clock and a replayed touch stream. +//! +//! `layout_tests.rs` and `sense_tests.rs` already build trees over +//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea +//! carried far enough to open a real app screen (`transcript-ui`'s, over +//! the bench fixture -- see the `transcript-fixture` crate) at the +//! phone's size and density, feed it a recorded flick, and assert on +//! where the list ended up. What it answers that the emulator cannot: +//! Android batches a 120Hz flick into one or two `MotionEvent`s +//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced +//! ones -- so the gesture shape a finger actually makes is only +//! reproducible from a *file* of timestamped samples. +//! +//! It is a third backend in the sense `default/` and `android/` are, and +//! deliberately the smallest one: the platform half of each of those +//! (a surface, an IME, a URL opener) becomes a recorded fact here -- +//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] -- +//! so a test can assert the platform *was asked*, which is the only +//! thing either backend does with those calls anyway. +//! +//! ```ignore +//! let mut h = Harness::new(phone_size(), PHONE_SCALE); +//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows); +//! h.frame(0); +//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?); +//! h.frames_until(20, 2_000, 8); +//! ``` + +use crate::prelude::*; +use std::marker::PhantomData; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +/// One replayed pointer sample: what Android's `MotionEvent` carries, cut +/// down to the part iris reads (`IrisViewPeer::on_touch_event`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TouchAction { + Down, + Move, + Up, + /// The gesture taken away by the system (a parent view claiming it, a + /// call arriving, the swipe up from the bottom edge to leave the + /// app). It ends the press, because a release that never arrives + /// leaves pointer capture held forever -- but it is not a release, + /// and nothing follows from it: no tap, no selection, no fling. See + /// `CursorState::cancelled`, which is what it sets. + Cancel, +} + +impl TouchAction { + fn parse(word: &str) -> Option { + match word { + "down" => Some(Self::Down), + "move" => Some(Self::Move), + "up" => Some(Self::Up), + "cancel" => Some(Self::Cancel), + _ => None, + } + } + + /// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands + /// [`crate::sense::log_input_event`], so an `iris::input` line and a + /// `.touch` file agree on one spelling of each action. + pub fn word(self) -> &'static str { + match self { + Self::Down => "down", + Self::Move => "move", + Self::Up => "up", + Self::Cancel => "cancel", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct TouchSample { + /// Milliseconds since the start of the recording -- the sample's own + /// time, which becomes `CursorState::time`. See that field's doc for + /// why a replay may not date its samples by when the loop got to + /// them. + pub t_ms: u64, + pub action: TouchAction, + pub pos: Vec2, +} + +/// A recorded gesture: one `t_ms action x y` line per sample, `#` and +/// blank lines ignored. Deliberately a plain text file rather than a +/// serialisation format -- it is written by hand as often as it is +/// recorded, and a diff of one has to be readable. +pub struct TouchScript { + pub samples: Vec, +} + +impl TouchScript { + /// Parses a script, naming the line and what was wrong with it: these + /// are hand-written files, so a typo is the ordinary case and + /// "expected 4 fields" without a line number is not enough to fix it. + pub fn parse(text: &str) -> Result { + let mut samples: Vec = Vec::new(); + for (i, line) in text.lines().enumerate() { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1); + let mut words = line.split_whitespace(); + let (Some(t), Some(action), Some(x), Some(y), None) = ( + words.next(), + words.next(), + words.next(), + words.next(), + words.next(), + ) else { + return Err(at("expected `t_ms action x y`")); + }; + let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?; + let action = TouchAction::parse(action) + .ok_or_else(|| at("action is not down/move/up/cancel"))?; + let x: f32 = x.parse().map_err(|_| at("x is not a number"))?; + let y: f32 = y.parse().map_err(|_| at("y is not a number"))?; + if let Some(last) = samples.last() + && t_ms < last.t_ms + { + return Err(at("samples must be in time order")); + } + samples.push(TouchSample { + t_ms, + action, + pos: Vec2::new(x, y), + }); + } + Ok(Self { samples }) + } + + /// The last sample's time, i.e. how long the recording runs. + pub fn end_ms(&self) -> u64 { + self.samples.last().map(|s| s.t_ms).unwrap_or(0) + } +} + +/// Counts the frames something asked for without drawing any -- the +/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for +/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can +/// tell "nothing moved" from "nothing was even asked to move". +#[derive(Default)] +pub struct RedrawCounter(AtomicUsize); + +impl RedrawCounter { + pub fn count(&self) -> usize { + self.0.load(Ordering::Relaxed) + } +} + +impl RequestRedraw for RedrawCounter { + fn request_redraw(&self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// The harness's app state: what each real backend keeps for the platform +/// half, recorded instead of performed. +pub struct HarnessState { + pub root: Option, + pub focus: Option>, + last_click: Instant, + /// How many times a tap asked for the keyboard (`FocusHost:: + /// focus_gained` with a region -- `showSoftInput` on Android, + /// `set_ime_cursor_area` on winit). The platform's own answer is not + /// available here, so this says what was *asked*, and a test must not + /// read it as "the IME is up". + pub keyboard_shown: usize, + /// Every URL a tapped link asked the platform to open, in order. + pub opened_urls: Vec, +} + +impl HarnessState { + fn new() -> Self { + Self { + root: None, + focus: None, + last_click: Instant::now(), + keyboard_shown: 0, + opened_urls: Vec::new(), + } + } +} + +impl HasRoot for HarnessState { + fn set_root(&mut self, root: StrongWidget) { + self.root = Some(root); + } +} + +impl FocusHost for HarnessState { + fn recent_click(&mut self) -> bool { + crate::attr::recent_click(&mut self.last_click) + } + fn set_focus(&mut self, id: Option>) { + self.focus = id; + } + fn is_focused(&self, id: WeakWidget) -> bool { + self.focus == Some(id) + } + fn focus_gained(&mut self, region: Option) { + if region.is_some() { + self.keyboard_shown += 1; + } + } +} + +impl OpenUrl for HarnessState { + fn open_url(&mut self, url: &str) { + self.opened_urls.push(url.to_string()); + } +} + +/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/ +/// `AndroidRsc` minus the windowing, for the same reason those two are +/// separate types (`AndroidRsc`'s own doc). +pub struct HarnessRsc { + pub ui: UiData, + pub events: EventManager, + pub tasks: Tasks, + pub state: WidgetState, + _state: PhantomData, +} + +impl UiRsc for HarnessRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.state.remove(id); + } +} + +impl HasState for HarnessRsc { + type State = HarnessState; +} + +impl HasEvents for HarnessRsc { + fn events(&self) -> &EventManager { + &self.events + } + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for HarnessRsc { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for HarnessRsc { + fn widget_state(&self) -> &WidgetState { + &self.state + } + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.state + } +} + +impl> std::ops::Index for HarnessRsc { + type Output = I::Output; + fn index(&self, index: I) -> &Self::Output { + index.get(self) + } +} + +impl> std::ops::IndexMut for HarnessRsc { + fn index_mut(&mut self, index: I) -> &mut Self::Output { + index.get_mut(self) + } +} + +/// A screen running with no window: the widget tree, the frame loop and +/// the pointer, all advanced by the caller. See the module doc. +pub struct Harness { + pub rsc: HarnessRsc, + pub render: UiRenderState, + pub state: HarnessState, + task_recv: TaskMsgReceiver, + redraws: Arc, + cursor: CursorState, + /// Time zero. Every `t_ms` in this harness is an offset from here, so + /// nothing reads the wall clock -- see [`Self::at`]. + base: Instant, + size: Vec2, +} + +impl Harness { + /// `size` is in physical pixels and `density` is physical pixels per + /// dp, the pair Android reads from the surface and + /// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The + /// phone's own numbers are `transcript_fixture::PHONE_SIZE`/ + /// `PHONE_SCALE`. + pub fn new(size: Vec2, density: f32) -> Self { + let redraws = Arc::new(RedrawCounter::default()); + let (tasks, task_recv) = Tasks::init(redraws.clone()); + let mut rsc = HarnessRsc { + ui: UiData::default(), + events: EventManager::default(), + tasks, + state: WidgetState::default(), + _state: PhantomData, + }; + rsc.ui.text.density = density; + let mut render = UiRenderState::new(); + render.set_density(density); + render.resize(size); + Self { + rsc, + render, + state: HarnessState::new(), + task_recv, + redraws, + cursor: CursorState::default(), + base: Instant::now(), + size, + } + } + + /// The `Instant` this harness means by `t_ms`. Public because a + /// caller driving `ScrollController::tick` or `DragGesture` by hand needs + /// to date those calls on the same clock the touch samples use. + pub fn at(&self, t_ms: u64) -> Instant { + self.base + Duration::from_millis(t_ms) + } + + pub fn size(&self) -> Vec2 { + self.size + } + + /// How many frames were asked for so far -- see [`RedrawCounter`]. + pub fn redraws(&self) -> usize { + self.redraws.count() + } + + /// One frame at `t_ms`: drain finished tasks, advance anything + /// animating, lay out and "draw". The same three steps + /// `DefaultApp::window_event`'s `RedrawRequested` arm and + /// `IrisViewPeer::render` take, minus handing primitives to a GPU. + pub fn frame(&mut self, t_ms: u64) { + while let Ok(update) = self.task_recv.try_recv() { + update(&mut self.state, &mut self.rsc); + } + let now = self.at(t_ms); + let animating = self.rsc.ui.tick_animations(now); + self.render.update(&self.state.root, &mut self.rsc); + // No GPU here, so there is no draw phase to time -- `draw` is + // always zero. `layout`/`redraw`/`primitives` are still real, + // because `render.update` just ran; see + // `iris::diagnostics::log_frame`'s own doc for why this reads + // those back rather than timing anything itself. + crate::diagnostics::log_frame(&self.render, now, Duration::ZERO, animating); + } + + /// Frames every `step_ms` up to and including `end_ms` -- what a + /// fling needs, since it moves only while something ticks it + /// (`ScrollController::fling`'s doc). Returns the time of the last frame run. + pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { + debug_assert!(step_ms > 0, "a frame loop with no step never ends"); + let mut t = from_ms; + while t <= end_ms { + self.frame(t); + t += step_ms; + } + t - step_ms + } + + /// One pointer sample through the sensors, then the frame it belongs + /// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one + /// call. Each sample is its own input frame, dated by the sample + /// rather than by when this ran. + pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) { + self.cursor.time = self.at(t_ms); + self.cursor.pos = pos; + match action { + TouchAction::Down => { + self.cursor.exists = true; + self.cursor.buttons.left.update(true); + } + TouchAction::Move => {} + TouchAction::Up => self.cursor.buttons.left.update(false), + // The platform taking the gesture away, not the finger + // lifting -- see `CursorState::cancelled`. + TouchAction::Cancel => { + self.cursor.buttons.left.update(false); + self.cursor.cancelled = true; + } + } + // Layer 1's half of `iris::input` (`sense::log_input_event`'s own + // doc): no batching happens here, so `historical` is always empty + // and `t_ms` is the script's own column, which is what makes this + // round-trip through `report_to_touch.py` back into an identical + // `TouchScript`. + crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]); + let cursor = self.cursor.clone(); + self.render + .run_sensors(&mut self.rsc, &mut self.state, cursor, self.size); + self.frame(t_ms); + self.cursor.end_frame(); + } + + /// Replays a whole recorded gesture. Nothing is inserted between the + /// samples: a file with three lines produces three input frames, so + /// the batched shape a real flick arrives in is preserved exactly as + /// recorded rather than smoothed into evenly-spaced motion. + pub fn replay(&mut self, script: &TouchScript) { + for sample in &script.samples { + self.touch(sample.action, sample.pos, sample.t_ms); + } + } +} diff --git a/src/layout_tests.rs b/src/layout_tests.rs new file mode 100644 index 0000000..2f3a1fe --- /dev/null +++ b/src/layout_tests.rs @@ -0,0 +1,1079 @@ +//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests +//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do +//! not touch a GPU or a window, so a tree can be built and driven directly. +//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the +//! CPU-side layout/move machinery LAYOUT.md is about. + +use crate::prelude::*; + +/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the +/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so +/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a +/// second copy of the same harness. +pub(crate) struct TestRsc { + pub(crate) ui: UiData, +} + +impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } +} + +/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large +/// enough that an O(N) regression in the move path would show up as a +/// non-trivial counter rather than being lost in noise (LAYOUT.md section +/// 8, condition 3, using rects rather than glyphs to avoid pulling the font +/// stack into a plain unit test). Returns the scroll widget (weak, for +/// mutating it later), the erased root to draw, and the rows (weak, for +/// hit-testing one of them). +fn scrolled_rects( + rsc: &mut TestRsc, + n: usize, +) -> (WeakWidget, StrongWidget, Vec>) { + let mut span = Span::empty(Dir::DOWN); + let mut rects = Vec::with_capacity(n); + for _ in 0..n { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + rects.push(rect.weak()); + // Each row gets a fixed height so the span's total content is + // genuinely taller than the viewport -- rest-sized rows would just + // divide whatever space is offered and never need scrolling. + let row = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(10.0)), + }); + span.push(row.any()); + } + let span = rsc.ui.widgets.add_strong(span); + let scroll = rsc + .ui + .widgets + // Anchored at the *start*: every test below scrolls down from + // the top and states its sign convention against that. An + // end-anchored area now sits at its end from its first drawn + // frame (`Scroll::draw` measures and places in the same frame), + // so `Pin::End` here would mean scrolling down from a + // position that is already the bottom -- a clamped no-op, which + // reads as "the move path is broken" rather than as the test + // starting somewhere it did not mean to. + .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start)); + let weak = scroll.weak(); + (weak, scroll.any(), rects) +} + +#[test] +fn an_unchanged_frame_draws_and_rewrites_nothing() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, root, _rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 20000.0)); + + render.update(&root, &mut rsc); + // Two, not one: the first offers `ScrollArea`'s content the container's + // own length as a placeholder (nothing has been measured yet) and + // `Scroll::draw` asks to be drawn again once it knows the real one, + // which the second update is. Only after that is the tree settled -- + // see `scrolling_moves_in_o1_without_a_redraw`'s own note on the + // same first draw. + render.update(&root, &mut rsc); + render.take_counters(); // discard the first, real draws + + render.update(&root, &mut rsc); + let (draws, rewrites, moves, _shapes) = render.take_counters(); + assert_eq!((draws, rewrites, moves), (0, 0, 0)); +} + +#[test] +fn scrolling_moves_in_o1_without_a_redraw() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, _rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + + // The first draw offers `ScrollArea`'s content a zero-height region + // (nothing has been measured yet) and learns the real content length + // from what comes back; `update()` only redraws widgets actually + // marked dirty, so that corrected length is not reflected in the + // content's own *active* region until something -- here a no-op + // scroll tick -- actually asks `ScrollArea` to redraw again. Only after + // that warm-up does the content's offered size stop changing between + // draws, which is what makes a further, real scroll tick a same-size + // move instead of a resize. See scroll.rs. + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + // Negative: `scroll`'s sign convention subtracts from `amt`, and + // `amt` starts at (and is clamped to) 0 at the top of the content, so + // a *positive* argument here would be scrolling further up (a no-op, + // already clamped) rather than actually moving anything. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); + render.update(&root, &mut rsc); + let (draws, _rewrites, moves, _shapes) = render.take_counters(); + + // The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and + // 1 move_offsets write, independent of how many rects are in the + // scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is + // marked dirty by `scroll()` and its own body is cheap arithmetic with + // no primitives of its own, so it is the one real `Widget::draw` this + // counts -- the 500 rects underneath move via the O(1) chain and are + // never revisited. + assert_eq!(draws, 1, "only Scroll itself should redraw"); + assert_eq!(moves, 1, "the scrolled subtree should move in one write"); +} + +#[test] +fn hit_testing_follows_a_scrolled_widget() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, rects) = scrolled_rects(&mut rsc, 500); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let target = &rects[2]; + let before = render.resolved_region(target, &rsc).unwrap(); + + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.0); + render.update(&root, &mut rsc); + + let after = render.resolved_region(target, &rsc).unwrap(); + let before_px = before.to_px((800.0, 600.0).into()); + let after_px = after.to_px((800.0, 600.0).into()); + + // Scrolling by -37 moves `amt` from 0 to 37, sliding the content's + // top-left up by 37px -- `resolved_region` (the CPU twin of the vertex + // shader's chain walk) must reflect that immediately, not the + // pre-scroll position, or a tap routed through it would land on + // whatever is now at the old coordinates instead of this widget. + assert!( + (after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01, + "before={before_px:?} after={after_px:?}" + ); +} + +/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one +/// it set for itself -- `redraw` feeds it straight back in as the inherited +/// mask, so storing the set one hands a `Masked` its own mask the second +/// time round -- which `Painter::set_mask` asserts against, since a mask +/// that chains to itself is a clip loop. That was an abort the first time +/// the composer's new scroll area was redrawn on the emulator; a targeted +/// redraw of a `Masked` is what any real screen does whenever anything +/// inside it changes. +#[test] +fn redrawing_a_masked_widget_does_not_nest_its_own_mask() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8); + let masked = rsc.ui.widgets.add_strong(Masked { + shape: None, + inner: inner_root, + }); + let masked_id = masked.id(); + let root = masked.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + render.redraw(masked_id, &mut rsc); + render.redraw(masked_id, &mut rsc); + + assert_eq!( + render.active.get(&masked_id).unwrap().mask, + MaskIdx::NONE, + "a `Masked` at the root is drawn under no mask of its own" + ); +} + +#[test] +fn a_mask_stays_put_while_its_scrolled_content_moves() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500); + let masked = rsc.ui.widgets.add_strong(Masked { + shape: None, + inner: inner_root, + }); + let masked_id = masked.id(); + let root = masked.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let masked_slot_before = render.active.get(&masked_id).unwrap().move_slot; + let mask_delta_before = rsc.ui.move_offsets[masked_slot_before.idx()].delta; + + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); + render.update(&root, &mut rsc); + + let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot; + let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta; + + // `Masked` itself is never the target of a `mov`/`reposition` here -- + // only its scrolled child is -- so the slot its own mask references + // (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must + // still read zero after the scroll. The visible counterpart of this + // (the clipped edge follows the scroll while the viewport border does + // not) is `iris/run-headless.sh`'s job to catch in a real frame; this + // is the numeric half, on the same data the fragment shader's + // `resolve_move` reads. See LAYOUT.md section 2b. + assert_eq!(mask_delta_before, [0.0, 0.0]); + assert_eq!(mask_delta_after, [0.0, 0.0]); +} + +/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape +/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded, +/// `rest`-width `TextEdit`, itself the second child of an outer +/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/ +/// resource plumbing `composer.rs`'s builders need, to isolate whether the +/// bug Iris reported on 2026-09-06 ("text seems to not appear in box") +/// is this crate's layout engine or something specific to the real +/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain +/// insert exercises the exact redraw path a keystroke does. +fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget) { + let field = wtext("") + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(18) + .color(UiColor::WHITE) + .add(rsc); + let bar = (field.pad(dp(12)).width(rest(1)),) + .span(Dir::RIGHT) + .background(rect(UiColor::new(40, 40, 46, 255))) + .add(rsc); + let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc); + let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any(); + (field, tree) +} + +/// The reproduction itself. A window this tall stands in for the keyboard +/// closed; the second, shorter `resize` stands in for `adjustResize` +/// shrinking the surface when the IME opens -- exactly the sequence +/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing +/// happens both before and after, since Iris's report was specifically +/// that text typed *after* the keyboard was already up did not appear. +#[test] +fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (field, root) = composer_like_tree(&mut rsc); + let mut render = UiRenderState::new(); + + render.resize((1080.0, 2298.0)); + render.update(&root, &mut rsc); + // Focusing a field is what places its caret on a real tap + // (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert + // with no caret is a routing bug rather than a state to simulate -- + // `insert_str`'s own `debug_assert!` says so, and caught this test + // typing into an unfocused field when it was added. + field + .edit(&mut rsc) + .select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false); + field.edit(&mut rsc).insert("a"); + render.update(&root, &mut rsc); + + let before_px = render.window_region(&field, &rsc).unwrap(); + // The field is one line plus 12dp of padding on a 2298-tall window -- + // nowhere near the whole window's height, and anchored at the bottom. + assert!( + before_px.bot_right.y - before_px.top_left.y < 200.0, + "before a resize: {before_px:?}" + ); + assert!( + before_px.top_left.y > 1800.0, + "expected the bar near the bottom before a resize: {before_px:?}" + ); + + // The keyboard opens: a real `surface_changed`/`resize` to a shorter + // window, then a further keystroke -- the redraw that must land in the + // bar's new (also short) region, not whatever region a provisional + // measurement pass used along the way. + render.resize((1080.0, 1478.0)); + render.update(&root, &mut rsc); + field.edit(&mut rsc).insert("b"); + render.update(&root, &mut rsc); + + let after_px = render.window_region(&field, &rsc).unwrap(); + assert!( + after_px.bot_right.y - after_px.top_left.y < 200.0, + "after a resize + keystroke: {after_px:?}" + ); + assert!( + after_px.top_left.y > 1200.0, + "expected the bar near the bottom of the shorter window: {after_px:?}" + ); +} + +/// `ScrollArea` used to be documented as resolving its own lengths against +/// `Painter::output_size` -- the window -- which read as if a scroll area +/// smaller than the screen could not work, and cost a session's +/// investigation before the composer was wired up (docs/RUST.md, +/// 2026-09-06). It measures `painter.px_size()` now, so this pins the +/// three numbers that follow from the offered box: what it reports +/// upward, what its capping parent reports, and how far it can pan. +#[test] +fn a_scroll_measures_the_box_it_was_offered_not_the_window() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let tall = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(1000.0)), + }); + let scroll = rsc + .ui + .widgets + // Start-anchored, so the `scroll(-37.0)` below has somewhere to + // go -- see `scrolled_rects`' note on the same choice. + .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); + let scroll_w = scroll.weak(); + let scroll_id = scroll.id(); + let capped = rsc.ui.widgets.add_strong(MaxSize { + inner: scroll.any(), + x: None, + y: Some(Len::abs(100.0)), + }); + let capped_id = capped.id(); + let root = capped.any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + // Two passes: the first offers the content a zero-length region + // (nothing measured yet) and learns the real content length from what + // comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why + // that warm-up is deliberate rather than a bug. + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + // Reports the *content*, so the cap above it has something to cap; + // reporting the container instead would make the answer a function of + // itself, since the container is sized from this very number. + assert_eq!( + render.active.get(&scroll_id).unwrap().size.y, + Len::abs(1000.0) + ); + assert_eq!( + render.active.get(&capped_id).unwrap().size.y, + Len::abs(100.0), + "the cap, not the content and not the window" + ); + + // Panning is bounded by content minus *container*: 900, not the 400 + // a 600px window would give. The draw is what spends the delta -- a + // controller banks it until the layout that knows where the content + // ends (`ScrollController::take_delta`). + rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0); + render.update(&root, &mut rsc); + assert!( + (rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01, + "amt={}", + rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() + ); +} + +/// The half `hit_testing_follows_a_scrolled_widget` could not see: it +/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own +/// `region` is stale and is corrected entirely by the move chain. The +/// moved widget itself had its `region` updated *and* the chain delta +/// added on top, so its hit box sat at twice the pan -- which is why a +/// finger pan of the composer left its field untappable. See +/// `ActiveData::move_applied`. +#[test] +fn a_panned_widgets_own_hit_box_moves_exactly_once() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let tall = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(1000.0)), + }); + let tall_w = tall.weak(); + let scroll = rsc + .ui + .widgets + // Start-anchored, so the `scroll(-37.0)` below has somewhere to + // go -- see `scrolled_rects`' note on the same choice. + .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); + let scroll_w = scroll.weak(); + let root = scroll.any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let before = render.window_region(&tall_w, &rsc).unwrap(); + rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0); + render.update(&root, &mut rsc); + let after = render.window_region(&tall_w, &rsc).unwrap(); + + assert!( + (after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01, + "the pan was applied twice: before={before:?} after={after:?}" + ); +} + +/// A `Masked` used to allocate a **new** mask slot on every draw, and +/// `draw_inner`'s unchanged-region fast path means its descendants are +/// mostly *not* redrawn with it -- so they went on referencing the slot +/// they were first drawn under, whose region had since stopped being the +/// widget's. Measured 2026-09-06 on the composer's tree: four live mask +/// entries, none of them the `Masked`'s current box, and the field it was +/// meant to clip drew nothing at all on the emulator. The slot is +/// allocated once and rewritten in place now (`ActiveData::own_mask`), so +/// this pins both halves: one entry, and that entry is the widget's own +/// region. +#[test] +fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8); + let masked = rsc.ui.widgets.add_strong(Masked { + shape: None, + inner: inner_root, + }); + let masked_id = masked.id(); + // Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling, + // which is what moves the bar away from the provisional slot it is + // first drawn at -- the move that left the stale mask behind. + let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK)); + let filler = rsc.ui.widgets.add_strong(Sized { + inner: filler.any(), + x: None, + y: Some(rest(1)), + }); + let capped = rsc.ui.widgets.add_strong(MaxSize { + inner: masked.any(), + x: None, + y: Some(Len::abs(60.0)), + }); + let mut span = Span::empty(Dir::DOWN); + span.push(filler.any()); + span.push(capped.any()); + let root = rsc.ui.widgets.add_strong(span).any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + for _ in 0..3 { + render.update(&root, &mut rsc); + render.redraw(masked_id, &mut rsc); + } + + assert_eq!( + rsc.ui.masks.iter().count(), + 1, + "one `Masked` must own exactly one mask slot, however often it is redrawn" + ); + let mask = *rsc.ui.masks.iter().next().unwrap(); + assert_eq!( + render.primitives.instance(mask.primitive).region, + render.active.get(&masked_id).unwrap().region, + "the mask a descendant clips against must be this widget's current box" + ); +} + +/// A `dp` cap that has done its job must be reported in pixels. `Span` +/// places a child using the `abs`/`rel` of the length it reported, so a +/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's +/// bar a slot of **zero** the moment its content grew past six lines -- +/// and the `ScrollArea` inside then measured its container at -63px (the +/// padding, subtracted from nothing) and panned the whole message out of +/// view. Measured on this checkout's emulator, 2026-09-06: +/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`. +#[test] +fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let tall = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(1000.0)), + }); + let capped = rsc.ui.widgets.add_strong(MaxSize { + inner: tall.any(), + x: None, + y: Some(Len::dp(100.0)), + }); + let capped_w = capped.weak(); + let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK)); + let filler = rsc.ui.widgets.add_strong(Sized { + inner: filler.any(), + x: None, + y: Some(rest(1)), + }); + let mut span = Span::empty(Dir::DOWN); + span.push(filler.any()); + span.push(capped.any()); + let root = rsc.ui.widgets.add_strong(span).any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.set_density(2.5); + render.update(&root, &mut rsc); + render.update(&root, &mut rsc); + + let box_px = render.window_region(&capped_w, &rsc).unwrap(); + let height = box_px.bot_right.y - box_px.top_left.y; + assert!( + (height - 250.0).abs() < 0.01, + "expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})" + ); +} + +/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on +/// the branch that fix had no reason to touch: `draw_inner`'s +/// size-independent fast path rewrites a widget's primitives *in place* +/// and leaves its move slot alone, so unlike `mov` there is no slot delta +/// for `region` to have absorbed. Counting one there anyway makes +/// `resolved_region` subtract a delta the chain never held, and the +/// widget's hit box lands short of where it is drawn by exactly the +/// distance it just moved -- with nothing on screen to say so, since the +/// primitives are in the right place. +#[test] +fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let spacer = rsc.ui.widgets.add_strong(Sized { + inner: top.any(), + x: None, + y: Some(Len::abs(100.0)), + }); + let spacer_w = spacer.weak(); + // `Rect` is `is_size_independent`, so growing the spacer above it + // offers this one a region that changed *both* position and size -- + // the one shape that reaches the branch under test. + let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let below_w = below.weak(); + let mut span = Span::empty(Dir::DOWN); + span.push(spacer.any()); + span.push(below.any()); + let root = rsc.ui.widgets.add_strong(span).any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + // `Span` draws each child once at the full region to measure it and + // then places it, so this widget has already been through the branch + // once by the end of the very first frame. + let first = render.window_region(&below_w, &rsc).unwrap(); + assert!( + (first.top_left.y - 100.0).abs() < 0.01, + "hit box at {:?}, drawn at y=100", + first.top_left + ); + + rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0)); + render.update(&root, &mut rsc); + let after = render.window_region(&below_w, &rsc).unwrap(); + assert!( + (after.top_left.y - 250.0).abs() < 0.01, + "hit box at {:?}, drawn at y=250", + after.top_left + ); +} + +/// A parent that both `mov`s a child (its own layout moved the box it +/// offers) and `reposition`s it inside that box in the same frame -- what +/// `LazySpan::place`'s Bottom-known branch does once a row's cached height +/// stops matching what the row reports, which is reachable as soon as a +/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a"). +struct MoveThenPlace { + inner: StrongWidget, + /// Where the child is *offered* a (constant-size) box, moved between + /// frames by the test. + offer_top: f32, + /// Where the child is then placed within this widget's own region. + place_top: f32, +} + +impl Widget for MoveThenPlace { + fn draw(&mut self, painter: &mut Painter) -> Size { + let offer = UiRegion::new( + UiSpan::FULL, + UiSpan::new( + UiScalar::abs(self.offer_top), + UiScalar::abs(self.offer_top + 40.0), + ), + ); + painter.widget_within(&self.inner, offer); + let place = UiRegion::new( + UiSpan::FULL, + UiSpan::new( + UiScalar::abs(self.place_top), + UiScalar::abs(self.place_top + 40.0), + ), + ); + painter.reposition(&self.inner, place); + Size::default() + } +} + +/// `mov` accumulates a delta onto a widget's move slot and `reposition` +/// overwrites it, and both can legitimately land on one widget in one +/// frame (see `MoveThenPlace`). `reposition` used to write its own delta +/// alone, which dropped the move and put the child back at the position +/// the offered box had *before* it moved; a `debug_assert!` that +/// `move_applied` was zero hid that behind a panic instead of fixing it. +/// The slot has one owner and one meaning now -- +/// `move_applied + repositioned` -- so the child stays where it was +/// placed however its offered box moves. Fails at the offer's position +/// (200) rather than the placement's (100) without that. +#[test] +fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let child = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(40.0)), + }); + let child_w = child.weak(); + let parent = rsc.ui.widgets.add_strong(MoveThenPlace { + inner: child.any(), + offer_top: 0.0, + place_top: 100.0, + }); + let parent_w = parent.weak(); + let root = parent.any(); + + let mut render = UiRenderState::new(); + render.resize((200.0, 400.0)); + render.update(&root, &mut rsc); + let before = render.window_region(&child_w, &rsc).unwrap(); + assert!( + (before.top_left.y - 100.0).abs() < 0.01, + "the child should be drawn where it was placed, not where it was offered: {before:?}" + ); + + // Move the offered box without changing its size (the `mov` fast path) + // and place the child at the same spot as before. Marking the parent + // dirty is what a real container's own content change does; the child + // itself is untouched, which is the case `mov` exists for. + { + let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap(); + parent.offer_top = 200.0; + } + rsc.ui.widgets.needs_redraw.insert(parent_w.id()); + render.update(&root, &mut rsc); + let after = render.window_region(&child_w, &rsc).unwrap(); + assert!( + (after.top_left.y - 100.0).abs() < 0.01, + "the placement did not change, so neither should the child: before={before:?} \ + after={after:?}" + ); +} + +// --------------------------------------------------------------------- +// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1. +// +// The shape a mask clips to is a *primitive already drawn*, never a copy +// of one, so "the child's clipped corner" and "the container's own corner" +// are the same arithmetic. These say so by evaluating both and demanding +// exact equality: an approximate assertion would also pass a second copy +// of the radius that merely happened to agree. +// --------------------------------------------------------------------- + +const RADIUS: f32 = 20.0; + +/// A rounded container with `.masked_by` it, holding a `Rect::REST` child +/// that fills it -- so the child's own corners are exactly the corners +/// being clipped away. Returns the drawn state, the mask, the child, and +/// the shape primitive the mask points at. +fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) { + let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let child_id = child.id(); + let shape = rsc + .ui + .widgets + .add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); + let shape_id = shape.id(); + let root = rsc + .ui + .widgets + .add_strong(Masked { + shape: Some(shape.any()), + inner: child.any(), + }) + .any(); + + let mut render = UiRenderState::new(); + render.resize((200.0, 100.0)); + render.update(&root, rsc); + + let mask = render + .active + .get(&child_id) + .expect("the child is drawn") + .mask; + assert_ne!( + mask, + MaskIdx::NONE, + "the child was drawn with no clip at all" + ); + let slot = render + .first_primitive(shape_id) + .expect("the shape widget drew a rect"); + (render, mask, child_id, slot) +} + +/// The pass condition: the child's coverage at a corner pixel *equals* +/// the container's own coverage there. Exactly equal, because it is the +/// same primitive evaluated once -- LAYOUT.md's point 1. +#[test] +fn a_masked_child_is_clipped_by_its_container_s_own_corner() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (render, mask, _child, slot) = rounded_container(&mut rsc); + let corners = render.primitive_corners(slot, &rsc); + let radius = render + .primitives + .primitive_data::(slot) + .expect("a mask's shape is a rect") + .radius; + + // Across the whole corner arc, not one point on it: a single sample + // is satisfied by a mask that clips to the box and happens to agree + // where the two coincide. Swept from the arc's own centre -- the + // straight chord between the two ends of the arc lies *inside* the + // circle everywhere, so a walk along it never leaves the shape and + // the `outside` count below is what caught that. + let arc_center = corners.top_left + Vec2::new(radius, radius); + let (mut outside, mut inside) = (0, 0); + for i in 0..=20 { + let angle = std::f32::consts::FRAC_PI_2 * i as f32 / 20.0; + let dir = Vec2::new(-angle.cos(), -angle.sin()); + for out in [-1.5f32, 0.0, 1.5] { + let pos = arc_center + dir * (radius + out); + let container = rounded_rect_coverage(pos, corners.top_left, corners.bot_right, radius); + assert_eq!( + render.mask_coverage(mask, pos, &rsc), + container, + "at {pos:?} the child's clip and the container's own edge disagree", + ); + if container < 0.5 { + outside += 1; + } else { + inside += 1; + } + } + } + assert!( + outside > 0 && inside > 0, + "the sweep stayed on one side of the curve ({outside} out, {inside} in), so it proved \ + nothing about the corner" + ); +} + +/// A hit test asks the same question the pixels do: the corner the +/// container rounded away is not there to be pressed, and a point just +/// inside the curve is. LAYOUT.md's point 4. +#[test] +fn a_mask_s_shape_decides_what_can_be_pressed() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (render, mask, _child, slot) = rounded_container(&mut rsc); + let corners = render.primitive_corners(slot, &rsc); + + // The very corner of the box, which the radius cut off. + let cut = corners.top_left + Vec2::new(1.0, 1.0); + assert!( + !render.mask_admits(mask, cut, &rsc), + "the corner the container rounded away is still pressable", + ); + // The same distance in along the diagonal, past the curve. + let inside = corners.top_left + Vec2::new(RADIUS, RADIUS); + assert!( + render.mask_admits(mask, inside, &rsc), + "a point well inside the curve is not pressable", + ); + // And the middle of an edge, which no radius touches -- the half the + // rounding had no reason to change. + let edge = Vec2::new( + (corners.top_left.x + corners.bot_right.x) / 2.0, + corners.top_left.y + 1.0, + ); + assert!( + render.mask_admits(mask, edge, &rsc), + "a straight edge between two corners is not pressable", + ); +} + +/// Nested masks multiply, so a pixel inside two feathered corners is +/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be +/// decreased / multiplied" Iris asked for. Written as a product of the +/// two the shader would compute separately, which is what "multiply" +/// means and what an intersection test would get wrong. +#[test] +fn nested_masks_multiply_their_coverage() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let child_id = child.id(); + + let inner_shape = rsc + .ui + .widgets + .add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); + let inner_shape_id = inner_shape.id(); + let inner = rsc.ui.widgets.add_strong(Masked { + shape: Some(inner_shape.any()), + inner: child.any(), + }); + let outer_shape = rsc + .ui + .widgets + .add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS))); + let outer_shape_id = outer_shape.id(); + let root = rsc + .ui + .widgets + .add_strong(Masked { + shape: Some(outer_shape.any()), + inner: inner.any(), + }) + .any(); + + let mut render = UiRenderState::new(); + render.resize((200.0, 100.0)); + render.update(&root, &mut rsc); + + let mask = render.active.get(&child_id).expect("drawn").mask; + let one = |render: &UiRenderState, rsc: &TestRsc, id, pos| { + let slot = render.first_primitive(id).expect("a shape rect"); + let c = render.primitive_corners(slot, rsc); + let radius = render + .primitives + .primitive_data::(slot) + .unwrap() + .radius; + rounded_rect_coverage(pos, c.top_left, c.bot_right, radius) + }; + + // A point on the corner arc, where both feathers are partial -- the + // only place a product and a minimum differ. + let slot = render.first_primitive(inner_shape_id).unwrap(); + let corners = render.primitive_corners(slot, &rsc); + let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3); + let inner_cov = one(&render, &rsc, inner_shape_id, pos); + let outer_cov = one(&render, &rsc, outer_shape_id, pos); + assert!( + inner_cov > 0.0 && inner_cov < 1.0, + "the sample point is not inside a feather ({inner_cov}), so this proves nothing" + ); + assert_eq!( + render.mask_coverage(mask, pos, &rsc), + inner_cov * outer_cov, + "two nested masks must multiply, not intersect", + ); +} + +/// A plain `.masked()` -- no shape given -- still clips to the widget's +/// own box with square corners, which is what every list and scroll area +/// relies on. The half the shape work had no reason to touch, and the one +/// that would silently round every existing clip if `set_mask` ever wrote +/// a radius of its own. +#[test] +fn a_plain_mask_still_clips_to_a_square_box() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8); + let root = rsc + .ui + .widgets + .add_strong(Masked { + shape: None, + inner: inner_root, + }) + .any(); + let mut render = UiRenderState::new(); + render.resize((200.0, 100.0)); + render.update(&root, &mut rsc); + + let mask = *rsc.ui.masks.iter().next().expect("one mask"); + let corners = render.primitive_corners(mask.primitive, &rsc); + let mask_idx = MaskIdx::preset(0); + assert!( + render.mask_admits(mask_idx, corners.top_left + Vec2::new(0.5, 0.5), &rsc), + "a square clip must admit its own corner pixel", + ); + assert!( + !render.mask_admits(mask_idx, corners.top_left - Vec2::new(2.0, 2.0), &rsc), + "a square clip must reject a point outside it", + ); +} + +/// A scroll area created to be *read* opens at the beginning of its +/// content, however many frames it takes to learn how long that content +/// is. +/// +/// The bug this pins: `content_len` was `0.0` both for "nothing here" and +/// for "not drawn yet", so the first frame's clamp found a range of zero, +/// read `amt == len` as "sitting at the end", and set `snap_end` -- and +/// the frame after, now knowing the real length, jumped to it. On screen +/// that was a code fence opening at the end of its longest line, in the +/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08). +#[test] +fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { + for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); + let tall = rsc.ui.widgets.add_strong(Sized { + inner: fill, + x: None, + y: Some(Len::abs(5000.0)), + }); + let scroll = rsc + .ui + .widgets + .add_strong(ScrollArea::new(tall.any(), Axis::Y, pin)); + let weak = scroll.weak(); + let root = scroll.any(); + + let mut render = UiRenderState::new(); + render.resize((800.0, 100.0)); + // Twice: the first draw is the one that measures the content, and + // the defect only showed on the second. The touch in between is + // what asks for that second draw -- an unchanged frame draws + // nothing at all, which is the point of the frame before it. + render.update(&root, &mut rsc); + let _ = rsc.ui.widgets.get_mut(&weak); + render.update(&root, &mut rsc); + + let amt = rsc.ui.widgets.get(&weak).unwrap().amt(); + assert!( + (amt - want).abs() < 0.01, + "an area to be {name} should have opened at {want}, got {amt}" + ); + } +} + +/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another +/// `Span` places those children a slot out of step", worked around in +/// `transcript-ui/src/tool.rs` by flattening the two spans into one -- +/// which costs a tool group the inset its cards should sit inside. +/// +/// The shape is the smallest one that reproduced it there: an outer +/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children +/// are each a `Pad` around a fixed-height rect. Each rect is asserted to +/// be *drawn* where its own box is -- `primitive_corners` rather than +/// `window_region`, since the report is about what is on screen and the +/// two resolve the move chain differently. +#[test] +fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() { + const PAD: f32 = 4.0; + const ROW: f32 = 20.0; + const HEADER: f32 = 30.0; + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any(); + let header_id = header_fill.id(); + let header = rsc.ui.widgets.add_strong(Sized { + inner: header_fill, + x: None, + y: Some(Len::abs(HEADER)), + }); + let mut inner = Span::empty(Dir::DOWN); + let mut rects = Vec::new(); + for _ in 0..3 { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + rects.push(rect.weak()); + let sized = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(ROW)), + }); + let padded = rsc.ui.widgets.add_strong(Pad { + padding: Padding::uniform(PAD), + inner: sized.any(), + }); + let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any(); + let card = rsc.ui.widgets.add_strong(Stack { + children: vec![fill, padded.any()], + size: StackSize::Child(1), + }); + let wide = rsc.ui.widgets.add_strong(Sized { + inner: card.any(), + x: Some(Len::rest(1.0)), + y: None, + }); + inner.push(wide.any()); + } + let inner = rsc.ui.widgets.add_strong(inner); + let outer = rsc.ui.widgets.add_strong(Span { + children: vec![header.any(), inner.any()], + dir: Dir::DOWN, + gap: Len::ZERO, + }); + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + list.push_back(LazyItem::new(0, outer.any())); + let list = rsc.ui.widgets.add_strong(list); + let root = rsc + .ui + .widgets + .add_strong(Masked { + shape: None, + inner: list.any(), + }) + .any(); + + let mut render = UiRenderState::new(); + render.resize((200.0, 400.0)); + render.update(&root, &mut rsc); + render.update(&root, &mut rsc); + + let head_slot = render + .first_primitive(header_id) + .expect("the header drew a primitive"); + let head_top = render.primitive_corners(head_slot, &rsc).top_left.y; + for (i, rect) in rects.iter().enumerate() { + let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD; + let slot = render + .first_primitive(rect.id()) + .expect("each rect drew a primitive"); + let drawn = render.primitive_corners(slot, &rsc); + assert!( + (drawn.top_left.y - want).abs() < 0.01, + "row {i} should be drawn at y={want}, got {drawn:?}" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index fa54bba..37eb711 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,59 @@ #![feature(unboxed_closures)] #![feature(fn_traits)] -#![feature(gen_blocks)] -#![feature(associated_type_defaults)] +// Only `default::DefaultAppState::Event`'s default uses this; unused (and +// warned about) on the android target, which has no such default. +#![cfg_attr(not(target_os = "android"), feature(associated_type_defaults))] #![feature(unsize)] #![feature(option_into_flat_iter)] #![feature(async_fn_traits)] +// Two windowing backends live side by side, chosen by target rather than by +// feature flag: winit everywhere but Android, android-view on it. They are +// mutually exclusive rather than both-compiled-in because winit's own +// Android support pulls in `android-activity`, which needs one of its +// `game-activity`/`native-activity` features selected -- exactly what +// `iris-core` was kept free of, and android-view is the framework's own +// answer to the same surface on that platform. See RUST.md's I2. +#[cfg(target_os = "android")] +pub mod android; +#[cfg(not(target_os = "android"))] pub mod default; + +pub mod attr; +pub mod diagnostics; pub mod event; +pub mod harness; +pub mod platform; +pub mod sense; +pub mod state; +pub mod task; pub mod widget; +#[cfg(test)] +mod access_tests; +#[cfg(test)] +mod layout_tests; +#[cfg(test)] +mod sense_tests; + pub use iris_core as core; pub use iris_macro as macros; pub mod prelude { use super::*; + #[cfg(target_os = "android")] + pub use android::*; + #[cfg(not(target_os = "android"))] pub use default::*; + + pub use attr::*; pub use event::*; pub use iris_core::*; pub use iris_macro::*; + pub use platform::*; + pub use sense::*; + pub use state::*; + pub use task::*; pub use widget::*; pub use iris_core::util::Vec2; diff --git a/src/platform.rs b/src/platform.rs new file mode 100644 index 0000000..86ec5ad --- /dev/null +++ b/src/platform.rs @@ -0,0 +1,22 @@ +//! Capabilities a widget tree needs from whatever is hosting it, that +//! neither iris nor the app can perform itself. +//! +//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the +//! interface is declared here, below, and implemented by each backend +//! above (`default/platform.rs`, `android/platform.rs`), so a widget can +//! ask for the capability by trait bound instead of a caller threading a +//! callback down through every builder. + +/// Hand a URL to whatever the platform opens URLs with. +/// +/// One method rather than a general "run an intent"/"exec" surface: the +/// only thing a transcript needs is to follow a link a reader tapped, and +/// a narrower capability is a narrower thing to get wrong. +/// +/// **Nothing is reported back.** There is no answer worth branching on -- +/// the platform either shows a browser or does not, and both are outside +/// this process -- so failures are logged where they happen (each impl) +/// rather than turned into a `Result` every call site would discard. +pub trait OpenUrl { + fn open_url(&mut self, url: &str); +} diff --git a/src/sense.rs b/src/sense.rs new file mode 100644 index 0000000..9c628d5 --- /dev/null +++ b/src/sense.rs @@ -0,0 +1,3171 @@ +use crate::prelude::*; +use std::{ + collections::VecDeque, + ops::{BitOr, Deref, DerefMut}, + rc::Rc, + time::{Duration, Instant}, +}; + +#[derive(Clone, Copy, PartialEq)] +pub enum CursorButton { + Left, + Right, + Middle, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum CursorSense { + PressStart(CursorButton), + Pressing(CursorButton), + PressEnd(CursorButton), + HoverStart, + Hovering, + HoverEnd, + Scroll, + /// Delivered exactly once, in place of `PressEnd`, to whichever widget + /// currently holds pointer capture (`UiRenderState::capture_pointer`) + /// when the button lifts -- see `iris::sense`'s pointer-capture doc + /// and `DragGesture`. A widget must register this explicitly (it is + /// never bundled into `click_or_drag`/`unclick`, since most widgets + /// never call `capture_pointer` and have no use for it) to receive it + /// at all; ordinary hit-tested widgets keep seeing `PressEnd`. + Drop, + /// Delivered exactly once to a widget that was tracking this press + /// when **another** widget took pointer capture + /// (`UiRenderState::capture_pointer`): the gesture it was following + /// has been taken away and it will see no further frame of it, not + /// even a `PressEnd` or a `Drop`. + /// + /// A separate sense rather than a second meaning for `Drop`, because + /// the two say opposite things to the widget reading them: `Drop` is + /// "your gesture finished", and a widget acts on it (a fling, a tap, + /// a link followed), while `Cancel` is "your gesture was never + /// yours", and acting on it is exactly the bug -- a horizontal pan of + /// a code fence would follow whatever markdown link the finger + /// happened to go down on. Registered explicitly, like `Drop`. + Cancel, +} + +#[derive(Clone)] +pub struct CursorSenses(Vec); + +impl Event for CursorSenses { + type Data<'a> = CursorData<'a>; + type State = SensorState; + type Global = PointerInput; + fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { + // `Drop` is never derived from raw cursor/hover state below (the + // free `should_run`'s own arm for it is only ever asked here, + // never independently true or false against the button) -- it is + // set exclusively by `run_sensors`' pointer-capture branch, which + // has already decided this exact frame is the captured widget's + // terminal event. Matching it by identity, ahead of the general + // derivation, matters because a captured widget's registration + // list very likely also carries `PressEnd` (`unclick()`, for the + // ordinary un-captured case) -- the same button-lift condition + // `PressEnd` matches on, so falling through to the loop below + // would let whichever of the two happens to be registered first + // win, silently swallowing the `Drop` a caller relied on. + // The same argument as `Drop` immediately above, for the same + // reason: `run_sensors` has already decided this frame is a + // cancellation for this widget, and the registration list very + // likely also carries `Pressing`, which the loop below would + // match against a button that is still down. + if data.sense == CursorSense::Drop || data.sense == CursorSense::Cancel { + return self.contains(&data.sense).then(|| data.clone()); + } + if let Some(sense) = should_run(self, &data.cursor, data.hover) { + let mut data = data.clone(); + data.sense = sense; + Some(data) + } else { + None + } + } +} + +impl CursorSense { + pub fn click() -> Self { + Self::PressStart(CursorButton::Left) + } + pub fn click_or_drag() -> CursorSenses { + Self::click() | Self::Pressing(CursorButton::Left) + } + pub fn unclick() -> Self { + Self::PressEnd(CursorButton::Left) + } + /// What a widget driving a [`DragGesture`] must register: the frames + /// of the gesture, plus **both** of the ways it can end for that + /// widget -- its own [`Self::Drop`] once it has captured the pointer, + /// and [`Self::Cancel`] if somebody else captured it first. + /// + /// One function rather than a set spelled out per call site, because + /// the two terminal senses are exactly what gets forgotten: a `ScrollArea` + /// registered `click_or_drag | unclick` and so never saw the end of + /// any gesture it had captured, which left its arbiter panning from a + /// stale position and made the *next* drag jump by the distance + /// between them -- Iris's "it keeps snapping back to some position + /// when horizontally scrolling" (docs/RUST.md, 2026-09-08). + pub fn drag_senses() -> CursorSenses { + Self::click_or_drag() | Self::unclick() | Self::Drop | Self::Cancel + } + pub fn is_dragging(&self) -> bool { + matches!(self, CursorSense::Pressing(CursorButton::Left)) + } + + /// True for a sense that names a specific thing happening this frame + /// (a button transitioning, a scroll) as opposed to the ambient, + /// always-on-while-over `Hover*` family. Used to decide whether a + /// widget actually *consumes* an input for fall-through purposes: a + /// widget that merely highlights on hover must not be able to block a + /// scroll or a click meant for whatever is behind it, the way it + /// currently could when "the cursor is over this widget" and + /// "this widget handled the event" were the same check. See + /// `SensorUi::run_sensors`. + pub fn is_momentary(&self) -> bool { + !matches!( + self, + CursorSense::HoverStart | CursorSense::Hovering | CursorSense::HoverEnd + ) + } +} + +#[derive(Clone)] +pub struct CursorState { + pub pos: Vec2, + pub exists: bool, + pub buttons: CursorButtons, + pub scroll_delta: Vec2, + /// When this pointer state was *sampled*, from the platform's own + /// input clock -- not when the handler reading it happened to run. + /// + /// It exists because Android batches touch samples: a flick on a + /// 120Hz screen arrives as one or two `MotionEvent`s carrying the + /// intermediate positions as *historical* samples + /// (`getHistoricalX`/`getHistoricalEventTime`), which + /// `IrisViewPeer::on_touch_event` replays through the sensor pass one + /// at a time. Every one of those replays happens within the same few + /// microseconds, so a gesture timing itself with `Instant::now()` + /// would see a span of nearly zero across the whole flick and divide + /// by it -- the velocity would be an artefact of how fast we looped, + /// which is exactly the inferred-as-measured number UI_RULES.md + /// forbids. Carrying the sample's own time makes the span real. + pub time: Instant, + /// The platform took this gesture away rather than the finger + /// finishing it -- Android's `ACTION_CANCEL`, or a + /// `TouchAction::Cancel` line in a `harness` replay. (The winit + /// backend drives a mouse, which the platform never takes away + /// mid-gesture, so it has nothing to set this from yet; a touch + /// path there would set it from `TouchPhase::Cancelled`.) + /// + /// It is **not** a release. Every widget tracking the press is sent + /// [`CursorSense::Cancel`], including whoever holds pointer capture, + /// and nothing follows from it: no tap, no selection, and above all + /// no fling. Reporting it as an ordinary `PressEnd` instead is what + /// made leaving the app move the transcript on Iris's phone + /// (2026-09-08): the swipe up from the bottom edge to go home is + /// delivered to the app as moves and then a cancel, so a cancel read + /// as a release handed the list the swipe's own velocity and it flung + /// while nobody was looking. + /// + /// Set for the one sample that carries it and cleared by + /// [`CursorState::end_frame`], like `scroll_delta`. + pub cancelled: bool, +} + +impl Default for CursorState { + fn default() -> Self { + Self { + pos: Vec2::ZERO, + exists: false, + buttons: CursorButtons::default(), + scroll_delta: Vec2::ZERO, + time: Instant::now(), + cancelled: false, + } + } +} + +#[derive(Default, Clone)] +pub struct CursorButtons { + pub left: ActivationState, + pub middle: ActivationState, + pub right: ActivationState, +} + +impl CursorButtons { + pub fn select(&self, button: &CursorButton) -> &ActivationState { + match button { + CursorButton::Left => &self.left, + CursorButton::Right => &self.right, + CursorButton::Middle => &self.middle, + } + } + + pub fn end_frame(&mut self) { + self.left.end_frame(); + self.middle.end_frame(); + self.right.end_frame(); + } + + pub fn iter(&self) -> impl Iterator { + [ + CursorButton::Left, + CursorButton::Middle, + CursorButton::Right, + ] + .into_iter() + .map(|b| (b, self.select(&b))) + } +} + +impl CursorState { + pub fn end_frame(&mut self) { + self.buttons.end_frame(); + self.scroll_delta = Vec2::ZERO; + self.cancelled = false; + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum ActivationState { + Start, + On, + End, + #[default] + Off, +} + +/// this and other similar stuff has a generic +/// because I kind of want to make CursorModule generic +/// or basically have some way to have custom senses +/// that depend on active widget positions +/// but I'm not sure how or if worth it +pub struct Sensor { + pub senses: CursorSenses, + pub f: Rc>, +} + +pub type SenseShape = UiRegion; + +#[derive(Default, Debug)] +pub struct SensorState { + pub hover: ActivationState, +} + +#[derive(Clone)] +pub struct CursorData<'a> { + /// where this widget was hit + pub pos: Vec2, + pub size: Vec2, + pub scroll_delta: Vec2, + pub hover: ActivationState, + pub cursor: CursorState, + /// the first sense that triggered this + pub sense: CursorSense, + pub render: &'a UiRenderState, + /// The pointer itself, for the length of this dispatch -- who holds + /// exclusive input and how to ask for it. See [`PointerRequests`]. + pub pointer: &'a PointerRequests, +} + +/// What the sensor pass knows about the pointer itself rather than about +/// any one listener: who has exclusive input, and who is tracking the +/// press in flight. [`Event::Global`] for [`CursorSenses`], so it is owned +/// by the event manager that runs the dispatch and reached by `&mut` -- +/// there is no lock and no copy of it anywhere else. +/// +/// **Capture** ([`PointerRequests::capture`]) gives one widget every later +/// sample of the gesture, so a pan or a selection keeps going once the +/// finger has moved off whatever hit region first noticed the press -- +/// including right off the end of it, which is what used to leave a fling +/// never started because no widget saw the release. +/// +/// **`pressed`** is capture's other half: every widget that has been +/// handed a frame of this press and not yet been told it ended. Taking the +/// pointer is a one-way door for all of them -- they see no `PressEnd` and +/// no `Drop` -- so each is sent one [`CursorSense::Cancel`], the way +/// Android sends `ACTION_CANCEL` and the web sends `pointercancel`. +/// Without it a gesture is left open forever with a stale origin, and the +/// *next* touch anywhere on screen is measured from it: Iris's 2026-09-08 +/// phone report, where a horizontal pan inside a code fence made the +/// transcript jump on the following tap (docs/RUST.md). +#[derive(Default)] +pub struct PointerInput { + captured: Option, + pressed: Vec, +} + +impl PointerInput { + /// Which widget holds exclusive pointer input between dispatches. + pub fn holder(&self) -> Option { + self.captured + } + + /// Hand the pointer to `id` from outside the sensor pass -- a test + /// setting a gesture up, or a backend tearing one down with `None`. + /// A handler *inside* the pass uses [`PointerRequests::capture`] + /// instead, which is the same state seen through the dispatch. + pub fn set_holder(&mut self, id: Option) { + self.captured = id; + } +} + +/// The pointer state of `rsc`'s cursor dispatch, for a caller outside the +/// sensor pass. Inside it, a handler has [`PointerRequests`] on its +/// [`CursorData`] and should use that. +pub fn pointer_input(rsc: &mut Rsc) -> &mut PointerInput { + &mut rsc.events_mut().get_type::().global +} + +/// The pointer, as a handler sees it during one dispatch: what it may ask +/// of the capture, and who holds it. Owned by [`SensorUi::run_sensors`] +/// for the length of the dispatch and folded back into [`PointerInput`] +/// straight after, so a handler's request never races anything and nothing +/// global is reachable from a widget. +/// +/// A `Cell`, not a lock: this is one frame of one thread's dispatch, and +/// the interior mutability is only here because a handler is handed +/// `CursorData` by shared reference. +#[derive(Default)] +pub struct PointerRequests { + holder: std::cell::Cell>, +} + +impl PointerRequests { + /// Give `id` exclusive pointer input from the next dispatch on. `id` + /// must be a widget that outlives the gesture -- a `LazySpan`'s own id, + /// not one of its virtualised rows, which can be retired mid-drag as + /// content scrolls. Overwrites any previous capture: a gesture that + /// starts a new one has already decided the old one is over, and the + /// old holder is told so with [`CursorSense::Cancel`]. + pub fn capture(&self, id: WidgetId) { + self.holder.set(Some(id)); + } + + /// Give up exclusive pointer input. Called for the capturing widget by + /// `run_sensors` itself once it has delivered the terminal + /// [`CursorSense::Drop`], or by that widget if it decides the gesture + /// is over some other way. + pub fn release(&self) { + self.holder.set(None); + } + + /// Which widget holds exclusive pointer input, as of this moment in + /// the dispatch. What a widget checks before releasing, so it cannot + /// drop a capture that is somebody else's. + pub fn holder(&self) -> Option { + self.holder.get() + } +} + +pub trait SensorUi { + fn run_sensors( + &self, + rsc: &mut Rsc, + state: &mut Rsc::State, + cursor: CursorState, + window_size: Vec2, + ); +} + +impl SensorUi for UiRenderState { + fn run_sensors( + &self, + rsc: &mut Rsc, + state: &mut Rsc::State, + cursor: CursorState, + window_size: Vec2, + ) { + // `iris::frame`'s `since_input` (`iris::diagnostics::log_frame`) + // reads this back -- every backend's sensor dispatch reaches here, + // so recording it once in the one place they share is what keeps + // it from needing a copy per backend. + self.note_input(cursor.time); + + // The pointer's own state, taken out of the event manager for the + // length of this dispatch and put back at the end -- the same + // `mem::take` the `active` map below uses, and for the same + // borrow reason. `PointerRequests` is what a handler sees of it. + let mut pointer: PointerInput = + std::mem::take(&mut rsc.events_mut().get_type::().global); + let requests = PointerRequests { + holder: std::cell::Cell::new(pointer.captured), + }; + let button_down = cursor.buttons.select(&CursorButton::Left).is_on(); + + // The platform took the gesture away (`CursorState::cancelled`). + // Everybody still tracking this press hears about it -- the + // capture holder included, which is what makes this different + // from the loser cancels at the end of an ordinary dispatch -- + // and no other sense is derived from this sample, because there + // is no press left to derive one from. Nothing may follow: not a + // tap, not a selection, and not a fling. + if cursor.cancelled { + let captured = pointer.captured.take(); + let mut told: Vec = captured.into_iter().collect(); + for id in pointer.pressed.drain(..) { + if Some(id) != captured { + told.push(id); + } + } + requests.release(); + for id in told { + deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); + } + // Deliberately not `requests.holder()`: a cancel handler has + // no gesture left to claim, so one that asked for the pointer + // anyway is refused here rather than left holding a capture + // that no release will ever reach. + rsc.events_mut().get_type::().global = pointer; + return; + } + + // Exclusive pointer capture (`PointerRequests::capture`): once + // some widget has committed to a drag, every other widget sees + // nothing from this pointer at all -- no hover, no click, no press + // -- until it releases. That is what lets a fast pan or a + // selection keep going once the finger has moved off whatever hit + // region first noticed the press (including right off the end of + // the gesture, at `PressEnd`/`Cancel`): a per-widget hit test + // would otherwise silently stop delivering to *anyone* the moment + // the pointer left every registered region, which is exactly what + // used to leave a fling never started (no widget ever saw the + // release). The captured widget keeps getting ordinary `Pressing` + // frames while the button is down and gets exactly one `Drop` -- + // not `PressEnd` -- the frame it lifts, which also releases the + // capture. + if let Some(id) = requests.holder() { + // The capture's path out for a widget that stopped being + // drawn mid-gesture -- a `LazySpan` row retired by virtualisation, + // a rebuilt subtree. Nothing can be delivered to an id with no + // region, so the gesture ends here for everyone. + let Some(shape) = self.resolved_region(&id, rsc) else { + pointer.captured = None; + pointer.pressed.clear(); + rsc.events_mut().get_type::().global = pointer; + return; + }; + let region = shape.to_px(window_size); + let sense = if button_down { + CursorSense::Pressing(CursorButton::Left) + } else { + CursorSense::Drop + }; + let data = CursorData { + pos: cursor.pos - region.top_left, + size: region.bot_right - region.top_left, + scroll_delta: cursor.scroll_delta, + hover: ActivationState::On, + cursor: cursor.clone(), + sense, + render: self, + pointer: &requests, + }; + rsc.run_event::(id, data, state); + if !button_down { + requests.release(); + pointer.pressed.clear(); + } + pointer.captured = requests.holder(); + rsc.events_mut().get_type::().global = pointer; + return; + } + + // in order to remove this take, need to store active list in UiRenderState somehow + // this would probably be done through a generic parameter that adds yet another rsc / + // state like thing, but local to render state, and is passed to UiRsc events so you can + // update it there? + // Whether *something specific* is happening this frame (a button + // transitioning, a scroll) as opposed to the cursor merely resting + // over widgets. Only this decides whether a widget can block a + // lower layer from also seeing the event -- see the `consumed` + // comment below. + let momentary_active = + cursor.scroll_delta != Vec2::ZERO || cursor.buttons.iter().any(|(_, a)| !a.is_off()); + + let mut active = std::mem::take(&mut rsc.events_mut().get_type::().active); + for layer in self.layers.indices().rev() { + let mut sensed = false; + for (id, sensor) in active.get_mut(&layer).into_flat_iter() { + let shape = self.resolved_region(id, rsc).unwrap(); + let region = shape.to_px(window_size); + // The mask this widget is drawn under, applied with the + // same coverage the fragment stage clips it with + // (LAYOUT.md's "Masks with a shape", point 4): a corner + // rounded away by a container is not there to be pressed, + // and a row scrolled out of a list's box is not either. + // The box test stays because it is what says the pointer + // is over *this* widget rather than merely inside its + // clip -- the two ask different questions and both have + // to hold. + let in_shape = cursor.exists + && region.contains(cursor.pos) + && self + .active + .get(id) + .is_none_or(|a| self.mask_admits(a.mask, cursor.pos, rsc)); + sensor.hover.update(in_shape); + if sensor.hover == ActivationState::Off { + continue; + } + + // A widget in shape always still runs (a hover-only + // highlight must fire on the topmost thing under the + // cursor even while a scroll or click passes through it), + // but whether it *consumes* the input -- stopping a lower + // layer from also seeing it -- is judged per input kind + // (LAYOUT.md's coordinator asked for this alongside the + // hit-test rewrite, since both are about `resolved_region` + // and what "under the pointer" means): with nothing + // momentary happening, "in shape" is consumption, same as + // before (the topmost widget wins an idle hover). With a + // scroll or a press/release actually happening, only a + // widget that registered a matching non-hover sense + // consumes it -- a button that only registered `click()` + // must not block a scroll meant for the list behind it. + let consumed = if momentary_active { + rsc.events_mut() + .get_type::() + .registered(*id) + .any(|senses| { + matches!(should_run(senses, &cursor, sensor.hover), Some(s) if s.is_momentary()) + }) + } else { + true + }; + if consumed { + sensed = true; + } + + let cursor = cursor.clone(); + + let data = CursorData { + pos: cursor.pos - region.top_left, + size: region.bot_right - region.top_left, + scroll_delta: cursor.scroll_delta, + hover: sensor.hover, + cursor, + // this does not have any meaning; + // might wanna set up Event to have a prepare stage + sense: CursorSense::Hovering, + render: self, + pointer: &requests, + }; + rsc.run_event::(*id, data, state); + // Anything handed a frame while the button is down may + // have opened a gesture on it, and is owed a `Cancel` if + // somebody else captures the pointer -- see + // `PointerInput`. Recorded for every such widget rather + // than only the ones known to drag, because this layer + // cannot see what a handler did with the frame. + if button_down && !pointer.pressed.contains(id) { + pointer.pressed.push(*id); + } + } + if sensed { + break; + } + } + rsc.events_mut().get_type::().active = active; + + pointer.captured = requests.holder(); + match pointer.captured { + // A capture taken during this frame's dispatch: every other + // widget tracking the same press is told, once, that it is + // over for them. Delivered after `active` is restored, since + // these are ordinary registered handlers being run outside + // the loop. + Some(winner) => { + let losers: Vec = pointer + .pressed + .iter() + .copied() + .filter(|&id| id != winner) + .collect(); + pointer.pressed.retain(|&id| id == winner); + for loser in losers { + deliver_cancel(self, rsc, state, loser, &cursor, window_size, &requests); + } + } + // The press ended without anyone capturing: everybody who saw + // it got their own `PressEnd`, so there is nothing to cancel + // and nothing to remember. + None if !button_down => pointer.pressed.clear(), + None => {} + } + // A cancel handler may itself have captured (a widget deciding + // the gesture is now its own); `requests` is still the truth. + pointer.captured = requests.holder(); + rsc.events_mut().get_type::().global = pointer; + } +} + +/// Hand one widget a [`CursorSense::Cancel`] -- the gesture it was +/// tracking has been taken by whoever captured the pointer. Silent if +/// the widget has no resolved region any more (it was retired in the +/// same frame), which is the same "nothing to deliver to" case +/// `run_sensors`' capture branch handles by releasing. +fn deliver_cancel( + render: &UiRenderState, + rsc: &mut Rsc, + state: &mut Rsc::State, + id: WidgetId, + cursor: &CursorState, + window_size: Vec2, + pointer: &PointerRequests, +) { + let Some(shape) = render.resolved_region(&id, rsc) else { + return; + }; + let region = shape.to_px(window_size); + let data = CursorData { + pos: cursor.pos - region.top_left, + size: region.bot_right - region.top_left, + scroll_delta: cursor.scroll_delta, + hover: ActivationState::On, + cursor: cursor.clone(), + sense: CursorSense::Cancel, + render, + pointer, + }; + rsc.run_event::(id, data, state); +} + +pub fn should_run( + senses: &CursorSenses, + cursor: &CursorState, + hover: ActivationState, +) -> Option { + // Every sense below that is about the *pointer* rather than about + // hovering needs the pointer to actually be on this widget, and + // `hover` is the only thing here that knows: `run_sensors` runs a + // widget one more time after the pointer has left it (`ActivationState + // ::End`, which is not `Off`) so that a `HoverEnd` can fire, and + // deriving a press from raw button state alone handed that frame a + // `PressStart` too. A widget the finger is nowhere near then opened a + // gesture and, if it went straight to panning, captured the pointer -- + // which is the whole of Iris's 2026-09-08 "if I try to scroll + // vertically while a horizontal scroll animation is still active, it + // stays locked to the horizontal scroll". Measured: a fence flicked + // sideways is left `hover == On` (the capture branch above returns + // before the loop that would have updated it), so the *next* touch + // down anywhere on the screen decayed it to `End`, ran the fence's + // `ScrollController::drag` with a `PressStart`, and -- the press being a catch + // of its own fling, which commits with no slop -- captured the whole + // gesture 500px away from the fence. The list under the finger moved + // by nothing at all. + // + // The rule is the set's, not one member's: press *and* scroll, since + // a wheel event reaching a widget the cursor has just left is the same + // fault with a different sense. `Drop` and `Cancel` are exempt because + // they are delivered deliberately to a widget that is *not* under the + // pointer, and `run_sensors` hands both of those an `On` anyway. + let on_this = hover.is_on(); + for sense in senses.iter() { + if match sense { + CursorSense::PressStart(button) => on_this && cursor.buttons.select(button).is_start(), + CursorSense::Pressing(button) => on_this && cursor.buttons.select(button).is_on(), + CursorSense::PressEnd(button) => on_this && cursor.buttons.select(button).is_end(), + CursorSense::HoverStart => hover.is_start(), + CursorSense::Hovering => hover.is_on(), + CursorSense::HoverEnd => hover.is_end(), + CursorSense::Scroll => on_this && cursor.scroll_delta != Vec2::ZERO, + // Never derived here -- `Drop` only ever fires through + // `CursorSenses::should_run`'s own special case, ahead of this + // loop, for the one widget `run_sensors`' capture branch is + // delivering it to this frame. If this arm answered from raw + // button state instead, an ordinary hit-tested widget that + // happened to register `Drop` (with no capture involved at + // all) would see it fire on every plain button-up under the + // cursor. + // Neither is ever derived from raw state -- see the `Drop` + // note above; both are set by `run_sensors` alone, for the one + // widget it is delivering to this frame. + CursorSense::Drop | CursorSense::Cancel => false, + } { + return Some(*sense); + } + } + None +} + +impl ActivationState { + pub fn is_start(&self) -> bool { + *self == Self::Start + } + pub fn is_on(&self) -> bool { + *self == Self::Start || *self == Self::On + } + pub fn is_end(&self) -> bool { + *self == Self::End + } + pub fn is_off(&self) -> bool { + *self == Self::End || *self == Self::Off + } + pub fn update(&mut self, on: bool) { + *self = match *self { + Self::Start => match on { + true => Self::On, + false => Self::End, + }, + Self::On => match on { + true => Self::On, + false => Self::End, + }, + Self::End => match on { + true => Self::Start, + false => Self::Off, + }, + Self::Off => match on { + true => Self::Start, + false => Self::Off, + }, + } + } + + pub fn end_frame(&mut self) { + match self { + Self::Start => *self = Self::On, + Self::End => *self = Self::Off, + _ => (), + } + } +} + +impl EventLike for CursorSense { + type Event = CursorSenses; + fn into_event(self) -> Self::Event { + self.into() + } +} + +impl Deref for CursorSenses { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for CursorSenses { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for CursorSenses { + fn from(val: CursorSense) -> Self { + CursorSenses(vec![val]) + } +} + +impl BitOr for CursorSense { + type Output = CursorSenses; + + fn bitor(self, rhs: Self) -> Self::Output { + CursorSenses(vec![self, rhs]) + } +} + +impl BitOr for CursorSenses { + type Output = Self; + + fn bitor(mut self, rhs: CursorSense) -> Self::Output { + self.0.push(rhs); + self + } +} + +/// One `iris::input` line for a single platform input sample -- called +/// once per platform event: `android::view::IrisViewPeer::on_touch_event` +/// once per real `MotionEvent` (`historical` carrying whatever samples it +/// batched, oldest first, exactly as Android delivers and replays them); +/// the winit backend once per pointer `WindowEvent`, always with an empty +/// `historical` since winit does not batch; and `harness::Harness::touch` +/// once per `TouchScript` line, also with an empty `historical`, which is +/// what makes a harness-produced report round-trip through +/// `iris/benches/report_to_touch.py` back into the exact script that was +/// replayed (docs/RUST.md's "Three test layers" / phone logging sections). +/// +/// `action` is one of the four words [`crate::harness::TouchScript::parse`] +/// accepts (`"down"`/`"move"`/`"up"`/`"cancel"`), so the same string is +/// both what a real device's `MotionAction` is translated to and what the +/// parser reads back -- one vocabulary rather than two that have to be +/// kept in step by hand. `t_ms`/`historical`'s own times are whatever the +/// caller's own clock calls "the start of this recording" -- the harness's +/// own `t_ms`, or nanoseconds since `IrisViewPeer`'s `input_clock` anchor +/// converted to ms -- so they are comparable to a `.touch` file's own +/// column but not to another process's. +/// +/// Gated on [`crate::diagnostics::trace_enabled`] rather than +/// `log::log_enabled!` -- see that module's doc for why the level alone +/// cannot give this a default-off switch. +pub fn log_input_event(action: &str, x: f32, y: f32, t_ms: u64, historical: &[(u64, f32, f32)]) { + if !crate::diagnostics::trace_enabled() { + return; + } + let mut hist = String::new(); + for (t, hx, hy) in historical { + hist.push_str(&format!(" {t}:{hx:.1},{hy:.1}")); + } + log::debug!( + target: "iris::input", + "iris input: action={action} x={x:.1} y={y:.1} t={t_ms}ms history={}{hist}", + historical.len(), + ); +} + +/// Converts a platform's own monotonic input timestamps into [`Instant`]s +/// through **one** anchor taken at the first event, so that every sample +/// this process ever sees is dated on a single ruler. +/// +/// A fresh `Instant::now()` per event, minus each sample's age inside it, +/// can date a later event's first sample before the previous event's last +/// one whenever delivery jitters by more than the batch spans -- which +/// [`VelocityTracker`] would rightly reject. +/// +/// The anchor is taken from the **earliest sample of the first event**, +/// not from that event's own time: an event batches samples that are by +/// definition older than itself, and anchoring on the event's own time +/// leaves them before the anchor, where they clamp onto one instant. Three +/// samples sharing a timestamp make the Lsq2 fit degenerate, so the flick +/// that produced them reads 0 px/s -- reachable whenever the first event a +/// view sees is a `Move` (the `Down` went to another view, or the view was +/// attached mid-gesture). Found by review, 2026-09-07 (docs/REVIEW-2026-09-07.md's D4). +#[derive(Clone, Copy)] +pub struct PointerClock { + anchor_at: Instant, + anchor_nanos: i64, + last_nanos: i64, +} + +impl PointerClock { + /// `now` is when the first event arrived, `event_time` its own + /// timestamp, and `oldest` the timestamp of the earliest sample it + /// carries -- equal to `event_time` when it batches none. + pub fn anchored(now: Instant, event_time: i64, oldest: i64) -> Self { + let batch_span = Duration::from_nanos(event_time.saturating_sub(oldest).max(0) as u64); + Self { + // `checked_sub` rather than `-`: an `Instant` taken very early + // in a process's life has nothing to subtract from, and the + // saturating answer (everything in the first batch at `now`) + // is the old behaviour rather than a panic. + anchor_at: now.checked_sub(batch_span).unwrap_or(now), + anchor_nanos: oldest, + last_nanos: oldest, + } + } + + /// Dates one sample, in the order the platform delivers them. + /// + /// The `debug_assert!` is deliberately not an `assert!`: this runs once + /// per touch sample, which on a 120Hz screen with batching is hundreds + /// a second, and a mis-ordered sample degrades a velocity rather than + /// drawing something wrong (CODE_RULES' hot-loop exception). + pub fn sample(&mut self, nanos: i64) -> Instant { + debug_assert!( + nanos >= self.last_nanos, + "input sample is dated {nanos}ns, before the {}ns sample ahead of it -- the input \ + clock is not what this assumes", + self.last_nanos, + ); + self.last_nanos = self.last_nanos.max(nanos); + self.at(nanos) + } + + /// Dates a sample without treating it as the newest one seen -- for + /// reads that are out of band, such as looking at a batch before + /// replaying it. + pub fn at(&self, nanos: i64) -> Instant { + self.anchor_at + Duration::from_nanos(nanos.saturating_sub(self.anchor_nanos).max(0) as u64) + } + + /// Milliseconds since the anchor, which is the column an `iris::input` + /// line and a `.touch` file both carry (see [`log_input_event`]). + pub fn ms_since_anchor(&self, nanos: i64) -> u64 { + (nanos.saturating_sub(self.anchor_nanos).max(0) as u64) / 1_000_000 + } +} + +/// How long a stationary press has to be held before it is treated as a +/// long-press rather than the start of a pan. +pub const LONG_PRESS: Duration = Duration::from_millis(500); +/// How far a press has to move, in pixels, before it counts as a drag +/// rather than jitter -- for both the pan-vs-select axis test and the +/// "did this actually move" long-press guard. +pub const DRAG_SLOP: f32 = 8.0; + +/// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided` +/// means neither a pan nor a selection has committed yet, so the caller +/// should do nothing observable this frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum DragOutcome { + Undecided, + /// Scroll the enclosing list by this many window-space pixels along + /// the drag axis (the delta since the arbiter's last decided frame). + Pan(f32), + /// A selection should begin at the arbiter's press origin. + SelectStart, + /// A selection already underway should extend to the current position. + SelectExtend, +} + +/// What the thing a press landed on looked like at the moment it landed +/// -- the two facts a [`DragArbiter`] cannot see for itself and that +/// decide what the press is allowed to become. One struct rather than two +/// boolean parameters because they are read together, on exactly one call +/// (`press_start`), and a bare `false, false` at a call site says nothing +/// about which is which. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PressState { + /// Whether anything was already selected *before* this press -- it + /// decides whether an early horizontal move extends that selection + /// instead of waiting for a long-press. + pub already_selected: bool, + /// Whether the target was already moving under its own momentum (a + /// `LazySpan` with a fling in flight, `Scrollable::is_scrolling`). See + /// [`DragArbiter::press_start`]: a press on moving content is a catch, + /// and catches skip the slop entirely. + pub scrolling: bool, +} + +#[derive(Clone, Copy, PartialEq)] +enum ArbiterState { + Idle, + Undecided { already_selected: bool }, + Panning, + Selecting, +} + +/// Decides, one shared instance per gesture surface (a transcript's whole +/// row list here), whether a touch drag that starts on a row's own +/// selectable text is panning the list or extending a text selection -- +/// RUST.md's I5 finding that both wanted the same `CursorSense:: +/// click_or_drag()` gesture, with the inner text layer winning every frame +/// regardless of which one the reader meant. Decided the way Android +/// itself decides it, so a reader's existing muscle memory carries over: +/// +/// - An ordinary vertical drag pans -- checked first, and immediately, +/// so a swipe never waits on the long-press timer. +/// - A stationary press held past [`LONG_PRESS`] starts a selection. +/// Every drag frame after that extends it, whichever direction it goes. +/// - A drag that starts **horizontally** while something is already +/// selected extends that selection right away, skipping the long-press +/// wait -- the "drag the selection handle" gesture a reader reaches for +/// once text is already highlighted. +/// +/// Pure state, no rendering or widget access, so it is unit-testable +/// exactly like the rest of this module (`sense_tests.rs`'s style) with a +/// caller-supplied `Instant` rather than a real clock. +pub struct DragArbiter { + state: ArbiterState, + /// Which way a pan runs. A transcript pans down its list and a code + /// fence pans across its own long lines, and the two decisions are + /// the same one with the axes swapped -- so the axis is a field + /// rather than a second copy of this state machine, and everything + /// below reads `along`/`across` instead of `dy`/`dx`. + axis: Axis, + origin: Vec2, + origin_at: Instant, + last: Vec2, +} + +impl Default for DragArbiter { + fn default() -> Self { + Self::on(Axis::Y) + } +} + +impl DragArbiter { + /// A vertical arbiter -- what a list, and every caller before the + /// axis became a field, wants. + pub fn new() -> Self { + Self::default() + } + + /// An arbiter whose pan runs along `axis`. + pub fn on(axis: Axis) -> Self { + Self { + state: ArbiterState::Idle, + axis, + origin: Vec2::ZERO, + origin_at: Instant::now(), + last: Vec2::ZERO, + } + } + + /// A fresh press-down at `pos`, with whatever the caller's target + /// looked like at that moment ([`PressState`]). + /// + /// `press.scrolling` short-circuits the whole decision: the press + /// commits to panning on this very sample, with no [`DRAG_SLOP`] and + /// no long-press timer, because a finger put down on content that is + /// already moving means "stop it here" and nothing else. That is + /// Compose's `scrollable`, whose `startDragImmediately` is + /// `ScrollingLogic.shouldScrollImmediately()` -- + /// `scrollableState.isScrollInProgress` -- and whose + /// `DragGestureNode.processInitialDownState` then consumes the DOWN + /// on the `Initial` pass and calls `sendDragStart` + + /// `sendDragEvent(Offset.Zero)` on the `Main` pass of that same + /// event, rather than moving to its await-touch-slop state. + pub fn press_start(&mut self, pos: Vec2, now: Instant, press: PressState) { + self.origin = pos; + self.origin_at = now; + self.last = pos; + self.state = if press.scrolling { + ArbiterState::Panning + } else { + ArbiterState::Undecided { + already_selected: press.already_selected, + } + }; + } + + /// Whether this arbiter has no press in flight -- either it has never + /// seen [`Self::press_start`], or the last one it saw was released. + /// What a caller whose own `PressStart` sense can miss (see + /// [`Self::update`]'s doc) uses to notice that a `Pressing` frame has + /// arrived with no matching start, and recover by starting one now. + pub fn is_idle(&self) -> bool { + matches!(self.state, ArbiterState::Idle) + } + + /// The press continues (still down) at `pos`. Call once per frame + /// while the button/finger is down; returns what this frame means. + /// + /// A caller must not call this while [`Self::is_idle`] is true for a + /// press that is genuinely still down -- `Idle` has no way to tell + /// "no press is happening" from "a press is happening but this + /// arbiter never got its `press_start`," so it always answers + /// `Undecided` and never leaves `Idle` on its own. That second case is + /// real: a touch's `ACTION_DOWN` lands whatever pixel the finger + /// actually hit, which is not guaranteed to be inside the same + /// row-local sensor region a later `ACTION_MOVE` in the same gesture + /// lands in (a row's own padding/gap, or its non-selectable header, is + /// pointer-transparent to `CursorSense`) -- so the widget that + /// receives the gesture's first `Pressing` frame may never have seen + /// its `PressStart`. `iris::transcript_ui::selection::Selection::drag` + /// is the caller that recovers from this, via `is_idle`. + pub fn update(&mut self, pos: Vec2, now: Instant) -> DragOutcome { + match self.state { + ArbiterState::Idle => DragOutcome::Undecided, + ArbiterState::Panning => { + let along = pos.axis(self.axis) - self.last.axis(self.axis); + self.last = pos; + DragOutcome::Pan(along) + } + ArbiterState::Selecting => { + self.last = pos; + DragOutcome::SelectExtend + } + ArbiterState::Undecided { already_selected } => { + let along = pos.axis(self.axis) - self.origin.axis(self.axis); + let across = pos.axis(!self.axis) - self.origin.axis(!self.axis); + if already_selected && across.abs() > DRAG_SLOP && across.abs() > along.abs() { + self.state = ArbiterState::Selecting; + self.last = pos; + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::input", + "iris gesture: select extend (early, already selected) across={across:.1}" + ); + } + DragOutcome::SelectExtend + } else if along.abs() > DRAG_SLOP && along.abs() >= across.abs() { + self.state = ArbiterState::Panning; + self.last = pos; + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::input", + "iris gesture: drag start axis={:?} along={along:.1}", + self.axis, + ); + } + // `along` here is the *whole* drag since `press_start`, + // not since the last frame -- nothing panned while + // `Undecided` was withholding the slop, so applying it + // in full on this one frame is a visible jump the + // instant `DRAG_SLOP` is crossed (IRIS_TODO.md's + // "scrolling down sometimes jitters the text," root- + // caused by tracing `LazySpan`'s per-frame offset against + // a synthetic monotonic drag: the offset held flat for + // every `Undecided` frame, then stepped by several + // frames' worth of motion at once on the frame slop + // was crossed, before resuming ordinary per-frame + // deltas). Only the excess past the slop threshold is + // real, undecided motion the reader hasn't seen + // reflected yet -- so only that excess is applied now, + // the same way Android's own touch handling consumes + // `ViewConfiguration.getScaledTouchSlop()` once from + // the first scroll past it rather than replaying the + // whole pre-threshold drag in one step. + DragOutcome::Pan(along - DRAG_SLOP.copysign(along)) + } else if now.duration_since(self.origin_at) >= LONG_PRESS + && across.abs() <= DRAG_SLOP + && along.abs() <= DRAG_SLOP + { + self.state = ArbiterState::Selecting; + self.last = pos; + if crate::diagnostics::trace_enabled() { + log::debug!(target: "iris::input", "iris gesture: long press"); + } + DragOutcome::SelectStart + } else { + DragOutcome::Undecided + } + } + } + } + + /// The press was released -- back to idle for the next one. + pub fn release(&mut self) { + self.state = ArbiterState::Idle; + } + + /// Whether the arbiter's current gesture (if any) has committed to + /// panning -- what a caller checks at release time to decide whether + /// to hand the tracked velocity to [`crate::widget::ScrollController::fling`], per + /// IRIS_TODO.md's "swiping has no momentum": a fling must only follow + /// a pan, never a text selection that happened to end with the finger + /// still moving. + pub fn is_panning(&self) -> bool { + matches!(self.state, ArbiterState::Panning) + } + + /// Which way this arbiter's pan runs -- what [`DragGesture`] reads to + /// know which component of a window position to hand its + /// [`VelocityTracker`], so the axis is stated once here rather than + /// stored a second time beside it. + pub fn axis(&self) -> Axis { + self.axis + } + + /// Whether a press is in flight that has committed to neither a pan + /// nor a selection -- what a release checks to tell a **tap** from + /// the end of a drag. A tap is exactly "pressed and let go without + /// ever deciding", so it is read here rather than timed separately: + /// one gesture machine, one answer. + pub fn is_undecided(&self) -> bool { + matches!(self.state, ArbiterState::Undecided { .. }) + } +} + +/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the +/// one further state a shared gesture needs: the drag ending, with the +/// released velocity if (and only if) it had committed to panning. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GestureOutcome { + Undecided, + /// Same units and sign as [`DragOutcome::Pan`] -- the caller's own + /// convention (`ScrollController::scroll`'s, for a transcript) to apply. + Pan(f32), + SelectStart, + SelectExtend, + /// The press ended without ever committing to a pan or a selection -- + /// a tap. Distinct from `Released(None)`, which is the end of a + /// gesture that *did* commit (a selection, or a pan too slow to + /// fling): a caller acting on a tap -- following a markdown link -- + /// must not also act when the finger was panning the list past that + /// link, which is the tap-vs-drag rule this enum exists to state + /// once for every caller rather than per widget. + Tapped, + /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. + /// `Some(velocity)` only if the gesture had committed to panning + /// (never a tap, a long-press selection, or one still `Undecided`); + /// same units as `Pan`, so a caller hands it to `ScrollController::fling` with + /// whatever sign flip it already applies to `Pan`. + Released(Option), + /// Another widget took the pointer (`CursorSense::Cancel`), so this + /// gesture is over and **nothing** should be acted on: not a tap, not + /// a fling, not a selection. Distinct from `Released(None)`, which is + /// a gesture of this widget's own that simply ended with nothing to + /// hand on. + Cancelled, +} + +/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing +/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's +/// velocity, and take pointer capture (`UiRenderState::capture_pointer`) +/// the moment the gesture commits so the rest of it -- including the +/// terminal release -- keeps reaching the same widget even after the +/// finger has moved off whatever hit region first noticed the press. Iris +/// asked for this to live here rather than in `transcript-ui::Selection` +/// (2026-09-06, recorded in `IRIS.md`): "dragging should be part of the +/// default input system ... anything that provides good performance and +/// can be generalized well is part of iris rather than the app." A caller +/// still decides what a committed pan or a completed selection *means* +/// (transcript-ui's pan-vs-select is one call site; a slider or a plain +/// scroll area is another) -- this only owns the *mechanics* every one of +/// them would otherwise duplicate. +pub struct DragGesture { + arbiter: DragArbiter, + velocity: VelocityTracker, + /// Set when a press began as a *catch* (`PressState::scrolling`) and + /// cleared by the first frame that actually moves the content. While + /// it is set the gesture is a pan that has panned nothing, so its + /// release is `Released(None)`: not a `Tapped`, because Compose's + /// scrollable consumed that DOWN and no click or long-press detector + /// under it ever saw the gesture at all, and not a + /// `Released(Some(v))`, because there is no velocity to hand on. + catch_unmoved: bool, +} + +impl Default for DragGesture { + fn default() -> Self { + Self::new() + } +} + +impl DragGesture { + pub fn new() -> Self { + Self::on(Axis::Y) + } + + /// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`]. + pub fn on(axis: Axis) -> Self { + Self { + arbiter: DragArbiter::on(axis), + velocity: VelocityTracker::new(), + catch_unmoved: false, + } + } + + /// Whether this gesture has no press in flight -- a thin passthrough + /// to the underlying `DragArbiter::is_idle`, for a caller (a test, a + /// diagnostic) that wants to observe the recovery behaviour `handle`'s + /// idle-recovery branch documents without reaching into a private + /// field. + pub fn is_idle(&self) -> bool { + self.arbiter.is_idle() + } + + /// Whether feeding `sense` to [`Self::handle`] right now would begin + /// a **new press**: any frame that is not a release, arriving while + /// no press is in flight. That covers a `PressStart` and equally the + /// first `Pressing` frame of a gesture whose `PressStart` never + /// arrived (`handle`'s recovery branch below). A caller reads this to + /// prepare the thing being dragged on exactly the frames `handle` + /// will call `DragArbiter::press_start` -- + /// `transcript_ui::Selection::drag` stops its list's fling and + /// reports whether there was one -- rather than keeping its own copy + /// of that rule, which is the one place the two could disagree. + /// + /// **A `PressStart` is not special-cased to `true`**, which it was + /// for one afternoon: one touch-down reaches every sensor under the + /// finger, and a transcript row's block and the tool row containing + /// it both drive this same shared `DragGesture`, so `handle` sees one + /// `PressStart` twice. Restarting on the second delivery re-reads the + /// caller's [`PressState`] *after* the first delivery has already + /// acted on it -- the list's fling is cancelled by then, so + /// `scrolling` comes back false and a catch silently becomes an + /// ordinary slop-waiting press. The second delivery is a continuation + /// of a press already in flight, and this says so. + pub fn starts_press(&self, sense: CursorSense) -> bool { + match sense { + CursorSense::Drop | CursorSense::PressEnd(_) | CursorSense::Cancel => false, + _ => self.arbiter.is_idle(), + } + } + + /// Feed one frame of a gesture through. `id` is the widget iris should + /// give exclusive pointer input to once this gesture commits to + /// panning or selecting -- a stable widget that outlives the gesture + /// (a `LazySpan`'s own id, not one of its virtualised rows, which can be + /// retired mid-drag as content scrolls). `pointer` is `CursorData`'s + /// own field, already in hand at every call site. `press` only matters + /// on the frames [`Self::starts_press`] answers true for -- see + /// `DragArbiter::press_start`'s doc. + pub fn handle( + &mut self, + pointer: &PointerRequests, + id: WidgetId, + sense: CursorSense, + pos_window: Vec2, + now: Instant, + press: PressState, + ) -> GestureOutcome { + match sense { + // Somebody else won this gesture. Forget it completely -- + // leaving the arbiter open is the fault this sense was added + // for, since its origin then measures the *next* touch and + // pans by the distance between two unrelated fingers. + // + // **Unless this gesture is the winner.** A `Cancel` goes to + // every widget that was handed a frame of the press and did + // not capture it (`PointerInput`'s doc), and one gesture is + // routinely driven by several of those: a transcript row's + // text block feeds `transcript_ui::Selection`'s shared + // `DragGesture`, which captures under the *list's* id -- so + // the block is a "loser" on the very frame its own gesture + // committed. Acting on that would release the pan the frame + // it started. `id` is what this gesture captures as, so + // comparing it against the holder is exactly the question + // "was it us that won". + CursorSense::Cancel if pointer.holder() == Some(id) => GestureOutcome::Undecided, + CursorSense::Cancel => { + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::input", + "iris gesture: cancelled (pointer captured elsewhere)", + ); + } + self.arbiter.release(); + self.catch_unmoved = false; + self.velocity.reset(); + GestureOutcome::Cancelled + } + CursorSense::Drop | CursorSense::PressEnd(_) => { + // Once: a `velocity()` is a full Lsq2 fit, and the log + // line below wants the same number the outcome carries. + let released = self.velocity.velocity(); + // `catch_unmoved` is only ever set beside `Panning` (a + // catch enters it on the down) and only ever left set + // while nothing has moved, so `Panning` is the one state + // it can be observed in. If that stops holding, the + // branch below is silently swallowing a tap. + debug_assert!( + !self.catch_unmoved || self.arbiter.is_panning(), + "a caught press that never moved must still be panning at release", + ); + let outcome = if self.catch_unmoved { + GestureOutcome::Released(None) + } else if self.arbiter.is_panning() { + GestureOutcome::Released(Some(released)) + } else if self.arbiter.is_undecided() { + GestureOutcome::Tapped + } else { + GestureOutcome::Released(None) + }; + // The one line that settles "why did that flick not fling" + // from a logcat, which is the only instrument available on + // Iris's phone (this-machine-android: system tracing does + // not work there). Every input to the decision is here, so + // a zero velocity can be told apart from a gesture that + // never reached `Panning` at all -- the two look identical + // on screen and had to be guessed between twice. + log::info!( + "iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}", + self.velocity.sample_count(), + self.velocity.span().as_secs_f32() * 1000.0, + released, + outcome, + ); + // The samples themselves, so a flick that felt wrong on + // Iris's phone can be replayed here instead of guessed at: + // paste them into a `touch/*.touch` recording or straight + // into `iris/benches/velocity_reference.py`. Debug rather + // than info because it is one line per gesture and the + // ring the report copies is small -- but "small" is still + // not "free" (docs/RUST.md's review, D1), so it is gated + // the same way every other `debug!` in this pass is. + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::input", + "iris drag release samples: {}", + self.velocity.samples_display() + ); + } + self.arbiter.release(); + self.catch_unmoved = false; + // Only if this gesture is the one holding it. A widget + // that never captured (it stayed `Undecided`, so this + // release is a tap) would otherwise drop somebody else's + // capture mid-drag, which is the same lost-gesture bug + // `CursorSense::Cancel` exists to prevent, in reverse. + if pointer.holder() == Some(id) { + pointer.release(); + } + outcome + } + // A `Pressing` frame can arrive with no matching `PressStart` + // if the touch-down landed outside whichever hit region first + // noticed it -- `DragArbiter::update`'s own doc. Both that + // recovery and an ordinary `PressStart` open a press the same + // way, so they are one branch: `starts_press` is the rule, and + // it is the same one the caller reads. + _ if self.starts_press(sense) => { + self.velocity.reset(); + // Where the finger went down is a sample, exactly as + // Compose's `DragGestureNode.sendDragStart` feeds the DOWN + // change to its tracker before any move. It is one of the + // three a fit needs, and it is the one that fixes the + // origin of the curve; without it a 120Hz flick delivering + // its whole motion in two frames has too few points and + // does not fling at all. + self.velocity + .add_position(pos_window.axis(self.arbiter.axis()), now); + self.arbiter.press_start(pos_window, now, press); + self.catch_unmoved = press.scrolling; + if crate::diagnostics::trace_enabled() { + let how = if matches!(sense, CursorSense::PressStart(_)) { + "" + } else { + " (recovered, no PressStart seen)" + }; + log::debug!( + target: "iris::input", + "iris gesture: press start{how} pos=({:.1},{:.1}) scrolling={}", + pos_window.x, pos_window.y, press.scrolling, + ); + } + self.dispatch(pointer, id, pos_window, now) + } + _ => self.dispatch(pointer, id, pos_window, now), + } + } + + fn dispatch( + &mut self, + pointer: &PointerRequests, + id: WidgetId, + pos: Vec2, + now: Instant, + ) -> GestureOutcome { + match self.arbiter.update(pos, now) { + DragOutcome::Undecided => GestureOutcome::Undecided, + DragOutcome::Pan(dy) => { + pointer.capture(id); + if dy != 0.0 { + // The catch has moved something, so its release is an + // ordinary pan release again -- see `catch_unmoved`. + self.catch_unmoved = false; + } + // The raw position, not `dy`: `dy` has the touch slop + // subtracted out of the frame that crossed it, and the + // tracker fits a curve through where the finger *was*. + self.velocity + .add_position(pos.axis(self.arbiter.axis()), now); + GestureOutcome::Pan(dy) + } + DragOutcome::SelectStart => { + pointer.capture(id); + GestureOutcome::SelectStart + } + DragOutcome::SelectExtend => { + pointer.capture(id); + GestureOutcome::SelectExtend + } + } + } +} + +/// Compose's `HistorySize`: how many samples the tracker holds at all. +/// Compose's is a circular buffer of exactly this many; the deque below +/// drops its oldest instead, which is the same set of samples. +const HISTORY_SIZE: usize = 20; +/// Compose's `HorizonMilliseconds`: a sample older than this than the +/// newest one is not part of the estimate. +const HORIZON_MS: f32 = 100.0; +/// Compose's `AssumePointerMoveStoppedMilliseconds`: a gap this long +/// between two consecutive samples means the finger stopped, and +/// everything older than the gap is a different motion. +const ASSUME_POINTER_MOVE_STOPPED_MS: f32 = 40.0; +/// `VelocityTracker1D`'s `minSampleSize` for `Strategy.Lsq2` -- a +/// quadratic needs three points, and fewer answers `0`. +const MIN_SAMPLE_SIZE: usize = 3; +/// Compose's `if (norm < 0.000001f)` in `polyFitLeastSquares`: below this, +/// the vectors are linearly dependent and there is no solution, so the fit +/// returns nothing rather than dividing by it. +const DEGENERATE_NORM: f32 = 0.000001; +/// The degree Compose fits (`polyFitLeastSquares(.., degree = 2)`), and +/// the number of coefficients that produces. +const FIT_DEGREE: usize = 2; +const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; + +/// `ViewConfiguration.getScaledMaximumFlingVelocity()`, in dp per second +/// -- AOSP's `MAXIMUM_FLING_VELOCITY`. Compose applies it at the release +/// (`DragGestureNode.sendDragStopped` passes +/// `LocalViewConfiguration.maximumFlingVelocity` into +/// `VelocityTracker.calculateVelocity(maximumVelocity)`); iris applies it +/// in [`crate::widget::ScrollController::fling`] instead, because that is the only +/// place that knows the density this has to be multiplied by. There is +/// deliberately **no** matching minimum: see `ScrollController::fling`. +pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; + +/// Estimates a drag's speed along one axis the way Compose's touch +/// scrolling does, so a fling released here starts at the speed the same +/// finger would have started one in a `LazyColumn` -- Iris's phone report +/// of 2026-09-07: "flinging now actually works but is slower than +/// Compose's immediately after releasing the flick (the slow down seems +/// correct)". The curve was already AOSP's; only the initial speed was +/// wrong. +/// +/// **What it was, and why that was slow.** Until 2026-09-07 this held +/// per-frame *deltas* and answered their sum over the span between the +/// oldest and newest -- an average. An average cannot tell an +/// accelerating flick from a steady drag, and a flick is by definition +/// accelerating: on the reference accelerating sample set +/// (`iris/benches/velocity_reference.py`) the average reads 1080px/s +/// where Compose reads 2445px/s, so every fling started at under half +/// the speed the finger asked for. +/// +/// **What Compose actually does, which is not what it is remembered as.** +/// `scrollable`/`draggable` release through the 2D `VelocityTracker`, +/// which on Android is `Lsq2VelocityTracker` -- two +/// `VelocityTracker1D(strategy = Lsq2)` over **absolute positions**, +/// fitting a degree-2 polynomial by least squares and taking its +/// derivative at the newest sample. `Strategy.Impulse` is reached only +/// through `DifferentialVelocityTracker`, whose one caller is +/// `NonTouchScrollingLogic`: mouse wheel and trackpad, never a finger. +/// (`AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled`, which +/// would swap in the platform's own tracker, defaults to `false`.) Read +/// out of `androidx.compose.ui:ui-android:1.12.0` and +/// `androidx.compose.foundation:foundation-android:1.12.0`'s +/// `-sources.jar`, 2026-09-07 -- the versions +/// `app/gradle/libs.versions.toml` builds the Compose app against, which +/// is the app Iris is comparing this one with. +/// +/// **So it holds positions, not deltas.** Lsq2 refuses differential data +/// in Compose itself (`"Lsq2 not (yet) supported for differential axes"` +/// is a thrown `IllegalStateException`), and a fit needs points on a +/// curve rather than the curve's increments. [`DragGesture`] feeds it the +/// raw window-space coordinate along the drag axis, exactly as Compose +/// feeds `originalEventPosition`. +/// +/// The numbers its tests assert on come from +/// `iris/benches/velocity_reference.py`, an independent transcription of +/// the same Kotlin -- not from this code, for the reason +/// `android_fling_spline`'s doc gives at length. +#[derive(Default)] +pub struct VelocityTracker { + /// `(when, position along the axis)`, oldest first, at most + /// `HISTORY_SIZE` of them. The horizon is applied in `velocity` + /// rather than here, because that is where Compose applies it and + /// because a sample outside the horizon still tells `span` and the + /// release log what was delivered. + samples: VecDeque<(Instant, f32)>, +} + +impl VelocityTracker { + pub fn new() -> Self { + Self::default() + } + + /// Forget everything -- called on a fresh press, so a new gesture's + /// velocity is never contaminated by the tail of the previous one. + /// Compose's `resetTracking`, called from the same place (its + /// `addPointerInputChange` resets on `changedToDown`). + pub fn reset(&mut self) { + self.samples.clear(); + } + + /// Record where the finger was, along the drag axis, at `at`. A + /// **position**, not a per-frame delta -- see the type's doc. + pub fn add_position(&mut self, position: f32, at: Instant) { + // A caller that samples out of order (a restored/replayed + // gesture, a test) would make `velocity`'s reverse walk compute + // negative ages and fit a curve through a shuffled x-axis -- + // masking the bug that produced it rather than surfacing it + // (docs/REVIEW-2026-09-06.md finding 4). + debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last)); + self.samples.push_back((at, position)); + while self.samples.len() > HISTORY_SIZE { + self.samples.pop_front(); + } + } + + /// How many samples are currently held, and how long they span. + /// Reported beside the velocity in `DragGesture`'s release log, + /// because a `v=0` on its own cannot say whether the gesture was slow + /// or whether the tracker was simply never fed -- which is exactly the + /// distinction the phone's missing fling turned on. + pub fn sample_count(&self) -> usize { + self.samples.len() + } + + pub fn span(&self) -> Duration { + match (self.samples.front(), self.samples.back()) { + (Some(&(first, _)), Some(&(last, _))) => last.duration_since(first), + _ => Duration::ZERO, + } + } + + /// Every held sample as `t_ms:position`, offsets from the oldest -- + /// what the release log prints at debug level so a gesture reported + /// from the phone can be replayed here (`TouchScript`, layer 1) or + /// pasted into `velocity_reference.py`. Iris has no logcat, so the + /// only way a flick that felt wrong on her screen becomes a number + /// anybody can check is for the samples themselves to be in the + /// report. + pub fn samples_display(&self) -> String { + let Some(&(first, _)) = self.samples.front() else { + return String::new(); + }; + self.samples + .iter() + .map(|&(at, position)| { + format!( + "{:.1}:{position:.1}", + at.duration_since(first).as_secs_f32() * 1000.0 + ) + }) + .collect::>() + .join(" ") + } + + /// The estimated speed at the newest sample, in units per second -- + /// `VelocityTracker1D.calculateVelocity` with `Strategy.Lsq2`, then + /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum + /// itself is applied by the caller that knows the density + /// ([`crate::widget::ScrollController::fling`]). + /// + /// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is + /// Compose's answer too: a press and a single move carry no curve to + /// fit, so they do not fling. + pub fn velocity(&self) -> f32 { + let mut positions = [0.0f32; HISTORY_SIZE]; + let mut ages = [0.0f32; HISTORY_SIZE]; + let mut count = 0; + + let Some(&(newest_at, _)) = self.samples.back() else { + return 0.0; + }; + let mut previous_at = newest_at; + // Newest first, walking back while the samples are one continuous + // motion -- Compose's own loop, including that `previous_at` + // steps sample to sample (the `Strategy.Lsq2 || isDataDifferential` + // branch) rather than staying on the newest. + for &(at, position) in self.samples.iter().rev() { + let age = newest_at.duration_since(at).as_secs_f32() * 1000.0; + let gap = previous_at.duration_since(at).as_secs_f32() * 1000.0; + previous_at = at; + if age > HORIZON_MS || gap > ASSUME_POINTER_MOVE_STOPPED_MS { + break; + } + positions[count] = position; + ages[count] = -age; + count += 1; + if count == HISTORY_SIZE { + break; + } + } + + if count < MIN_SAMPLE_SIZE { + return 0.0; + } + // The 2nd coefficient is the fitted quadratic's derivative at + // x = 0, and x = 0 is the newest sample's own timestamp. ms -> s. + // `None` is Compose's "linearly dependent, no solution" -- see + // `poly_fit_least_squares`. + let Some(fit) = poly_fit_least_squares(&ages[..count], &positions[..count]) else { + return 0.0; + }; + let velocity = fit[1] * 1000.0; + // `calculateVelocity(maximumVelocity)`'s first branch, kept as the + // outer guard even though the degenerate case is now detected + // rather than clamped: a fit can still overflow on inputs nothing + // here has produced, and `ScrollController::fling` asserts finiteness. + if velocity.is_finite() { velocity } else { 0.0 } + } +} + +/// Compose's `polyFitLeastSquares` at its one call site's shape: degree +/// [`FIT_DEGREE`], weights all 1, coefficients lowest order first. Gram- +/// Schmidt QR of the Vandermonde matrix, then back-substitution. +/// +/// Fixed-size arrays rather than Compose's allocated `Matrix`, since both +/// dimensions are constants here -- `FIT_COEFFICIENTS` rows by at most +/// `HISTORY_SIZE` columns. Compose truncates the degree when it has fewer +/// points than coefficients; [`MIN_SAMPLE_SIZE`] makes that unreachable +/// from the only caller, so the truncation is an assert instead of a +/// branch that could never be exercised. +// Both guards stay `debug_assert!` under docs/REVIEW-2026-09-07.md's R1: +// they are preconditions of a fit run on every velocity query, and the two +// callers between them already answer 0 below `MIN_SAMPLE_SIZE` and check +// the result with `is_finite`, so a release build has a defined outcome +// rather than a silently wrong one. +/// `None` where Compose returns no solution: see `DEGENERATE_NORM`. +fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS]> { + debug_assert_eq!(x.len(), y.len()); + debug_assert!( + (FIT_COEFFICIENTS..=HISTORY_SIZE).contains(&x.len()), + "a degree-{FIT_DEGREE} fit needs {FIT_COEFFICIENTS}..={HISTORY_SIZE} points, got {}", + x.len() + ); + let m = x.len(); + + // a[i][h] = x[h]^i. + let mut a = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; + for h in 0..m { + a[0][h] = 1.0; + for i in 1..FIT_COEFFICIENTS { + a[i][h] = a[i - 1][h] * x[h]; + } + } + + // q: orthonormal basis; r: upper triangular. + let mut q = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; + let mut r = [[0.0f32; FIT_COEFFICIENTS]; FIT_COEFFICIENTS]; + for j in 0..FIT_COEFFICIENTS { + q[j][..m].copy_from_slice(&a[j][..m]); + for i in 0..j { + let (earlier, from_j) = q.split_at_mut(j); + let z = &earlier[i]; + let w = &mut from_j[0]; + let dot = dot(&w[..m], &z[..m]); + for h in 0..m { + w[h] -= dot * z[h]; + } + } + // Compose's own bail-out, not a clamp. `polyFitLeastSquares` + // treats a norm this small as "the vectors are linearly dependent, + // so there is no solution" and returns nothing; clamping instead + // -- which this did until 2026-09-07 + // (docs/REVIEW-2026-09-07.md's R7) -- produces a `q` row of zeros, + // a zero on `r`'s diagonal and a 0/0 that only the caller's + // `is_finite` check happened to catch. Working by accident, and + // not what the source it is transcribed from does. + let norm = dot(&q[j][..m], &q[j][..m]).sqrt(); + if norm < DEGENERATE_NORM { + return None; + } + let inverse_norm = 1.0 / norm; + for v in &mut q[j][..m] { + *v *= inverse_norm; + } + for i in 0..FIT_COEFFICIENTS { + r[j][i] = if i < j { + 0.0 + } else { + dot(&q[j][..m], &a[i][..m]) + }; + } + } + + // Solve R B = Qt Y, bottom-right to top-left. + let mut coefficients = [0.0f32; FIT_COEFFICIENTS]; + for i in (0..FIT_COEFFICIENTS).rev() { + let mut c = dot(&q[i][..m], &y[..m]); + for j in ((i + 1)..FIT_COEFFICIENTS).rev() { + c -= r[i][j] * coefficients[j]; + } + coefficients[i] = c / r[i][i]; + } + Some(coefficients) +} + +fn dot(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +/// Android's fling deceleration curve, ported from AOSP's +/// `android.widget.OverScroller.SplineOverScroller` (the same curve +/// Compose's `androidx.compose.animation.AndroidFlingSpline` and +/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here +/// travels the same distance a Compose `LazyColumn`'s own +/// `ScrollableDefaults.flingBehavior()` would for the same initial +/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling +/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum" +/// asked for the same physics a reader's muscle memory already expects +/// from every other Android scroll view. Both sources were read at +/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and +/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` +/// (2026-09-07); they agree line for line. +/// +/// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]` +/// is the fraction of the total distance covered at time fraction +/// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and +/// `(index + 1) / N` -- never between table entries. AOSP builds a second +/// table, `SPLINE_TIME`, purely for `adjustDuration` (re-timing a fling +/// whose target moved), which nothing here has; it is deliberately not +/// built, so there is one array and one indexing rule rather than two of +/// each to pick the wrong one from. +/// +/// The wrong one was picked, and this is what it cost. Until 2026-09-07 +/// the two halves of AOSP's build loop were transposed -- the bisection +/// solved the tension curve and the sample evaluated the `P1`/`P2` one, +/// where AOSP does the opposite -- which made this table and `SPLINE_TIME` +/// *identical*, and the old lookup, which bracketed `t` between +/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling +/// coasted at constant speed for its whole duration and stopped dead: +/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt +/// stop", verbatim out of the arithmetic. Every test it had compared the +/// curve with itself, so none of them could see it; +/// `the_spline_matches_aosps_own_table` pins the absolute numbers now. +mod android_fling_spline { + use std::sync::OnceLock; + + const NB_SAMPLES: usize = 100; + /// Where the two cubic tension lines cross (AOSP's own constant name + /// and value, `SplineOverScroller.INFLEXION`). + pub(super) const INFLEXION: f32 = 0.35; + const START_TENSION: f32 = 0.5; + const END_TENSION: f32 = 1.0; + const P1: f32 = START_TENSION * INFLEXION; + const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); + + /// What a lookup answers: how far along the fling is, and how fast it + /// is going there -- AOSP's `distanceCoef`/`velocityCoef` and Compose's + /// `AndroidFlingSpline.FlingResult`. Both are fractions of the fling's + /// *total* distance, the second per unit of its *total* duration, so a + /// caller scales them by `distance` and `distance / duration`. + pub(super) struct SplineSample { + pub(super) distance_fraction: f32, + pub(super) velocity_fraction: f32, + } + + fn build() -> [f32; NB_SAMPLES + 1] { + let mut position = [0.0f32; NB_SAMPLES + 1]; + let mut x_min = 0.0f32; + for (i, slot) in position.iter_mut().enumerate().take(NB_SAMPLES) { + let alpha = i as f32 / NB_SAMPLES as f32; + let mut x_max = 1.0f32; + let (mut x, mut coef); + loop { + x = x_min + (x_max - x_min) / 2.0; + coef = 3.0 * x * (1.0 - x); + // Solved on the `P1`/`P2` curve and sampled on the tension + // one. Transposing these two is the defect this module's + // doc comment describes; they are not interchangeable. + let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; + if (tx - alpha).abs() < 1e-5 { + break; + } + if tx > alpha { + x_max = x; + } else { + x_min = x; + } + } + *slot = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x; + } + position[NB_SAMPLES] = 1.0; + position + } + + static SPLINE_POSITION: OnceLock<[f32; NB_SAMPLES + 1]> = OnceLock::new(); + + /// Sample the curve at `time_fraction` (0..=1 of the fling's total + /// duration), exactly as AOSP's `SplineOverScroller.update` and + /// Compose's `AndroidFlingSpline.flingPosition` do. + pub(super) fn sample(time_fraction: f32) -> SplineSample { + let position = SPLINE_POSITION.get_or_init(build); + let t = time_fraction.clamp(0.0, 1.0); + let index = (t * NB_SAMPLES as f32) as usize; + if index >= NB_SAMPLES { + // The end of the fling: all of the distance covered and + // nothing left moving. AOSP's `distanceCoef = 1f` / + // `velocityCoef = 0f` defaults, which its + // `if (index < NB_SAMPLES)` leaves in place. + return SplineSample { + distance_fraction: 1.0, + velocity_fraction: 0.0, + }; + } + let t_inf = index as f32 / NB_SAMPLES as f32; + let t_sup = (index + 1) as f32 / NB_SAMPLES as f32; + let velocity_fraction = (position[index + 1] - position[index]) / (t_sup - t_inf); + SplineSample { + distance_fraction: position[index] + (t - t_inf) * velocity_fraction, + velocity_fraction, + } + } +} + +/// AOSP `SplineOverScroller`'s two other physical constants: the default +/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a +/// friction of `0.84` per frame at 60Hz corresponds to +/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`). +const FLING_FRICTION: f32 = 0.015; +/// AOSP's own look-and-feel tuning constant, the argument +/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when +/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a +/// different number used a different place in the same formula. This was +/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too +/// small, which put an `ln` of a 56x-too-large ratio through +/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** +/// instead of 1.6. Nothing could see it while a finger fling never +/// animated at all (`ScrollController::fling`'s doc), which is why two defects had to +/// be fixed before either was visible. +const FLING_TUNING: f32 = 0.84; +fn deceleration_rate() -> f32 { + (0.78f32.ln()) / (0.9f32.ln()) +} +const GRAVITY_EARTH: f32 = 9.80665; + +/// Turns an initial fling velocity into a total travel distance and +/// duration, following AOSP `SplineOverScroller`'s own closed-form +/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) -- +/// ported the same way Compose's `FlingCalculator` is, including its +/// `density`-dependent physical coefficient (`computeDeceleration`, +/// `GravityEarth * 39.37 * density * 160 * friction`). Density and +/// `density` is physical pixels per `dp`, and the velocity handed in has +/// to be in those same physical pixels -- which is what a touch event +/// carries. It does **not** cancel out: `duration` is +/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the +/// wrong density changes how long a fling lasts exponentially rather than +/// scaling it. An earlier version of this comment claimed the opposite and +/// `ScrollController::fling` passed `1.0`; on a 2.75-density screen that gave a +/// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads +/// its density from the painter now. +pub struct FlingCalculator { + physical_coefficient: f32, +} + +impl FlingCalculator { + pub fn new(density: f32) -> Self { + Self { + physical_coefficient: GRAVITY_EARTH * 39.37 * density * 160.0 * FLING_TUNING, + } + } + + fn deceleration_for(&self, velocity: f32) -> f32 { + (android_fling_spline::INFLEXION * velocity.abs() + / (FLING_FRICTION * self.physical_coefficient)) + .ln() + } + + /// Total signed distance the fling travels before settling, in the + /// same pixel units `velocity` was given in. + pub fn distance(&self, velocity: f32) -> f32 { + // See `ScrollController::fling`'s matching assertion -- a non-finite velocity + // here silently produces a NaN distance rather than surfacing the + // bug that produced it (docs/REVIEW-2026-09-06.md finding 3). + debug_assert!(velocity.is_finite()); + if velocity == 0.0 { + return 0.0; + } + let l = self.deceleration_for(velocity); + let rate = deceleration_rate(); + let magnitude = + FLING_FRICTION * self.physical_coefficient * (rate / (rate - 1.0) * l).exp(); + magnitude.copysign(velocity) + } + + /// How long the fling takes to settle. + pub fn duration(&self, velocity: f32) -> Duration { + // See `distance`'s matching assertion, above. + debug_assert!(velocity.is_finite()); + if velocity == 0.0 { + return Duration::ZERO; + } + let l = self.deceleration_for(velocity); + let rate = deceleration_rate(); + Duration::from_secs_f32((l / (rate - 1.0)).exp()) + } + + /// The signed distance covered by `elapsed` into a fling of this + /// `velocity` -- what a per-frame ticker (`ScrollController::tick`) calls to + /// find how far to have scrolled by now. Clamped to the full + /// `distance()` once `elapsed` reaches `duration()`, so a caller need + /// not special-case "past the end." + pub fn position_at(&self, velocity: f32, elapsed: Duration) -> f32 { + let duration = self.duration(velocity); + if duration.is_zero() { + return 0.0; + } + let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); + self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction + } + + /// The signed *speed* at `elapsed` into the same fling, in the units + /// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's + /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start + /// to zero at `duration()`, which is the whole difference between a + /// fling and a constant-speed slide, so it is what + /// `ScrollController::tick`'s debug line reports: successive frames printing + /// a shrinking number is the evidence that the curve is being followed + /// at all. + pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { + let duration = self.duration(velocity); + if duration.is_zero() { + return 0.0; + } + let fraction = elapsed.as_secs_f32() / duration.as_secs_f32(); + android_fling_spline::sample(fraction).velocity_fraction * self.distance(velocity) + / duration.as_secs_f32() + } +} + +/// One fling in flight: the physics ([`FlingCalculator`]) plus how much of +/// its total travel has already been applied, so a tick only ever hands +/// back this frame's *incremental* delta. +/// +/// It owns the curve and the clock and nothing else. Which way a positive +/// delta moves the content, and whether the content has anywhere left to +/// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a +/// `ScrollArea` moves its `amt` the other, and a `Flinger` that tried to know +/// which would have to be told, which is the same thing as not knowing. +/// So a caller applies [`Self::tick`]'s delta in its own convention and +/// calls [`Self::stop`] when it runs out of content. +/// +/// Every scrolling widget in this crate flings through this one type, +/// which is what Iris's 2026-09-08 "flinging doesn't work in horizontal +/// scroll areas -- flinging should be enabled by default in all scroll +/// areas on android to match compose's behavior" asks for: Compose's +/// `scrollable` attaches `ScrollableDefaults.flingBehavior()` on every +/// axis, and it is not something a caller opts into. +pub struct Flinger { + fling: Option, +} + +struct InFlight { + calc: FlingCalculator, + velocity: f32, + /// When the curve begins -- **the first [`Flinger::tick`], not the + /// release**. Set there so the only clock this reads is the one its + /// driver hands it: a caller running frames on an explicit clock + /// (`iris::harness`, `bench_client.rs`'s scripted phases) would + /// otherwise start every fling at the wall clock and advance it on a + /// different one, and a fling released at t=500ms would arrive + /// already over. In a running app the difference is at most one + /// frame, since that is how soon a fling is first ticked. + started_at: Option, + applied: f32, +} + +impl Default for Flinger { + fn default() -> Self { + Self::new() + } +} + +impl Flinger { + pub fn new() -> Self { + Self { fling: None } + } + + /// Start a fling at `velocity_px_per_s`, in whatever pixel space the + /// caller applies [`Self::tick`]'s delta in. `density` is physical + /// pixels per dp, from the painter -- it does **not** cancel out of + /// the spline (see [`FlingCalculator`]), and a hardcoded 1.0 against a + /// 2.55-density screen made a one-second coast run for 45. + /// + /// Answers whether a fling actually started, which is a caller's cue + /// to register for frames (`UiData::animate`): registering a widget + /// that is not animating only asks the next frame to find that out. + /// Cancels any fling already in progress. + /// + /// Compose's two thresholds at a release, and **only** those two. The + /// maximum is `ViewConfiguration.getScaledMaximumFlingVelocity()` + /// (8000dp/s), which `DragGestureNode.sendDragStopped` passes into + /// `VelocityTracker.calculateVelocity(maximumVelocity)`; it is applied + /// here rather than in the tracker because the tracker works in pixels + /// and has no density. The minimum is 1px/s, from + /// `DefaultFlingBehavior.performFling`'s `abs(initialVelocity) > 1f` + /// and its own stated reason ("we need it since spline curve gives us + /// NaNs") -- **not** + /// `ViewConfiguration.getScaledMinimumFlingVelocity()`'s 50dp/s, whose + /// single use in either artifact is `NestedScrollInteropConnection`, + /// for View interop. A 50dp/s floor would swallow slow, deliberate + /// releases that Compose flings. + pub fn start(&mut self, velocity_px_per_s: f32, density: f32) -> bool { + // A NaN/inf velocity (a `VelocityTracker::velocity()` + // divide-by-near-zero span, or a caller passing a raw device value + // straight through) would propagate silently into + // `deceleration_for`'s `.ln()` -- the fling either never settles + // or jumps to NaN positions with nothing on screen saying why + // (docs/REVIEW-2026-09-06.md finding 3). A plain `assert!` rather + // than a `debug_assert!`: it is one comparison per *gesture*, and + // every build anybody runs -- the emulator's and Iris's phone's -- + // is release, where a debug-only guard against silently wrong + // output is no guard at all (docs/REVIEW-2026-09-07.md's R1). + assert!(velocity_px_per_s.is_finite()); + assert!(density.is_finite() && density > 0.0); + let max = MAX_FLING_VELOCITY_DP_S * density; + let velocity_px_per_s = velocity_px_per_s.clamp(-max, max); + if velocity_px_per_s.abs() <= 1.0 { + self.fling = None; + return false; + } + self.fling = Some(InFlight { + calc: FlingCalculator::new(density), + velocity: velocity_px_per_s, + started_at: None, + applied: 0.0, + }); + true + } + + /// Whether a fling is in flight. What a caller polls to decide whether + /// a fresh press is a *catch* ([`PressState::scrolling`]) and when to + /// stop driving [`Self::tick`]. + pub fn is_flinging(&self) -> bool { + self.fling.is_some() + } + + /// The velocity a fling in progress is coasting at, `None` at rest -- + /// what a test reads to see what a release actually measured, at the + /// place it landed. + pub fn velocity(&self) -> Option { + self.fling.as_ref().map(|f| f.velocity) + } + + /// End any fling with no further movement -- the next touch-down's + /// job (Android's `Scroller::abortAnimation`, which the view is + /// likewise expected to call: the curve has no idea a finger came back + /// down), and equally what a caller calls when the content has run out + /// underneath it. + pub fn stop(&mut self) { + self.fling = None; + } + + /// Advance to `now` and answer how far to move the content *this* + /// frame, in the caller's own sign convention. `0.0` with nothing + /// flinging, so a caller does not need to check first; the fling ends + /// itself on the spline's own schedule, after which + /// [`Self::is_flinging`] is false and the caller stops asking for + /// frames. + pub fn tick(&mut self, now: Instant) -> f32 { + let Some(f) = &mut self.fling else { + return 0.0; + }; + let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now)); + let target = f.calc.position_at(f.velocity, elapsed); + let delta = target - f.applied; + f.applied = target; + // The evidence that the spline is actually being followed, at the + // one granularity where a linear coast and a decelerating one look + // different: successive `dy` and `speed` shrinking. It was neither + // observable nor observed while `distance_fraction` returned `t` + // (`android_fling_spline`'s doc). Gated on + // `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/ + // RUST.md's review, D1): one line per fling *tick*, unconditional, + // was enough on its own to help fill the log ring. + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::frame", + "iris fling tick: t={:.3}s dy={:+.1}px speed={:.0}px/s of {:.0} left={:.1}px", + elapsed.as_secs_f32(), + delta, + f.calc.velocity_at(f.velocity, elapsed), + f.velocity, + f.calc.distance(f.velocity) - target, + ); + } + if elapsed >= f.calc.duration(f.velocity) { + self.fling = None; + } + delta + } +} + +#[cfg(test)] +mod velocity_tracker_tests { + use super::*; + use std::sync::LazyLock; + + // A single fixed base rather than a fresh `Instant::now()` per call -- + // computing it once per test keeps every sample's spacing exact + // instead of at the mercy of however long the test itself takes to + // run between calls, the same reasoning `drag_arbiter_tests::t` uses. + static BASE: LazyLock = LazyLock::new(Instant::now); + + fn t(ms: u64) -> Instant { + *BASE + Duration::from_millis(ms) + } + + /// Every number below is printed by `iris/benches/velocity_reference.py`, + /// an independent transcription of the same Kotlin. Do not "fix" one by + /// running the Rust and copying what it said -- that is exactly how the + /// fling spline shipped as a straight line for two builds. + fn tracker(samples: &[(u64, f32)]) -> VelocityTracker { + let mut v = VelocityTracker::new(); + for &(ms, position) in samples { + v.add_position(position, t(ms)); + } + v + } + + /// f32 Gram-Schmidt against the reference's f64: the fits here agree to + /// several digits, so a tolerance this tight still fails by a mile on + /// any wrong estimator (the average misses by 25--125%). + fn assert_velocity(samples: &[(u64, f32)], expected: f32) { + let got = tracker(samples).velocity(); + let tolerance = expected.abs() * 1e-3 + 1e-3; + assert!( + (got - expected).abs() <= tolerance, + "expected {expected} from velocity_reference.py, got {got}" + ); + } + + /// `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds + /// it: the DOWN position and one position per MOVE, no sample for the + /// UP (Compose's `Lsq2VelocityTracker.addPointerInputChange` adds none + /// either). **The before/after of Iris's 2026-09-07 report** on this + /// recording: the average answered 12250px/s, Compose answers 15250. + const FLICK_120HZ: [(u64, f32); 5] = [ + (0, 1000.0), + (4, 1040.0), + (8, 1086.0), + (12, 1138.0), + (16, 1196.0), + ]; + + #[test] + fn the_recorded_flick_reads_what_compose_reads() { + assert_velocity(&FLICK_120HZ, 15250.0); + } + + #[test] + fn a_steady_drag_reports_its_own_speed() { + // 5px every 10ms, 11 samples spanning 100ms. The one case where a + // constant-velocity fit and an average must agree -- kept because + // it is the sanity check, and kept *with* the two below because on + // its own it cannot tell the two estimators apart at all. + let samples: Vec<(u64, f32)> = (0..=10).map(|i| (i * 10, (i * 5) as f32)).collect(); + assert_velocity(&samples, 500.0); + } + + /// **The negative control for the whole change.** Deltas doubling into + /// the release: Compose reads 2445px/s where the average reads 1080, + /// so a flick started at 44% of the speed the finger asked for. This + /// is the test the old estimator fails and the steady drag above does + /// not. Reverting `velocity` to `(newest - oldest) / span` fails seven + /// -- this one, the flick recording, the horizon, the stopped finger, + /// the minimum sample count and both `drag_gesture` flick tests, plus + /// `phone_screen.rs`'s flick -- and leaves everything else green. Rerun + /// 2026-09-07 (docs/REVIEW-2026-09-07.md): this comment used to say + /// "exactly this one, the flick recording, and `phone_screen.rs`", + /// which disagreed with docs/RUST.md's count of the same experiment. + /// Seven is what the run prints; RUST.md was right. + #[test] + fn an_accelerating_flick_reads_its_speed_at_the_release() { + const ACCELERATING: [(u64, f32); 6] = [ + (0, 0.0), + (10, 2.0), + (20, 6.0), + (30, 14.0), + (40, 30.0), + (50, 54.0), + ]; + assert_velocity(&ACCELERATING, 2445.0); + let average: f32 = 54.0 / 0.050; + assert!( + (average - 1080.0).abs() < 1.0, + "the average this is a control against moved: {average}" + ); + } + + #[test] + fn fewer_than_three_samples_reports_zero() { + // Compose's `minSampleSize` for Lsq2 is 3: a quadratic through two + // points is not a fit. So a press and a single move do not fling, + // which is what Compose does with the same two samples. + assert_eq!(VelocityTracker::new().velocity(), 0.0); + assert_velocity(&[(0, 0.0)], 0.0); + assert_velocity(&[(0, 0.0), (8, 100.0)], 0.0); + } + + #[test] + fn only_the_last_100ms_of_samples_count() { + // An old, fast jump outside `HORIZON_MS` followed by a slow steady + // drag reports the recent speed, not both. 1px/10ms = 100px/s; the + // average of the whole set is 9182px/s. + let mut samples = vec![(0u64, 0.0f32)]; + samples.extend((0..11).map(|i| (10 + i * 10, 1000.0 + i as f32))); + assert_velocity(&samples, 100.0); + } + + #[test] + fn a_finger_that_stops_before_lifting_does_not_fling() { + // A 48ms gap is past `ASSUME_POINTER_MOVE_STOPPED_MS`, so the walk + // back stops at it and the fast motion before it is a different + // gesture. One usable sample, so 0 -- where the average would + // still say 2533px/s and fling from a standstill. + assert_velocity( + &[(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)], + 0.0, + ); + } + + #[test] + fn reset_forgets_prior_samples() { + let mut v = tracker(&FLICK_120HZ); + assert!(v.velocity() != 0.0); + v.reset(); + assert_eq!(v.velocity(), 0.0); + assert_eq!(v.sample_count(), 0); + assert_eq!(v.samples_display(), ""); + } + + #[test] + fn only_the_last_twenty_samples_are_held() { + // `HISTORY_SIZE`. The oldest fall out rather than the newest being + // refused -- a long drag's velocity is its recent motion. + let samples: Vec<(u64, f32)> = (0..40).map(|i| (i * 4, (i * 10) as f32)).collect(); + let v = tracker(&samples); + assert_eq!(v.sample_count(), HISTORY_SIZE); + assert_eq!( + v.span(), + Duration::from_millis(4 * (HISTORY_SIZE as u64 - 1)) + ); + } + + /// docs/REVIEW-2026-09-07.md's R7. Three samples at one instant -- + /// which the input clock produced on its own before 2ec0fee -- leave + /// the second basis vector all zeros, and Compose calls that "linearly + /// dependent, no solution" and returns nothing. Clamping the norm to + /// 1e-6 instead reached the solve with a zero on `r`'s diagonal and + /// answered `[NaN, NaN, NaN]`, which only the caller's `is_finite` + /// check kept off the fling path. + #[test] + fn a_fit_through_linearly_dependent_points_has_no_solution() { + assert_eq!( + poly_fit_least_squares(&[0.0, 0.0, 0.0], &[0.0, 40.0, 90.0]), + None, + ); + // The other half: an ordinary set still fits. + let fit = poly_fit_least_squares(&[-8.0, -4.0, 0.0], &[1000.0, 1040.0, 1086.0]) + .expect("three distinct points describe a quadratic"); + assert!(fit.iter().all(|c| c.is_finite())); + } + + #[test] + fn the_sample_list_is_reported_relative_to_the_first() { + // What the release log prints at debug level, and what a phone + // report has to be replayable from. + assert_eq!( + tracker(&FLICK_120HZ[..3]).samples_display(), + "0.0:1000.0 4.0:1040.0 8.0:1086.0" + ); + } +} + +#[cfg(test)] +mod fling_calculator_tests { + use super::*; + + #[test] + fn zero_velocity_flings_nowhere() { + let calc = FlingCalculator::new(1.0); + assert_eq!(calc.distance(0.0), 0.0); + assert_eq!(calc.duration(0.0), Duration::ZERO); + } + + #[test] + fn distance_grows_with_velocity_and_keeps_its_sign() { + let calc = FlingCalculator::new(2.75); // a typical phone's density + let d_slow = calc.distance(2000.0); + let d_fast = calc.distance(12000.0); + assert!(d_slow > 0.0); + assert!(d_fast > d_slow); + assert_eq!(calc.distance(-12000.0), -d_fast); + } + + /// Summing the spline's own per-frame position deltas across the + /// whole fling has to land within 1% of the closed-form `distance()` + /// -- this is the guarantee that `ScrollController::tick`'s per-frame reads + /// of `position_at` actually add up to the total the fling promised, + /// not merely that the two formulas look plausible independently. + #[test] + fn integrating_position_at_matches_the_closed_form_distance() { + let calc = FlingCalculator::new(1.0); + for velocity in [1500.0f32, 5000.0, 12000.0, -12000.0] { + let total = calc.distance(velocity); + let duration = calc.duration(velocity); + let final_position = calc.position_at(velocity, duration); + let err = (final_position - total).abs() / total.abs(); + assert!( + err < 0.01, + "velocity {velocity}: position_at(duration)={final_position} vs distance()={total}, err={err}" + ); + } + } + + /// The absolute numbers, against AOSP's own formula worked by hand -- + /// the one thing every other test here cannot see, because they all + /// compare this calculator with itself (monotonic, signed, integrates + /// to the closed form) and so pass just as happily with a coefficient + /// 56x out. That is exactly the state this file was in: an ordinary + /// flick lasted 30 seconds on the emulator and every test was green. + /// + /// `SplineOverScroller` at ppi = 2.75*160 = 440: + /// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`; + /// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`; + /// `duration = exp(l / (DECELERATION_RATE - 1))`. + /// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s, + /// 1.586s. + #[test] + fn a_flick_lasts_what_aosps_own_formula_says_it_does() { + let calc = FlingCalculator::new(2.75); + let slow = calc.duration(3000.0).as_secs_f32(); + assert!( + (slow - 0.592).abs() < 0.02, + "3000px/s at density 2.75 should settle in ~0.59s, got {slow}s" + ); + let distance = calc.distance(3000.0); + assert!( + (distance - 621.5).abs() < 5.0, + "3000px/s at density 2.75 should travel ~621px, got {distance}" + ); + let fast = calc.duration(11444.0).as_secs_f32(); + assert!( + (fast - 1.586).abs() < 0.05, + "11444px/s at density 2.75 should settle in ~1.59s, got {fast}s" + ); + } + + #[test] + fn position_at_is_monotonic_and_clamped_past_the_end() { + let calc = FlingCalculator::new(1.0); + let velocity = 12000.0f32; + let duration = calc.duration(velocity); + let total = calc.distance(velocity); + let mut last = 0.0; + let mut t = Duration::ZERO; + while t < duration { + let p = calc.position_at(velocity, t); + assert!(p >= last - 0.01, "position went backwards at {t:?}"); + last = p; + t += Duration::from_millis(16); + } + // Well past the end, it stays pinned at the total -- a caller + // must be able to ask "where would this fling be" without first + // checking whether it has already settled. + assert_eq!( + calc.position_at(velocity, duration + Duration::from_secs(5)), + total + ); + } + + /// The table itself, against AOSP's own entries. Every number here + /// came out of `benches/fling_spline_reference.py`, which is a + /// separate hand transcription of `OverScroller.java` and + /// `SplineBasedDecay.kt` -- so this is the one test in the file that + /// is not the Rust code grading its own homework, and the only kind + /// that could have caught the transposed build loop + /// `android_fling_spline`'s doc describes. + /// + /// The property that names the old defect directly: the curve is + /// **not** the identity. At a tenth of the way through its time a + /// fling has covered 27.4% of its distance, and at half its time + /// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed + /// slide -- so the two `assert!`s below fail by a factor of three. + #[test] + fn the_spline_matches_aosps_own_table() { + for (t, expected) in [ + (0.0f32, 0.000023f32), + (0.1, 0.274002), + (0.25, 0.583811), + (0.5, 0.858411), + (0.75, 0.971068), + (0.9, 0.995811), + (1.0, 1.0), + ] { + let got = android_fling_spline::sample(t).distance_fraction; + assert!( + (got - expected).abs() < 1e-4, + "distance fraction at t={t}: got {got}, AOSP says {expected}" + ); + } + // Speed falls monotonically to nothing -- the difference between + // a fling and a slide, and what the abrupt stop was. + let mut last = f32::INFINITY; + for step in 0..=100 { + let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction; + assert!(v <= last + 1e-4, "speed rose at t={step}/100: {v} > {last}"); + last = v; + } + assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0); + } + + /// The same curve carried through `distance`/`duration` at Iris's own + /// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`), + /// again with every number from `benches/fling_spline_reference.py`. + /// A fling's *speed* a third of the way through is 4733px/s out of an + /// initial 11064 -- what a reader sees as deceleration, and the + /// quantity that was constant before this. + /// + /// The sample fractions are deliberately not round: the velocity + /// coefficient is piecewise constant across each of the 100 samples, + /// so `0.75` sits exactly on a step and the assertion would be about + /// which side of it the last float landed rather than about the curve. + #[test] + fn a_flick_decelerates_the_way_aosp_says_it_does() { + let calc = FlingCalculator::new(2.55); + let velocity = 11064.0f32; + let duration = calc.duration(velocity); + assert!( + (duration.as_secs_f32() - 1.6357).abs() < 0.01, + "duration {duration:?}" + ); + assert!( + (calc.distance(velocity) - 6334.2).abs() < 5.0, + "distance {}", + calc.distance(velocity) + ); + for (fraction, position, speed) in [ + (0.125f32, 2123.3f32, 9202.1f32), + (0.335, 4458.3, 4733.0), + (0.505, 5459.0, 2649.6), + (0.755, 6158.7, 950.9), + ] { + let at = duration.mul_f32(fraction); + let got_position = calc.position_at(velocity, at); + let got_speed = calc.velocity_at(velocity, at); + assert!( + (got_position - position).abs() < 5.0, + "position at {fraction} of the fling: got {got_position}, AOSP says {position}" + ); + assert!( + (got_speed - speed).abs() < 20.0, + "speed at {fraction} of the fling: got {got_speed}, AOSP says {speed}" + ); + } + assert_eq!(calc.velocity_at(velocity, duration), 0.0); + } +} + +#[cfg(test)] +mod drag_arbiter_tests { + use super::*; + + fn t(ms: u64) -> Instant { + // A fixed base plus an offset, rather than `Instant::now()` per + // call -- keeps every test's timing deterministic instead of at + // the mercy of how long the test itself took to run. + Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms) + } + + #[test] + fn small_jitter_stays_undecided() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!(a.update(Vec2::new(1.0, 1.0), t(10)), DragOutcome::Undecided); + } + + #[test] + fn a_vertical_drag_pans_immediately() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + // The transition frame applies only the motion past `DRAG_SLOP` + // (20 - 8 = 12), not the full 20px since `press_start` -- see the + // `Pan` arm's own comment for why replaying the whole withheld + // drag in one step is the scroll-jitter bug this guards against. + assert_eq!( + a.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Pan(12.0) + ); + // Subsequent frames keep panning, by the delta since last frame. + assert_eq!( + a.update(Vec2::new(0.0, 35.0), t(20)), + DragOutcome::Pan(15.0) + ); + } + + /// Direct regression test for the fix: a slow drag that crosses + /// `DRAG_SLOP` by only a fraction of a pixel must not still produce a + /// visible jump -- the amount applied on the crossing frame should + /// itself shrink toward zero as the crossing gets closer to exactly + /// `DRAG_SLOP`, rather than always dumping the whole pre-threshold + /// distance at once. + #[test] + fn crossing_the_slop_by_a_little_pans_by_a_little() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + a.update(Vec2::new(0.0, DRAG_SLOP + 0.5), t(10)), + DragOutcome::Pan(0.5) + ); + } + + #[test] + fn a_horizontal_drag_with_nothing_selected_does_not_select() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + // Horizontal movement alone, with no prior selection, is not any + // of the three named gestures -- it stays undecided rather than + // guessing (it will resolve to a long-press-selection if the + // finger then stops moving, or nothing if it lifts). + assert_eq!( + a.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Undecided + ); + } + + #[test] + fn a_long_press_without_moving_starts_a_selection() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(5.0, 5.0), t(0), PressState::default()); + assert_eq!(a.update(Vec2::new(5.0, 5.0), t(10)), DragOutcome::Undecided); + assert_eq!( + a.update(Vec2::new(6.0, 5.0), t(LONG_PRESS.as_millis() as u64 + 1)), + DragOutcome::SelectStart + ); + } + + #[test] + fn after_a_long_press_any_further_drag_extends() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)), + DragOutcome::SelectStart + ); + // Even a vertical move now extends the selection rather than + // panning -- once a selection has started, it owns the gesture + // until release. + assert_eq!( + a.update(Vec2::new(0.0, 40.0), t(600)), + DragOutcome::SelectExtend + ); + } + + #[test] + fn a_horizontal_drag_on_already_selected_text_extends_immediately() { + let mut a = DragArbiter::new(); + a.press_start( + Vec2::new(0.0, 0.0), + t(0), + PressState { + already_selected: true, + ..Default::default() + }, + ); + assert_eq!( + a.update(Vec2::new(20.0, 2.0), t(10)), + DragOutcome::SelectExtend + ); + } + + #[test] + fn a_vertical_drag_still_pans_even_with_a_prior_selection() { + let mut a = DragArbiter::new(); + a.press_start( + Vec2::new(0.0, 0.0), + t(0), + PressState { + already_selected: true, + ..Default::default() + }, + ); + assert_eq!( + a.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Pan(12.0) + ); + } + + /// A fresh arbiter that never saw `press_start` -- the state a widget's + /// own arbiter is left in when a gesture's `ACTION_DOWN` landed on a + /// pixel no sensor covered (a row's padding/gap, or its header) and + /// only a later `ACTION_MOVE` reached this widget. `update` must not + /// silently swallow the whole rest of the gesture here; `is_idle` is + /// what a caller checks to notice and recover (RUST.md's I5 + /// intermittent-touch-scroll-dropout finding, 2026-09-05) -- + /// `transcript_ui::selection::Selection::drag` is the real caller, + /// this is the pure-state half of the fix. + #[test] + fn is_idle_reports_a_press_that_was_never_started() { + let a = DragArbiter::new(); + assert!(a.is_idle()); + } + + #[test] + fn update_on_an_idle_arbiter_stays_undecided_forever_without_recovery() { + // Documents the failure this fix works around: calling `update` + // (as if the arbiter were mid-gesture) without ever having called + // `press_start` leaves it stuck answering `Undecided`, even for a + // movement well past `DRAG_SLOP` that would otherwise pan + // immediately. + let mut a = DragArbiter::new(); + assert_eq!( + a.update(Vec2::new(0.0, 100.0), t(10)), + DragOutcome::Undecided + ); + assert!(a.is_idle()); + } + + #[test] + fn a_caller_can_recover_a_missed_press_start_via_is_idle() { + let mut a = DragArbiter::new(); + // Simulates the real call site: a `Pressing` frame arrives with no + // matching `PressStart` ever having reached this arbiter. + assert!(a.is_idle()); + a.press_start(Vec2::new(0.0, 700.0), t(0), PressState::default()); + assert_eq!( + a.update(Vec2::new(0.0, 720.0), t(10)), + DragOutcome::Pan(12.0) + ); + assert!(!a.is_idle()); + } + + /// The tap-vs-drag rule a markdown link is followed by + /// (`transcript-ui`'s `row.rs`): a press that never committed is a + /// tap, and a press that panned or selected is not -- read from this + /// one machine rather than timed a second time beside it. + #[test] + fn a_press_that_never_moved_is_still_undecided_at_release() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + a.update(Vec2::new(1.0, 1.0), t(10)); + assert!(a.is_undecided()); + assert!(!a.is_panning()); + } + + /// The half the tap rule had no reason to touch: a gesture that + /// panned must not also read as a tap when the finger comes up over + /// the link it started on. + #[test] + fn a_press_that_panned_is_not_undecided_at_release() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + a.update(Vec2::new(0.0, 40.0), t(10)); + assert!(a.is_panning()); + assert!(!a.is_undecided()); + } + + /// A long press that grew a selection is not a tap either. + #[test] + fn a_long_press_that_selected_is_not_undecided() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + a.update(Vec2::new(0.0, 1.0), t(LONG_PRESS.as_millis() as u64 + 10)), + DragOutcome::SelectStart + ); + assert!(!a.is_undecided()); + } + + /// Both axes are one machine with the axis passed in: a horizontal + /// arbiter (a code fence panning across its own long lines) pans on + /// exactly the drag a vertical one ignores, and ignores the one it + /// pans on. + #[test] + fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() { + let mut across = DragArbiter::on(Axis::X); + across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + across.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Pan(12.0) + ); + + let mut down = DragArbiter::new(); + down.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + down.update(Vec2::new(20.0, 0.0), t(10)), + DragOutcome::Undecided + ); + + // ...and a vertical drag over the horizontal one stays undecided, + // which is what lets the list behind a code fence still be + // panned by a finger that started on the fence. + let mut across = DragArbiter::on(Axis::X); + across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + assert_eq!( + across.update(Vec2::new(0.0, 20.0), t(10)), + DragOutcome::Undecided + ); + } + + #[test] + fn release_resets_to_idle() { + let mut a = DragArbiter::new(); + a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); + a.update(Vec2::new(0.0, 20.0), t(10)); + a.release(); + assert_eq!( + a.update(Vec2::new(0.0, 999.0), t(20)), + DragOutcome::Undecided + ); + } +} + +/// [`DragGesture`] end to end, at the shape Android actually delivers a +/// flick in. The arbiter and the tracker each behave correctly on their +/// own (the two modules above); what these cover is the join between them +/// at release, which is where the phone's missing fling lived. +#[cfg(test)] +mod drag_gesture_tests { + use super::*; + use std::sync::LazyLock; + + static BASE: LazyLock = LazyLock::new(Instant::now); + + fn t(ms: u64) -> Instant { + *BASE + Duration::from_millis(ms) + } + + /// The pointer as a handler sees it, with nothing captured. + /// `DragGesture` only ever reads and sets the holder, which needs no + /// widget tree behind it. + fn pointer() -> PointerRequests { + PointerRequests::default() + } + + /// The id a capture records. Any id will do -- nothing here + /// resolves it -- so it comes from a real (empty) widget registry + /// rather than being fabricated. + fn some_id(ui: &mut UiData) -> WidgetId { + ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id() + } + + /// **The phone's shape.** A 120Hz flick reaches the app as very few + /// `MotionEvent`s -- the intermediate positions are batched inside + /// them as historical samples, which `IrisViewPeer::on_touch_event` + /// replays one at a time, so the frames here are what a whole flick + /// can amount to. Compose fits a quadratic through the positions, so + /// three of them (the press and two moves) is the fewest that can + /// fling; the number is `velocity_reference.py`'s, not this code's. + #[test] + fn a_flick_delivered_as_two_move_frames_releases_with_a_velocity() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 100.0), + t(8), + PressState::default(), + ); + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 220.0), + t(16), + PressState::default(), + ); + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::new(0.0, 220.0), + t(24), + PressState::default(), + ); + + // (0, 0) (8, 100) (16, 220) through Compose's Lsq2 fit. Note the + // release adds no sample -- Compose's tracker ignores the UP + // position -- so the finger resting for those last 8ms costs + // nothing, which is the whole point of ignoring it. + match out { + GestureOutcome::Released(Some(v)) => { + assert!((v - 16250.0).abs() < 20.0, "expected ~16250, got {v}"); + } + other => panic!("expected a released pan, got {other:?}"), + } + } + + /// The case the port had every reason to get wrong: **one** move frame + /// is two samples, and a quadratic through two points is not a fit. + /// Compose answers 0 here (`minSampleSize = 3`) and so does this, so + /// the release is a pan that flings nothing rather than a pan that + /// flings at a guessed speed. Before 2026-09-07 the average answered + /// 12500px/s from the same gesture. + #[test] + fn a_flick_delivered_as_one_move_frame_carries_no_velocity_to_fit() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 100.0), + t(8), + PressState::default(), + ); + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::new(0.0, 100.0), + t(16), + PressState::default(), + ); + assert_eq!(out, GestureOutcome::Released(Some(0.0))); + } + + /// The other half of the same join, and the case the fix had no + /// reason to touch: a press and release with no motion at all is a + /// tap, and must not acquire a velocity from the seeded press sample. + #[test] + fn a_tap_is_still_a_tap_and_flings_nothing() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::ZERO, + t(20), + PressState::default(), + ); + assert_eq!(out, GestureOutcome::Tapped); + } + + /// A long-press selection released while the finger was still moving + /// must not fling either -- `Released(None)`, never the tracked + /// velocity. Also untouched by the press-seeding above, which is why + /// it is checked here rather than assumed. + #[test] + fn a_selection_release_carries_no_velocity() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + // Held still past LONG_PRESS, which is what starts a selection. + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::ZERO, + t(0) + LONG_PRESS, + PressState::default(), + ); + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::new(0.0, 50.0), + t(0) + LONG_PRESS + Duration::from_millis(10), + PressState::default(), + ); + assert_eq!(out, GestureOutcome::Released(None)); + } + + /// A catch: the press lands on content that is already moving, so it + /// pans from its very first sample with no `DRAG_SLOP` withheld -- + /// docs/IRIS_TODO.md's "it fails to stop & snap to where finger is". + #[test] + fn a_press_on_moving_content_pans_from_the_first_sample() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + let caught = PressState { + scrolling: true, + ..Default::default() + }; + + assert_eq!( + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + caught, + ), + // The down itself moves nothing; it only stops the fling. + GestureOutcome::Pan(0.0), + ); + // A move of 2px, a quarter of `DRAG_SLOP` -- an ordinary press + // would still be `Undecided` here. + assert_eq!( + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 2.0), + t(8), + caught, + ), + GestureOutcome::Pan(2.0), + ); + } + + /// The sibling of the case above, and the pair that says the catch is + /// not simply "every press pans": the same two samples with nothing + /// moving underneath stay inside the slop and decide nothing. + #[test] + fn the_same_press_on_settled_content_stays_undecided() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + assert_eq!( + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 2.0), + t(8), + PressState::default(), + ), + GestureOutcome::Undecided, + ); + } + + /// A catch released without moving is `Released(None)`: not a + /// `Tapped`, because Compose's scrollable consumed that DOWN and no + /// click detector under it ever saw the gesture -- so stopping a + /// fling with a finger must not also follow the link it landed on -- + /// and not a `Released(Some(v))`, because there is no velocity to + /// hand on. + #[test] + fn a_catch_released_without_moving_is_neither_a_tap_nor_a_fling() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + let caught = PressState { + scrolling: true, + ..Default::default() + }; + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + caught, + ); + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::ZERO, + t(20), + caught, + ); + assert_eq!(out, GestureOutcome::Released(None)); + // The identical gesture with nothing moving underneath is the + // tap it looks like -- `a_tap_is_still_a_tap_and_flings_nothing` + // above. The two differ by `scrolling` and nothing else. + } + + /// Once a catch has actually moved the content, its release is an + /// ordinary pan release again and hands on a velocity -- otherwise + /// "catch it, then keep flicking" would stop dead every time. + #[test] + fn a_catch_that_then_drags_still_flings() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + let caught = PressState { + scrolling: true, + ..Default::default() + }; + + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + caught, + ); + for (i, y) in [100.0, 220.0].into_iter().enumerate() { + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, y), + t(8 * (i as u64 + 1)), + caught, + ); + } + let out = g.handle( + &r, + id, + CursorSense::PressEnd(CursorButton::Left), + Vec2::new(0.0, 220.0), + t(24), + caught, + ); + assert!( + matches!(out, GestureOutcome::Released(Some(v)) if v.abs() > 1.0), + "a catch that dragged must release with a velocity, got {out:?}" + ); + } + + /// One touch-down reaches every sensor under the finger, and a + /// transcript row's block and the tool row containing it share one + /// `DragGesture` -- so `handle` sees the same `PressStart` twice, and + /// the second delivery carries a `PressState` the first has already + /// acted on (`Selection::drag` has cancelled the fling by then, so + /// `scrolling` is false). It must be a continuation, not a restart; + /// as a restart it silently turned every catch back into an ordinary + /// slop-waiting press, which is how this was found. + #[test] + fn a_second_delivery_of_one_press_start_does_not_restart_it() { + let mut ui = UiData::default(); + let id = some_id(&mut ui); + let r = pointer(); + let mut g = DragGesture::new(); + + let caught = PressState { + scrolling: true, + ..Default::default() + }; + assert!(g.starts_press(CursorSense::PressStart(CursorButton::Left))); + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + caught, + ); + assert!( + !g.starts_press(CursorSense::PressStart(CursorButton::Left)), + "the second sensor must be told this press is already in flight" + ); + g.handle( + &r, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::ZERO, + t(0), + PressState::default(), + ); + assert_eq!( + g.handle( + &r, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 2.0), + t(8), + PressState::default(), + ), + GestureOutcome::Pan(2.0), + "the catch survived only if the second delivery left it panning" + ); + } +} diff --git a/src/sense_tests.rs b/src/sense_tests.rs new file mode 100644 index 0000000..357761d --- /dev/null +++ b/src/sense_tests.rs @@ -0,0 +1,781 @@ +//! IRIS_TODO.md's "Input does not fall through by input type": a widget +//! that only registered `click()` used to also block a `ScrollArea` meant for +//! whatever is behind it, because `run_sensors` decided "consumed, stop +//! looking at lower layers" from mere hover, not from anything actually +//! matching. Exercised as a plain unit test for the same reason +//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents` +//! impl need no GPU or window. + +use crate::prelude::*; +use std::{cell::Cell, rc::Rc, time::Instant}; + +struct SenseRsc { + ui: UiData, + events: EventManager, +} + +impl UiRsc for SenseRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + } +} + +impl HasState for SenseRsc { + type State = (); +} + +impl HasEvents for SenseRsc { + fn events(&self) -> &EventManager { + &self.events + } + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +fn cursor_at(pos: Vec2) -> CursorState { + CursorState { + pos, + exists: true, + buttons: Default::default(), + scroll_delta: Vec2::ZERO, + ..Default::default() + } +} + +#[test] +fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // Both cover the whole window -- the button "sitting over" the list, + // the case in IRIS_TODO.md's report. + let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let list_weak = list.weak(); + let button = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)); + let button_weak = button.weak(); + + let scrolled = Rc::new(Cell::new(false)); + let clicked = Rc::new(Cell::new(false)); + { + let scrolled = scrolled.clone(); + rsc.register_event(list_weak, CursorSense::Scroll, move |_ctx, _rsc| { + scrolled.set(true); + }); + } + { + let clicked = clicked.clone(); + rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| { + clicked.set(true); + }); + } + + // A Stack draws its children on separate layers in order, which is + // exactly the "one thing drawn over another" shape `run_sensors` + // walks top layer first. + let root = rsc + .ui + .widgets + .add_strong(Stack { + children: vec![list.any(), button.any()], + size: StackSize::default(), + }) + .any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let mut state = (); + let mut scroll_cursor = cursor_at((50.0, 50.0).into()); + scroll_cursor.scroll_delta = (0.0, 10.0).into(); + render.run_sensors(&mut rsc, &mut state, scroll_cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + + assert!( + scrolled.get(), + "a scroll over the button must still reach the list underneath it" + ); + assert!( + !clicked.get(), + "a scroll is not a click; the button must not have fired" + ); + + let mut click_cursor = cursor_at((50.0, 50.0).into()); + click_cursor.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, click_cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + + assert!( + clicked.get(), + "the button on top must still receive an actual click" + ); +} + +/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report, +/// defect 2): a fast gesture's `PressEnd` can land at a screen position +/// nothing is registered at -- past the edge of whatever widget noticed +/// the press, in a gap, or off the loaded content entirely. Before pointer +/// capture, `run_sensors`' hit test simply delivered nothing that frame, +/// so a widget mid-drag never saw its release and never got a chance to +/// start a fling. `PointerRequests::capture`/`DragGesture` fix this +/// by giving the drag's widget every frame regardless of where the +/// pointer is, including the terminal `Drop` in place of `PressEnd`. +#[test] +fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // A small draggable widget in the corner -- the release below lands + // far outside it, exactly the "moved off the hit region" case. + let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); + let draggable_weak = draggable.weak(); + + let dropped = Rc::new(Cell::new(false)); + { + let dropped = dropped.clone(); + rsc.register_event( + draggable_weak, + CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop, + move |ctx, rsc| match ctx.data.sense { + CursorSense::PressStart(_) | CursorSense::Pressing(_) => { + // Any committed drag takes capture -- a real caller + // would gate this on a `DragArbiter`/`DragGesture` + // decision, but this test only needs to exercise the + // capture-and-release mechanics themselves. + ctx.data.pointer.capture(draggable_weak.id()); + let _ = rsc; + } + CursorSense::Drop => dropped.set(true), + _ => {} + }, + ); + } + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&draggable, &mut rsc); + + let mut state = (); + let mut press = cursor_at((5.0, 5.0).into()); + press.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, press, (100.0, 100.0).into()); + render.update(&draggable, &mut rsc); + assert_eq!( + pointer_input(&mut rsc).holder(), + Some(draggable.id()), + "the press should have taken capture" + ); + + // The release lands nowhere near the widget's own region -- the exact + // shape of a fast fling's `ACTION_UP`. + let mut release = cursor_at((95.0, 95.0).into()); + release.buttons.left = ActivationState::End; + render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into()); + render.update(&draggable, &mut rsc); + + assert!( + dropped.get(), + "a release outside every widget's hit region must still reach \ + the widget holding pointer capture" + ); + assert_eq!( + pointer_input(&mut rsc).holder(), + None, + "Drop must release the capture" + ); +} + +/// A widget that never registers `CursorSense::Drop` at all must not be +/// affected by someone else's capture -- capture is per-gesture, not +/// global suppression of the whole input system for widgets that were +/// never party to it. (Practically this matters because a captured +/// widget's registration list still has to include `Drop` for `should_run` +/// to ever match it; this pins that half of the contract.) +#[test] +fn capturing_one_widget_starves_every_other_widget_of_events() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + let a = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let a_weak = a.weak(); + let b = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)); + let b_weak = b.weak(); + + let b_hovered = Rc::new(Cell::new(false)); + { + let b_hovered = b_hovered.clone(); + rsc.register_event(b_weak, CursorSense::Hovering, move |_ctx, _rsc| { + b_hovered.set(true); + }); + } + + let root = rsc + .ui + .widgets + .add_strong(Stack { + children: vec![a.any(), b.any()], + size: StackSize::default(), + }) + .any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + pointer_input(&mut rsc).set_holder(Some(a_weak.id())); + + let mut state = (); + let cursor = cursor_at((50.0, 50.0).into()); + render.run_sensors(&mut rsc, &mut state, cursor, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + + assert!( + !b_hovered.get(), + "while a's drag holds capture, b must see no hover at all" + ); +} + +/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only +/// answered a wheel, so a finger drag over overflowed text did nothing. +/// End-to-end over the real wiring -- `scrollable()`'s own registration, +/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and +/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in +/// `scroll.rs`, because the registration is exactly the half those cannot +/// see. +#[test] +fn a_finger_drag_over_a_scroll_area_pans_it() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // 1000px of content in a 100px window: room to pan. + let scroll_strong = rect(UiColor::WHITE) + .height(Len::abs(1000.0)) + .scrollable(Axis::Y, Pin::Start) + .add_strong(&mut rsc); + let scroll = scroll_strong.weak(); + let root = scroll_strong.any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + // `ScrollArea` reads its content length back from the draw it just did, so + // the frame after is the first one that knows there is anything to pan + // -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is + // how `layout_tests.rs` asks for that second frame, and it also drops + // `snap_end`, leaving this parked at the start of the content. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0); + + let mut state = (); + let mut down = cursor_at((50.0, 80.0).into()); + down.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, down, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + assert_eq!( + rsc.ui.widgets.get(&scroll).unwrap().amt(), + 0.0, + "the touch-down alone must not move anything" + ); + + // Inside the slop: still a tap as far as anything can tell. + let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into()); + nudge.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + assert_eq!( + rsc.ui.widgets.get(&scroll).unwrap().amt(), + 0.0, + "a press inside DRAG_SLOP must not scroll" + ); + + // Past it, upward: the content follows the finger up, which for this + // widget means more `amt`. + let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into()); + drag.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into()); + render.update(&root, &mut rsc); + let after = rsc.ui.widgets.get(&scroll).unwrap().amt(); + assert!( + (after - 40.0).abs() < 0.01, + "expected the 40px past the slop to pan it, got {after}" + ); + + // And the gesture holds the pointer, so the rest of it reaches this + // widget even once the finger leaves its box. + assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id())); +} + +/// docs/REVIEW-2026-09-07.md's D4. The first `MotionEvent` a view sees can +/// be a `Move` -- the `Down` went to another view, or the view was attached +/// mid-gesture -- and its batched samples are older than its own +/// timestamp. Anchoring on that timestamp clamped every one of them onto +/// the anchor, so the tracker saw three samples at one instant, the Lsq2 +/// fit went degenerate, and the flick read 0 px/s. +#[test] +fn the_first_events_batched_samples_are_dated_apart() { + const MS: i64 = 1_000_000; + let now = Instant::now(); + // A 120Hz batch: three historical samples at 0/4/8ms and the event's + // own at 12ms. + let clock = PointerClock::anchored(now, 12 * MS, 0); + + assert_eq!( + clock.at(12 * MS), + now, + "the event's own sample is the one that arrived now" + ); + let batch = [clock.at(0), clock.at(4 * MS), clock.at(8 * MS)]; + assert!( + batch[0] < batch[1] && batch[1] < batch[2] && batch[2] < now, + "the batch must keep the 4ms between its samples, got {:?}", + batch + .iter() + .map(|t| now.duration_since(*t)) + .collect::>() + ); + assert_eq!(clock.ms_since_anchor(8 * MS), 8); +} + +/// The same clock has to keep ordering *across* events: the sample it +/// compares a new event's first sample against is the previous event's +/// last one, never the anchor. +#[test] +fn the_clock_orders_samples_across_events() { + const MS: i64 = 1_000_000; + let mut clock = PointerClock::anchored(Instant::now(), 12 * MS, 0); + let first = clock.sample(12 * MS); + let second = clock.sample(28 * MS); + assert!(second > first); + assert_eq!( + second.duration_since(first), + std::time::Duration::from_millis(16) + ); +} + +/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to +/// some position when horizontally scrolling." +/// +/// A `ScrollArea` that has committed to a pan holds the pointer, so the +/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable` +/// used to register `click_or_drag | unclick` only, which `should_run` +/// never matches a `Drop` against. So the widget never learned its own +/// gesture had ended: its `DragArbiter` stayed `Panning` at the position +/// the finger left, and the *next* drag's first frame was measured from +/// there and applied in one step. The registration is +/// `CursorSense::drag_senses()` now, which is the rule for every widget +/// driving a `DragGesture` rather than a fact about this one. +#[test] +fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + let scroll_strong = rect(UiColor::WHITE) + .height(Len::abs(1000.0)) + .scrollable(Axis::Y, Pin::Start) + .add_strong(&mut rsc); + let scroll = scroll_strong.weak(); + let root = scroll_strong.any(); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + + let mut state = (); + let win = Vec2::new(100.0, 100.0); + let mut send = |render: &mut UiRenderState, rsc: &mut SenseRsc, y: f32, button| { + let mut c = cursor_at((50.0, y).into()); + c.buttons.left = button; + render.run_sensors(rsc, &mut state, c, win); + render.update(&root, rsc); + }; + + // One pan of 40px past the slop, then a release well outside the + // widget -- the ordinary shape of a flick. + send(&mut render, &mut rsc, 80.0, ActivationState::Start); + send( + &mut render, + &mut rsc, + 80.0 - (DRAG_SLOP + 40.0), + ActivationState::On, + ); + let after_first = rsc.ui.widgets.get(&scroll).unwrap().amt(); + assert!((after_first - 40.0).abs() < 0.01, "amt={after_first}"); + send(&mut render, &mut rsc, 400.0, ActivationState::End); + assert_eq!( + pointer_input(&mut rsc).holder(), + None, + "the release must give the pointer back" + ); + + // A second gesture, starting where the first one did. If the arbiter + // were still panning from the release position, this first frame + // would apply the whole distance between the two at once. + send(&mut render, &mut rsc, 80.0, ActivationState::Start); + let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt(); + assert!( + (after_second - after_first).abs() < 0.01, + "a fresh touch-down moved the content by {} -- the previous \ + gesture was never closed", + after_second - after_first, + ); +} + +/// The second half of the same report: "tapping sometimes seems to make +/// the scrolling jump, particularly when tapping on things that have +/// events like horizontal scrolling." +/// +/// Two widgets see the same press -- a scroll area and, under it, +/// something tracking the gesture for a list. When the scroll area +/// captures, the other one is cut off completely: no `PressEnd`, no +/// `Drop`. It has to be told, or its gesture stays open at an origin +/// belonging to a finger that has long gone, and the next unrelated touch +/// is measured from it. +#[test] +fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // The bystander *contains* the capturer, which is the real shape: a + // transcript's `LazySpan` and one row's own text both track the same + // press, and a `Stack`'s siblings would be on separate layers where + // only the topmost is dispatched to at all. + let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let capturer_weak = capturer.weak(); + let bystander = rsc.ui.widgets.add_strong(Stack { + children: vec![capturer.any()], + size: StackSize::default(), + }); + let bystander_weak = bystander.weak(); + + let capturer_saw = Rc::new(Cell::new(0u32)); + { + let capturer_saw = capturer_saw.clone(); + rsc.register_event( + capturer_weak, + CursorSense::drag_senses(), + move |ctx, _rsc| { + capturer_saw.set(capturer_saw.get() + 1); + if matches!(ctx.data.sense, CursorSense::Pressing(_)) { + ctx.data.pointer.capture(capturer_weak.id()); + } + }, + ); + } + let cancelled = Rc::new(Cell::new(0u32)); + let ended = Rc::new(Cell::new(0u32)); + { + let (cancelled, ended) = (cancelled.clone(), ended.clone()); + rsc.register_event( + bystander_weak, + CursorSense::drag_senses(), + move |ctx, _rsc| match ctx.data.sense { + CursorSense::Cancel => cancelled.set(cancelled.get() + 1), + CursorSense::PressEnd(_) | CursorSense::Drop => ended.set(ended.get() + 1), + _ => {} + }, + ); + } + + let root = bystander.any(); + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let mut state = (); + let win = Vec2::new(100.0, 100.0); + let mut down = cursor_at((50.0, 50.0).into()); + down.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, down, win); + render.update(&root, &mut rsc); + assert_eq!(cancelled.get(), 0, "nothing has captured yet"); + + let mut moved = cursor_at((50.0, 20.0).into()); + moved.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, moved, win); + render.update(&root, &mut rsc); + assert!(capturer_saw.get() > 0, "the capturer never saw the press"); + assert_eq!( + pointer_input(&mut rsc).holder(), + Some(capturer_weak.id()), + "the capture should have been taken on this frame" + ); + assert_eq!( + cancelled.get(), + 1, + "the widget that lost the gesture must be told exactly once" + ); + + // And exactly once: the frames after the capture reach the capturer + // alone, so there is nothing left to cancel. + let mut more = cursor_at((50.0, 10.0).into()); + more.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, more, win); + render.update(&root, &mut rsc); + let mut up = cursor_at((50.0, 10.0).into()); + up.buttons.left = ActivationState::End; + render.run_sensors(&mut rsc, &mut state, up, win); + render.update(&root, &mut rsc); + assert_eq!(cancelled.get(), 1, "cancelled more than once"); + assert_eq!( + ended.get(), + 0, + "a cancelled widget must not also be told the gesture ended \ + normally -- acting on that is the tap it never made" + ); +} + +/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger +/// horizontal if you drag left or right, and vertical should fall through +/// if you drag up or down." +/// +/// One mechanism does both, and it is `DragArbiter`'s existing axis test: +/// each scroll area's gesture commits only on its own axis, so a drag +/// along the other one is never claimed and the enclosing area's gesture +/// -- which sees the same press, being an ancestor rather than a sibling +/// layer -- is the one that commits and captures. This pins the pair, +/// including the direction the change had no reason to touch. +#[test] +fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { + for (name, to, pans, still) in [ + ("vertical", Vec2::new(50.0, 80.0 - (DRAG_SLOP + 40.0)), 0, 1), + ( + "horizontal", + Vec2::new(50.0 - (DRAG_SLOP + 40.0), 80.0), + 1, + 0, + ), + ] { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + // 1000px square of content in a 100px window: room to pan either + // way, in an X area inside a Y one. + let seen = Rc::new(Cell::new(None)); + let record = seen.clone(); + let outer_strong = rect(UiColor::WHITE) + .width(Len::abs(1000.0)) + .height(Len::abs(1000.0)) + .scrollable(Axis::X, Pin::Start) + // The inner area's own handle, taken as the chain is built -- + // the whole point is to exercise `scrollable`'s real + // registration on both, so neither is assembled by hand. + .with_id(move |_rsc, id| { + record.set(Some(id)); + id + }) + .scrollable(Axis::Y, Pin::Start) + .add_strong(&mut rsc); + let inner = seen.get().unwrap(); + let outer = outer_strong.weak(); + let root = outer_strong.any(); + let areas = [outer, inner]; + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + // The second frame, where each area knows its content length -- + // LAYOUT.md section 4's one-frame lag, and what drops `snap_end`. + for a in areas { + rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0); + } + render.update(&root, &mut rsc); + + let mut state = (); + let win = Vec2::new(100.0, 100.0); + let mut down = cursor_at((50.0, 80.0).into()); + down.buttons.left = ActivationState::Start; + render.run_sensors(&mut rsc, &mut state, down, win); + render.update(&root, &mut rsc); + let mut drag = cursor_at(to); + drag.buttons.left = ActivationState::On; + render.run_sensors(&mut rsc, &mut state, drag, win); + render.update(&root, &mut rsc); + + let moved = rsc.ui.widgets.get(&areas[pans]).unwrap().amt(); + let unmoved = rsc.ui.widgets.get(&areas[still]).unwrap().amt(); + assert!( + (moved - 40.0).abs() < 0.01, + "a {name} drag should have panned the {name} area by the 40px \ + past the slop, got {moved}" + ); + assert_eq!( + unmoved, 0.0, + "a {name} drag must not move the area that owns the other axis" + ); + } +} + +/// Iris's 2026-09-08 report: "if I try to scroll vertically while a +/// horizontal scroll animation is still active, it stays locked to the +/// horizontal scroll", with her own diagnosis -- "tapping outside of +/// something that a fling is currently active for should have no code in +/// common with the fling that could influence it." +/// +/// She was right that it was global state, and this is where it lived. +/// `run_sensors` runs a widget one more frame *after* the pointer has +/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is +/// not `Off`) -- and `should_run` derived a press from the button alone, +/// so that farewell frame also carried a `PressStart`. A widget nowhere +/// near the finger therefore opened a gesture, and a `ScrollArea` catching +/// its own fling commits with no slop, so it captured the pointer and the +/// whole gesture went to it. +/// +/// Two areas side by side here rather than one, because "the press went +/// to the wrong widget" and "the press went nowhere" are different +/// failures and only the second area can tell them apart. +#[test] +fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { + let mut rsc = SenseRsc { + ui: UiData::default(), + events: EventManager::default(), + }; + + // Two 1000px-tall scroll areas, stacked: the top half of the window + // is the first, the bottom half the second. Each area's own handle is + // taken as its chain is built (`with_id`, the same way the nested-axes + // test above does it), since what is under test is `scrollable()`'s + // real registration rather than a `ScrollArea` assembled by hand. + let seen: [Rc>>>; 2] = Default::default(); + let half = |slot: &Rc>>>| { + let record = slot.clone(); + rect(UiColor::WHITE) + .height(Len::abs(1000.0)) + .scrollable(Axis::Y, Pin::Start) + .with_id(move |_rsc, id| { + record.set(Some(id)); + id + }) + .height(Len::rel(0.5)) + }; + let root = (half(&seen[0]), half(&seen[1])) + .span(Dir::DOWN) + .add_strong(&mut rsc) + .any(); + let (top_w, bottom_w) = (seen[0].get().unwrap(), seen[1].get().unwrap()); + + let win: Vec2 = (100.0, 200.0).into(); + let mut render = UiRenderState::new(); + render.resize((win.x, win.y)); + render.update(&root, &mut rsc); + // The second frame is the first that knows how long the content is -- + // see `a_finger_drag_over_a_scroll_area_pans_it`. + for w in [&top_w, &bottom_w] { + rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0); + } + render.update(&root, &mut rsc); + + let mut state = (); + // Flick the top area and let go: it is left flinging, and -- because + // the release goes through `run_sensors`' capture branch, which + // returns before the loop that would have updated anybody's hover -- + // its sensor is left `On` with the pointer no longer on it. Both + // halves of the real gesture, since both are what the bug needs. + let base = Instant::now(); + let mut t = 0; + let sample = |render: &mut UiRenderState, + rsc: &mut SenseRsc, + state: &mut (), + y: f32, + button: ActivationState, + at_ms: u64| { + let mut c = cursor_at((50.0, y).into()); + c.buttons.left = button; + c.time = base + std::time::Duration::from_millis(at_ms); + render.run_sensors(rsc, state, c, win); + render.update(&root, rsc); + }; + sample( + &mut render, + &mut rsc, + &mut state, + 50.0, + ActivationState::Start, + t, + ); + for y in [44.0, 32.0, 14.0] { + t += 8; + sample(&mut render, &mut rsc, &mut state, y, ActivationState::On, t); + } + t += 8; + sample( + &mut render, + &mut rsc, + &mut state, + 14.0, + ActivationState::End, + t, + ); + assert!( + rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(), + "the flick must leave the top area coasting -- the press below is \ + only dangerous while something is still moving", + ); + let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt(); + + // Now press and drag in the *bottom* area: the top area's hover + // decays to `End` on this very sample, which is the frame that used + // to carry a `PressStart` to it. + t += 8; + sample( + &mut render, + &mut rsc, + &mut state, + 150.0, + ActivationState::Start, + t, + ); + t += 8; + sample( + &mut render, + &mut rsc, + &mut state, + 150.0 - (DRAG_SLOP + 40.0), + ActivationState::On, + t, + ); + + let moved = rsc.ui.widgets.get(&bottom_w).unwrap().amt(); + assert!( + (moved - 40.0).abs() < 0.01, + "the area actually under the finger should have panned by the 40px \ + past the slop, got {moved}" + ); + assert_eq!( + rsc.ui.widgets.get(&top_w).unwrap().amt(), + flung_to, + "the area the pointer had left must not have seen the press at all -- \ + a catch would have stopped its fling on the touch-down" + ); + assert_eq!( + pointer_input(&mut rsc).holder(), + Some(bottom_w.id()), + "the gesture belongs to the widget under the finger", + ); +} diff --git a/src/default/state.rs b/src/state.rs similarity index 60% rename from src/default/state.rs rename to src/state.rs index 6a5b0b2..d9cff08 100644 --- a/src/default/state.rs +++ b/src/state.rs @@ -1,7 +1,8 @@ use iris_core::{ - WidgetId, + UiRsc, WidgetId, util::{HashMap, HashSet}, }; +use iris_core::{WeakWidget, Widget}; use std::{ any::{Any, TypeId}, marker::PhantomData, @@ -73,3 +74,39 @@ impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState { state.get_mut(self) } } + +/// What `Rsc[weak_handle]` indexes through -- one impl per kind of handle +/// (a widget, a piece of per-widget state), shared by both backends' `Rsc` +/// types since indexing a widget tree has nothing to do with windowing. +/// Each backend still needs its own `Index`/`IndexMut for ItsRsc` +/// (`default/mod.rs`, `android/view.rs`), because a blanket impl over every +/// `I: RscIdx` for every possible `Rsc` would conflict between crates. +pub trait RscIdx { + type Output; + fn get(self, rsc: &Rsc) -> &Self::Output; + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output; +} + +impl RscIdx for WeakWidget { + type Output = W; + + fn get(self, rsc: &Rsc) -> &Self::Output { + &rsc.ui().widgets[self] + } + + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { + &mut rsc.ui_mut().widgets[self] + } +} + +impl RscIdx for WeakState { + type Output = T; + + fn get(self, rsc: &Rsc) -> &Self::Output { + rsc.widget_state().get(self) + } + + fn get_mut(self, rsc: &mut Rsc) -> &mut Self::Output { + rsc.widget_state_mut().get_mut(self) + } +} diff --git a/src/default/task.rs b/src/task.rs similarity index 57% rename from src/default/task.rs rename to src/task.rs index 36b55c1..752a6f9 100644 --- a/src/default/task.rs +++ b/src/task.rs @@ -13,7 +13,17 @@ use tokio::{ unbounded_channel as async_channel, }, }; -use winit::window::Window; + +/// What a completed task nudges when it wants its result drawn. Shared +/// between backends rather than typed as `winit::window::Window` directly: +/// android-view has no `Window` at all, and the redraw request there is a +/// JNI call (`View::post_frame_callback`) rather than a method call on a +/// value this crate owns. Each backend supplies its own implementation -- +/// `default/render.rs` for winit, `android/render.rs` for android-view -- +/// and this module never needs to know which one it is holding. +pub trait RequestRedraw: Send + Sync + 'static { + fn request_redraw(&self); +} pub type TaskMsgSender = SyncSender>>; pub type TaskMsgReceiver = SyncReceiver>>; @@ -23,7 +33,7 @@ impl TaskUpdate pub struct Tasks { start: AsyncSender, - window: Arc, + redraw: Arc, msg_send: SyncSender>>, } @@ -45,7 +55,7 @@ impl TaskCtx { type BoxTask = Pin + Send>>; impl Tasks { - pub fn init(window: Arc) -> (Self, TaskMsgReceiver) { + pub fn init(redraw: Arc) -> (Self, TaskMsgReceiver) { let (start, start_recv) = async_channel(); let (msgs, msgs_recv) = sync_channel(); std::thread::spawn(|| { @@ -56,21 +66,33 @@ impl Tasks { Self { start, msg_send: msgs, - window, + redraw, }, msgs_recv, ) } + /// The same redraw handle `spawn`'s wrapper calls once, after a whole + /// task's future completes -- exposed so a caller running its own + /// longer-lived loop *inside* a spawned task (a live SSE follow, here) + /// can ask for a frame after each `TaskCtx::update`, not just at the + /// end. Without this a caller has no way to get a redraw mid-stream, + /// which is exactly the gap `iris/desktop-app`'s `app.rs` module doc + /// names for why it uses winit's `Proxy` instead of `Tasks` -- Android + /// has no `Proxy`, so this is what closes the same gap there. + pub fn redraw_handle(&self) -> Arc { + self.redraw.clone() + } + pub fn spawn) + 'static + std::marker::Send>(&mut self, task: F) where F::CallOnceFuture: Send, { let send = self.msg_send.clone(); - let window = self.window.clone(); + let redraw = self.redraw.clone(); let _ = self.start.send(Box::pin(async move { task(TaskCtx::new(send)).await; - window.request_redraw(); + redraw.request_redraw(); })); } } diff --git a/src/widget/image.rs b/src/widget/image.rs index 244bbb8..7f2c131 100644 --- a/src/widget/image.rs +++ b/src/widget/image.rs @@ -6,16 +6,19 @@ pub struct Image { } impl Widget for Image { - fn draw(&mut self, painter: &mut Painter) { - painter.texture(&self.handle); + fn draw(&mut self, painter: &mut Painter) -> Size { + // Drawn at its own natural size, anchored top-left of whatever it + // was offered, not stretched to fill it -- its primitive is + // independent of the offered region, matching `is_size_independent` + // below. A caller that wants it placed differently wraps it (e.g. + // `.center()`, `.align(...)`). + let size = self.handle.size(); + painter.texture_within(&self.handle, size.align(Align::TOP_LEFT)); + Size::abs(size) } - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::abs(self.handle.size().x) - } - - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::abs(self.handle.size().y) + fn is_size_independent(&self) -> bool { + true // a decoded image's primitive never depends on the region it is offered } } diff --git a/src/widget/mask.rs b/src/widget/mask.rs index cc075e9..1373599 100644 --- a/src/widget/mask.rs +++ b/src/widget/mask.rs @@ -1,20 +1,36 @@ use crate::prelude::*; +/// Clips `inner` -- and everything below it -- to a shape. +/// +/// The shape is a **primitive**, never a rectangle or a radius stored +/// here: with `shape`, the widget named there is drawn behind `inner` +/// filling the same box and the clip is its first primitive, so a rounded +/// container's corner and the corner its content is cut to are the same +/// arithmetic and cannot fall out of step. Without one, this writes an +/// undrawn rect at its own region, which is the plain "clip to my box" +/// every list and scroll area wants. See docs/LAYOUT.md's "Masks with a +/// shape". pub struct Masked { + /// The widget whose first primitive is the clip, drawn behind + /// `inner`, or `None` for this widget's own box. + pub shape: Option, pub inner: StrongWidget, } impl Widget for Masked { - fn draw(&mut self, painter: &mut Painter) { - painter.set_mask(painter.region()); - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + fn draw(&mut self, painter: &mut Painter) -> Size { + match &self.shape { + // Layered the way `Stack` layers a background under its + // content, and for the same reason: within one layer the draw + // order is undefined once anything has been freed. + Some(shape) => { + painter.child_layer(); + painter.widget(shape); + painter.set_mask_to_widget(shape); + painter.next_layer(); + } + None => painter.set_mask(painter.region()), + } + painter.widget(&self.inner) } } diff --git a/src/widget/position/align.rs b/src/widget/position/align.rs index 1a6d6a5..feb3c06 100644 --- a/src/widget/position/align.rs +++ b/src/widget/position/align.rs @@ -6,30 +6,31 @@ pub struct Aligned { } impl Widget for Aligned { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { + // Draw once at the whole region this widget was offered to learn + // the child's real size -- this placement is provisional and + // corrected below without a second draw. `painter.widget` (not + // `widget_within(..., painter.region())`) is what "my whole, + // already-resolved region, unmodified" means: `widget_within` + // composes its argument as a *local*, `UiRegion::FULL`-relative + // box against `painter.region()`, so handing it the + // already-resolved region double-applies that composition and is + // wrong for any widget nested below the root. + let used = painter.widget(&self.inner); + let density = painter.density(); let region = match self.align.tuple() { - (Some(x), Some(y)) => painter - .size(&self.inner) - .to_uivec2() - .align(RegionAlign { x, y }), + (Some(x), Some(y)) => used.to_uivec2(density).align(RegionAlign { x, y }), (Some(x), None) => { - let x = painter.size_ctx().width(&self.inner).apply_rest().align(x); + let x = used.x.apply_rest(density).align(x); UiRegion::new(x, UiSpan::FULL) } (None, Some(y)) => { - let y = painter.size_ctx().height(&self.inner).apply_rest().align(y); + let y = used.y.apply_rest(density).align(y); UiRegion::new(UiSpan::FULL, y) } (None, None) => UiRegion::FULL, }; - painter.widget_within(&self.inner, region); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.reposition(&self.inner, region); // O(1): one offset write, no second draw + used } } diff --git a/src/widget/position/layer.rs b/src/widget/position/layer.rs index fb2ced3..93f7616 100644 --- a/src/widget/position/layer.rs +++ b/src/widget/position/layer.rs @@ -6,18 +6,10 @@ pub struct LayerOffset { } impl Widget for LayerOffset { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { for _ in 0..self.offset { painter.next_layer(); } - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.widget(&self.inner) } } diff --git a/src/widget/position/lazy_span.rs b/src/widget/position/lazy_span.rs new file mode 100644 index 0000000..b0d0298 --- /dev/null +++ b/src/widget/position/lazy_span.rs @@ -0,0 +1,2481 @@ +//! `LazySpan`: a virtualised span of variable-height rows, laid out from +//! an anchor rather than eagerly like `Span`. +//! +//! **`docs/SCROLL.md` is the overview** -- how this widget and `ScrollArea` +//! divide the work, the one sign convention, the `Widget` handoff, and +//! what is still open. Read it first; this file is the detail. +//! RUST.md's I3. Read LAYOUT.md first -- this widget is built entirely out +//! of primitives that design already provides (`Painter::widget`/ +//! `widget_within`/`reposition`, and `draw_inner`'s own old-children +//! diffing) rather than adding a second move mechanism. +//! +//! ## Design +//! +//! **It knows nothing about masks.** What it does is *cull*: a row that +//! falls entirely outside the region this widget was offered is never +//! drawn (`intersects_viewport`). A row that *straddles* an edge is drawn +//! in full, because virtualisation decides which rows are drawn and never +//! how much of one -- so the overhang past this list's box reaches the +//! screen unless something clips it, and clipping is `.masked()`, which +//! the caller adds when it wants one. Iris, 2026-09-08: **"Why does the +//! mask matter at all. If you want a mask then you add `.masked()`. It +//! should just prevent rows that aren't in its region at all from drawing +//! ... Just like the opt in scrollable, masking should be opt in."** +//! +//! Two shapes this file went through before that, both worse. It asserted +//! `Painter::is_masked` and refused to draw otherwise, which made an +//! ordinary full-screen list -- every benchmark, every simple app -- +//! panic for want of ceremony it did not need; and the case that actually +//! bites passed the check anyway, since a mask *larger* than the list's +//! box satisfies `is_masked` while still letting the overhang through. +//! Then it set a mask of its own, which is this widget deciding something +//! that is not its to decide: a caller that wants the overhang (or that +//! is already clipped by something bigger) has no way to say so, and the +//! transcript ended up double-masked. +//! +//! The fault a caller is opting *out* of, when it leaves `.masked()` off, +//! is the transcript panned to its top edge drawing code through the +//! header bar above it, on Iris's phone (docs/IRIS_TODO.md, 2026-09-07). +//! +//! **Rows are keyed by a `u64` (`RowKey`), not a generic type.** Every real +//! row source in this codebase (a transcript's monotonic sequence number, a +//! chat message id) is already an integer; a generic key would cost every +//! call site a type parameter for a capability nothing here needs yet -- +//! the simplest thing that works, per the code rules. +//! +//! **Composed only while visible, for free.** `LazySpan` does not maintain its +//! own "which widgets are alive" bookkeeping. Its `draw` calls +//! `painter.widget`/`widget_within` only for the rows currently in view; +//! `UiRenderState::draw_inner` already diffs a redrawn widget's new +//! `children` against its old ones and frees (`remove_rec`) whatever is no +//! longer called (LAYOUT.md section on caching, and the "old_children" +//! removal in `draw_inner`). A row that scrolls out is therefore dropped +//! and its primitives freed the very next time `LazySpan` redraws -- no new +//! mechanism, just relying on the one LAYOUT.md already built. +//! +//! **Rows draw once and are moved, not re-laid-out, on scroll.** A `LazySpan` +//! is laid out outward from one **anchor** row (`Anchor { slot, edge, +//! offset }`: a slot index, which of its edges is pinned, and that edge's +//! pixel offset from the viewport's leading edge) rather than from a +//! single scroll amount measured from the top of all content -- there is +//! no "top of all content" to measure without walking every row, which is +//! exactly the O(N) cost virtualisation exists to avoid. Rows below the +//! anchor are placed **top-known** (`Placement::Leading`): offered an exact +//! top and a generous, oversized bottom, drawn once with +//! `painter.widget_within`, and their real height read back from the +//! returned `Size`. Rows above the anchor are placed **bottom-known** +//! (`Placement::Trailing`): since every widget in this crate paints itself +//! anchored top-left of whatever it is offered (LAYOUT.md's deviation 2), +//! placing a row so its *bottom* lands at an exact pixel needs the same +//! "learn the size, then move" trick `Aligned` already uses -- +//! `painter.widget` at the full offered region to measure, then +//! `painter.reposition` (an O(1) offset write, no second draw) to the +//! exact box. On an ordinary scroll tick only `Anchor::offset` changes; +//! every already-visible row keeps the same *size* it was offered last +//! frame (top-known rows: same generous bottom bound; bottom-known rows: +//! the same full-region measurement, which `draw_inner`'s own +//! `active.region == region` check turns into a **no draw at all**, its +//! cached `Size` returned for free) so the per-row cost of a tick is one +//! `mov()`/`reposition()` write, never a redraw -- verified in this file's +//! `moves_stay_o1_across_list_size` test and in `benches/message_lazy_span.rs`. +//! +//! **The scroll anchor survives a row inserted above it.** The anchor +//! names a row by its *slot index*, not by an absolute content offset +//! measured from the top -- so `push_front` only has to shift the +//! anchor's slot by one (`+= 1`, an O(1) write) to keep it pointing at the +//! same logical row; nothing about where that row is drawn changes, and +//! rows outside the loaded window are never touched. This is the same +//! reason `push_back`/`pop_front`/`pop_back` are all O(1): the widget +//! never computes "total content height," only the local heights of the +//! rows it is actively placing. +//! +//! **"More" sentinels are two ordinary optional widgets, not a second +//! data model.** `more_before`/`more_after` are each `Option` +//! set by the caller (`WidgetPtr`'s own idiom); when present, the walk +//! outward from the anchor treats the sentinel as one more slot past the +//! real rows (`BEFORE_SLOT`/`AFTER_SLOT`, reserved `isize` values below +//! `0`/above any real index) rather than special-casing it, so a sentinel +//! costs nothing extra to place or to virtualise away. +//! +//! **"Hold the edge nearest the tap," done in the layout pass.** +//! `note_tap(viewport_pos)` records where the user last touched the list, +//! in viewport-relative pixels, without forcing a redraw by itself -- the +//! app is expected to call it and then mutate whatever row is expanding +//! (e.g. toggling a collapsed message), which is what actually marks that +//! row (and, by the existing resize-bubble in `UiRenderState::redraw`, +//! `LazySpan` itself) dirty. The *next* time `LazySpan::draw` runs, before placing +//! anything, it looks at `extents` (each visible row's on-screen box as of +//! the *previous* frame, cached while walking) to find which row contains +//! the tap, decides whether the tap was nearer that row's top or bottom +//! edge, and re-anchors to exactly that row/edge/pixel -- so the row this +//! frame draws at its *new* height with the chosen edge pinned to the same +//! screen position it already occupied, and only the far side visibly +//! grows or shrinks. This is a layout decision made before any primitive +//! is written for the frame, not a correction applied to an already-drawn +//! wrong frame. +//! +//! **A row's height is cached by key once measured**, and reused directly +//! (one `widget_within` at the exact box, no re-measurement) on every later +//! placement of that row -- not merely an optimisation: see `place`'s doc +//! for why a row that fills whatever it is offered (a `.background(rect +//! (...))`) needs this to ever be placed at the right size at all, and why +//! reusing `draw_twice` every frame instead would defeat `draw_inner`'s own +//! skip-or-move caching. Only a row's first-ever appearance pays the +//! two-draw measurement; nothing here estimates a height for an off-screen +//! row that has never been measured, so this stays independent of how many +//! rows exist outside the loaded window. +//! +//! **Only what overlaps the viewport is drawn, and it is drawn whole.** +//! One rule, `intersects_viewport`, used by both halves of that sentence: +//! a row straddling either edge is drawn in full and clipped by the +//! `.masked()` its caller must place it in (`LazySpan::draw` asserts that), +//! and a row that has left the viewport is not drawn at all. The walk +//! still traverses whatever lies between the anchor and the viewport, and +//! `rehome_anchor` moves the anchor back onto a visible row every frame so +//! that "whatever lies between" stays empty however far the list is +//! panned. +//! +//! **This widget scrolls itself, and everything that is not its layout +//! lives in a [`ScrollController`] it owns** -- the position, the gesture, +//! the fling and the pin, the same struct a `ScrollArea` holds +//! (`docs/SCROLL.md`). It is not wrapped in one of those and must not be: +//! a scroll tick offers a moved region of the same size, `draw_inner` +//! takes the `mov` path, and a virtualising child inside it would never +//! update which rows it shows. `.scrollable()` here is the span's own +//! inherent one, registering the wheel and the drag against that +//! controller. +//! +//! What is left in this file is the layout: an anchor, a walk outward +//! from it, and an honest answer about how far it can go +//! ([`Self::travel`]). +//! +//! **Overscroll cannot be entered by scrolling, and is taken back within +//! the frame when something else causes it.** The controller clamps a +//! delta to the travel the last walk reported, so a delta that runs off a +//! wall already in view is simply cut short. What that cannot cover is a +//! wall this span has not walked to yet -- with rows loaded past an edge +//! there is no bound to report -- or the content or viewport changing +//! under a settled anchor, and for both +//! `overscroll_gap` measures the gap from the ends the walk already +//! placed and `draw` moves the anchor by it and walks a second time +//! **before the frame ends** -- layout is a pure function of the state, +//! not of how many frames have been drawn (Iris, 2026-09-08), the same +//! rule `ScrollArea::draw` follows. A span shorter than its viewport is not +//! overscrolled and is left alone, still pinned to the end it was built +//! with. + +use crate::prelude::*; +use iris_core::util::HashMap; +use std::collections::VecDeque; +use std::time::Instant; + +/// A stable identifier for a loaded row, reused across pages so that a row +/// already measured and drawn is not treated as new when data is inserted +/// elsewhere. See the module doc for why this is a plain integer. +pub type RowKey = u64; + +/// One loaded row: a stable key plus its content widget, built by the +/// caller (with access to the real `Rsc`) before it is handed to `LazySpan` -- +/// `LazySpan` itself only ever sees `&dyn Widget` through `Painter`, per +/// LAYOUT.md's single-draw model, so it cannot build rows lazily on its +/// own. +pub struct LazyItem { + pub key: RowKey, + pub widget: StrongWidget, +} + +impl LazyItem { + pub fn new(key: RowKey, widget: StrongWidget) -> Self { + Self { key, widget } + } +} + +/// Which of a row's two edges is pinned in place, named along `dir` +/// rather than by screen position: `Leading` is the top of a `Dir::DOWN` +/// span and the bottom of a `Dir::UP` one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edge { + Leading, + Trailing, +} + +/// A slot index one below the lowest real item index, reserved for the +/// "more before" sentinel. `isize::MIN` rather than `-1` so it can never +/// collide with a real index no matter how `items` grows, and so it needs +/// no adjustment when rows are inserted or removed at the front (only real +/// indices shift; the sentinels are fixed constants). +const BEFORE_SLOT: isize = isize::MIN; +/// The mirror of `BEFORE_SLOT` for "more after" -- fixed regardless of how +/// many real items exist, so appending or removing at the back never has +/// to touch it either. +const AFTER_SLOT: isize = isize::MAX; + +/// Where the span is anchored: `slot`'s `edge` renders at `offset` pixels +/// from the viewport's leading edge (its top, for a `Dir::DOWN` span), and +/// everything else is placed outward from that one fixed point. See the +/// module doc's "Rows draw once and are moved" section. +#[derive(Debug, Clone, Copy)] +struct Anchor { + slot: isize, + edge: Edge, + offset: f32, +} + +/// A visible row's on-screen box as of the last successful layout, cached +/// only so `note_tap`'s effect can be resolved next frame without a scan +/// over anything outside the viewport. Cleared on any structural change +/// (`push_front`/`push_back`/`pop_front`/`pop_back`/`set_more_*`) rather +/// than kept in step with slot-index shifts, since a tap landing in the +/// same tick as a page insert is rare enough that "the tap is silently +/// dropped" is an acceptable answer and it avoids a second piece of index +/// bookkeeping to keep correct. +#[derive(Debug, Clone, Copy)] +struct RowExtent { + slot: isize, + /// Both in **direction-relative** pixels from this widget's leading + /// edge (`abs_region`'s space), not screen pixels -- the walk works in + /// one space and only the public helpers below convert + /// ([`LazySpan::to_screen`]). + lead: f32, + trail: f32, +} + +/// Which of a row's edges the walk already knows, and where it is. The +/// two are symmetric -- one pinned edge plus a height gives the box -- +/// so `place` parameterises over them through [`Self::edges`] rather +/// than carrying a copy of the same logic per direction. +#[derive(Clone, Copy)] +enum Placement { + /// This row's leading edge is known; its trailing edge is wherever its + /// own height puts it. + Leading(f32), + /// This row's trailing edge is known; its leading edge is that height + /// back from it. + Trailing(f32), +} + +impl Placement { + /// The `(leading, trailing)` edges this placement implies for a row + /// of `height`. + fn edges(self, height: f32) -> (f32, f32) { + match self { + Placement::Leading(lead) => (lead, lead + height), + Placement::Trailing(trail) => (trail - height, trail), + } + } +} + +/// A virtualised span of variable-height rows, laid out lazily from an +/// anchor. See the module doc for the design. +pub struct LazySpan { + /// Which end of this widget's box item 0 sits at, and which way the + /// sequence grows -- the same meaning `Span::dir` has, so the word is + /// one concept across both. Deliberately *not* the same question as + /// which end the view is pinned to (`snap_end`): a transcript's oldest + /// message is item 0 and sits at the **top** (`Dir::DOWN`) while the + /// view sits at the **bottom**, so conflating the two would stand it + /// on its head. + dir: Dir, + items: VecDeque, + more_before: Option, + more_after: Option, + anchor: Option, + /// The position, the gesture, the fling and the pin -- everything + /// about scrolling that is not this widget's own layout, in the same + /// struct a `ScrollArea` holds rather than a protocol between the two + /// (`docs/SCROLL.md`). This widget's `draw` is one instance of the + /// contract in [`ScrollController`]'s module doc: take the delta, + /// lay out, report what it did and how far it can still go. + ctl: ScrollController, + viewport_len: f32, + /// `viewport_len` as of the *previous* draw -- what `repair_anchor` + /// compares against to tell "the container was actually resized" from + /// "an ordinary frame where `snap_end` merely hasn't been recomputed + /// since a `scroll()` call yet." Without this distinction, + /// `repair_anchor` would re-snap a deliberate scroll away from the + /// bottom back to flush on the very next frame, since `snap_end` is + /// only recomputed at the *end* of a layout pass and so still reads + /// `true` (from before the scroll) the next time `repair_anchor` runs. + last_viewport_len: f32, + pending_tap: Option, + extents: HashMap, + /// Each row's height as of its last real draw, kept across frames so + /// an already-measured row is placed directly at its exact box next + /// time (one `widget_within`, no oversized measurement pass) -- + /// see `place`'s doc for why a fresh measurement can't be skipped + /// merely by translating an already-drawn primitive. Pruned when a + /// row is evicted (`pop_front`/`pop_back`) so this cannot grow past + /// however many rows are currently loaded. + heights: HashMap, + /// Whether the last walk found no more content before the leading + /// edge *and* nothing left to give back there -- what + /// [`Self::overscroll_gap`] reads. `false` by default, matching + /// "assume there is more content until a walk proves otherwise." + at_start: bool, + /// The mirror of `at_start` for the trailing end. + at_end: bool, + /// The extreme edges the last walk reached, in the walk's own + /// direction-relative pixels, kept so [`Self::travel`] can say + /// **exactly** how much of a delta this span is able to take rather + /// than only whether it is against a wall: with no more content past + /// an edge, the travel left in that direction is the distance from + /// that edge to the viewport's. An estimate here would leave `amt` + /// drifting from what is on screen by every overshoot into a wall. + content_lead: f32, + content_trail: f32, + /// Whether the last walk ran out of items before its leading / + /// trailing edge -- the structural half of `at_start`/`at_end`, and + /// what says whether `content_lead`/`content_trail` bound a scroll at + /// all. With more content past an edge there is no bound to give. + no_more_before: bool, + no_more_after: bool, +} + +impl LazySpan { + /// `pin` says which end of its content this span opens at and clings + /// to as rows arrive -- [`Pin::End`] for a transcript, and independent + /// of `dir`, which says where item 0 is (see the field). `dir` is also + /// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of + /// asking the same question. + pub fn new(dir: Dir, pin: Pin) -> Self { + Self { + dir, + ctl: ScrollController::new(dir, pin), + items: VecDeque::new(), + more_before: None, + more_after: None, + anchor: None, + viewport_len: 0.0, + last_viewport_len: 0.0, + at_start: false, + at_end: false, + content_lead: 0.0, + content_trail: 0.0, + no_more_before: false, + no_more_after: false, + pending_tap: None, + extents: HashMap::default(), + heights: HashMap::default(), + } + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Insert-above: O(1). The anchor is keyed by slot index, not by an + /// absolute content offset, so the only bookkeeping a prepended row + /// needs is shifting that one index -- nothing about an already-placed + /// row's position is touched. See the module doc. + pub fn push_front(&mut self, row: LazyItem) { + self.items.push_front(row); + if let Some(a) = &mut self.anchor + && a.slot != AFTER_SLOT + && a.slot >= 0 + { + a.slot += 1; + } + self.extents.clear(); + } + + /// O(1). If the list is currently flush with its own end + /// ([`ScrollController::pinned_to_end`]), the new row becomes the + /// anchor so a live list stays pinned to its newest content. + pub fn push_back(&mut self, row: LazyItem) { + self.items.push_back(row); + if self.ctl.pinned_to_end() { + self.anchor = Some(Anchor { + slot: self.items.len() as isize - 1, + edge: Edge::Trailing, + offset: self.viewport_len, + }); + } + self.extents.clear(); + } + + /// O(1). If the anchor was pinned to the row being removed, it is + /// invalidated here and repaired (falling back to the bottom-most + /// remaining row) on the next `draw`, since there is no more specific + /// answer than "wherever this widget's default now is." + pub fn pop_front(&mut self) -> Option { + let popped = self.items.pop_front(); + if let Some(row) = &popped { + if let Some(a) = &mut self.anchor { + if a.slot == 0 { + self.anchor = None; + } else if a.slot > 0 { + a.slot -= 1; + } + } + self.heights.remove(&row.key); + self.extents.clear(); + } + popped + } + + /// O(1); see `pop_front`. + pub fn pop_back(&mut self) -> Option { + let old_len = self.items.len() as isize; + let popped = self.items.pop_back(); + if let Some(row) = &popped { + if let Some(a) = &mut self.anchor + && a.slot == old_len - 1 + { + self.anchor = None; + } + self.heights.remove(&row.key); + self.extents.clear(); + } + popped + } + + pub fn set_more_before(&mut self, widget: Option) { + self.more_before = widget; + self.extents.clear(); + } + + pub fn set_more_after(&mut self, widget: Option) { + self.more_after = widget; + self.extents.clear(); + } + + /// Swap the last row's widget for a new one **without moving it**: the + /// slot index is unchanged, so an anchor already pointing at this slot + /// (in particular `snap_end`'s pinned-to-newest case) stays pinned, and + /// an anchor pointing anywhere else -- this row scrolled out of view -- + /// is untouched, so nothing currently on screen moves. This is what a + /// streamed reply needs: the row whose *content* keeps changing after + /// it first appears is still the same row by position, even if its + /// `RowKey` happens to change too (rare -- only `heights`/`extents` care + /// about the key, and both are invalidated here the same way + /// `pop_back` already invalidates them for the row it removes). + /// `None` if the list is empty. O(1), same as `push_back`/`pop_back`. + pub fn replace_back(&mut self, row: LazyItem) -> Option { + let idx = self.items.len().checked_sub(1)?; + let old = std::mem::replace(&mut self.items[idx], row); + self.heights.remove(&old.key); + self.extents.clear(); + Some(old) + } + + /// Drop every loaded row and reset to the same state `LazySpan::new` would + /// give -- the fallback path for a change `apply`-style incremental + /// callers can't express as a replace-or-append (RUST.md: `group_tool_runs` + /// regrouping an earlier row). `more_before`/`more_after` are left + /// alone: a full paging reset is a different operation from "the + /// content changed," and a caller that wants both calls + /// `set_more_before(None)`/`set_more_after(None)` itself. + pub fn clear(&mut self) { + self.items.clear(); + self.anchor = None; + self.ctl.set_pinned_to_end(true); + self.heights.clear(); + self.extents.clear(); + } + + /// Move the anchor's edge by `amt` pixels, where positive brings + /// **later** content into view. + /// + /// Named apart from [`Scrollable::scroll`] rather than shadowing it: + /// the two run in different spaces and an inherent method silently + /// wins over a trait one, so a caller reaching for the public + /// convention would have got this instead. + /// + /// Private, and in the direction-relative space the walk works in + /// rather than the screen space every public delta speaks in: the + /// anchor's offset says where the pinned edge *sits*, so moving the + /// content forward moves that number down, and for a `Sign::Neg` + /// `dir` "forward" is up the screen rather than down it. + /// [`Self::flip_delta`] is the one conversion, exactly as + /// [`Self::flip_pos`] is for positions. + /// + /// Unclamped here, on purpose: it is one write. The controller does + /// the clamping, against the walls [`Self::travel`] published from the + /// last walk, and `overscroll_gap` gives back whatever that could not + /// know about. + fn move_anchor(&mut self, amt: f32) { + if self.anchor.is_none() { + return; + } + self.anchor.as_mut().unwrap().offset -= amt; + // Converted into the screen-space convention the controller and + // every caller outside this widget speak in. Every move this span + // makes goes through here, including the ones `overscroll_gap` + // gives back, so `amt` is what actually happened rather than what + // was asked for -- see `ScrollController::moved_by`. Jumps + // (`jump_to_end`/`jump_to_start`) deliberately do not: they are + // not travel across the content. + let moved = self.flip_delta(amt); + self.ctl.moved_by(moved); + } + + /// The anchor's own row index and pixel offset, formatted the same + /// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset` + /// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling + /// phase reads before/after/between its fling runs so the two apps' + /// travel can be compared directly. `more_before`/`more_after` + /// sentinels print as `idx=more-before`/`idx=more-after` rather than + /// leaking their internal `isize` representation; `idx=none` if the + /// list has never drawn (no anchor yet -- e.g. right after + /// `jump_to_end` and before the next frame runs `repair_anchor`). + pub fn anchor_position_display(&self) -> String { + match self.anchor { + None => "idx=none".to_string(), + Some(a) if a.slot == BEFORE_SLOT => "idx=more-before".to_string(), + Some(a) if a.slot == AFTER_SLOT => "idx=more-after".to_string(), + Some(a) => format!("idx={}/off={}px", a.slot, a.offset.round() as i64), + } + } + + /// Snap to the newest content (last item, or the `more_after` + /// sentinel if set), aligned to the trailing edge of the viewport -- + /// its bottom for a `Dir::DOWN` span. O(1). + pub fn jump_to_end(&mut self) { + self.anchor = None; + self.pending_tap = None; + } + + /// Snap to the oldest loaded content (first item, or `more_before` if + /// set), aligned to the leading edge of the viewport -- its top for a + /// `Dir::DOWN` span. O(1). + pub fn jump_to_start(&mut self) { + let slot = if self.more_before.is_some() { + BEFORE_SLOT + } else if !self.items.is_empty() { + 0 + } else { + return; + }; + self.anchor = Some(Anchor { + slot, + edge: Edge::Leading, + offset: 0.0, + }); + self.pending_tap = None; + } + + /// Convert between the screen-space pixel offsets every caller of this + /// widget speaks in -- a pointer position, a row's box, both measured + /// from this widget's **top** (or left) -- and the direction-relative + /// space the walk works in, which for a `Sign::Neg` `dir` runs the + /// other way. Its own inverse, so one function covers both directions. + /// + /// The whole of the conversion lives at this boundary rather than in + /// the layout: `abs_region` is the only other place that knows which + /// way round the box is, and keeping the walk in one space is what + /// lets the anchor, the placement and the clamp be written once. + fn flip_pos(&self, pos: f32) -> f32 { + match self.dir.sign { + Sign::Pos => pos, + Sign::Neg => self.viewport_len - pos, + } + } + + /// [`Self::flip_pos`] for a *delta*: convert between the screen-space + /// scroll deltas every caller speaks in -- positive scrolls the + /// reader up or left, whatever this span's `dir` is -- and the + /// direction-relative amount [`Self::scroll`] takes, where positive + /// always brings later content into view. Its own inverse, so one + /// function covers both directions and both ways round. + /// + /// **The sign is a screen direction, not a logical one** (Iris, + /// 2026-09-08: "positive should always scroll up / left, and negative + /// down / right ... that way it always works as the user would + /// expect"). Without this a `Dir::UP` span pans backwards against + /// every other scrollable in iris for the same delta, because its + /// later content is *above* rather than below -- and a test written + /// in the walk's own space cannot see it, since both halves agree + /// with each other while the screen disagrees with both. + /// + /// A position needs `viewport_len` to flip about and a delta does + /// not, which is why they are two functions rather than one. + fn flip_delta(&self, amt: f32) -> f32 { + match self.dir.sign { + Sign::Pos => -amt, + Sign::Neg => amt, + } + } + + /// Record where (in pixels from this widget's top edge, the space a + /// pointer event arrives in) the user last touched it, for the *next* + /// layout pass in which some row's height changes to resolve against + /// -- see the module doc's "hold the edge nearest the tap" section. + /// Does not by itself mark anything dirty; the row whose height is + /// about to change is what triggers the redraw this is read during. + pub fn note_tap(&mut self, viewport_pos: f32) { + self.pending_tap = Some(self.flip_pos(viewport_pos)); + } + + /// The on-screen `(top, bottom)` viewport-pixel extent of `key`'s row + /// as of the last layout, or `None` if it was not among the rows drawn + /// then (off-screen, not yet loaded, or it hasn't drawn since). What a + /// caller reads to decide where to aim `note_tap` -- e.g. "the top of + /// the row that's about to expand" -- without duplicating this + /// widget's own layout math. Ordered top-then-bottom on screen + /// whichever way `dir` runs, since that is what a caller comparing it + /// against a pointer position needs. + pub fn extent(&self, key: RowKey) -> Option<(f32, f32)> { + self.extents.get(&key).map(|e| { + let (a, b) = (self.flip_pos(e.lead), self.flip_pos(e.trail)); + (a.min(b), a.max(b)) + }) + } + + /// The row whose on-screen box (as of the last layout) contains + /// `viewport_pos`, or `None` if it falls outside every row currently + /// drawn (a gap, a header, or off the loaded content entirely). O + /// (visible rows), same as `reanchor_at_tap`. What a caller resolves a + /// pointer-captured gesture's row-under-the-finger against once the + /// gesture is no longer being delivered through any one row's own hit + /// region -- see `iris::sense`'s pointer-capture doc. + pub fn key_at(&self, viewport_pos: f32) -> Option { + let pos = self.flip_pos(viewport_pos); + self.extents + .iter() + .find(|(_, ext)| pos >= ext.lead && pos <= ext.trail) + .map(|(&key, _)| key) + } + + fn slot_exists(&self, slot: isize) -> bool { + match slot { + BEFORE_SLOT => self.more_before.is_some(), + AFTER_SLOT => self.more_after.is_some(), + s => s >= 0 && s < self.items.len() as isize, + } + } + + fn slot_widget(&self, slot: isize) -> &StrongWidget { + match slot { + BEFORE_SLOT => self + .more_before + .as_ref() + .expect("BEFORE_SLOT placed with no more_before widget set"), + AFTER_SLOT => self + .more_after + .as_ref() + .expect("AFTER_SLOT placed with no more_after widget set"), + s => &self.items[s as usize].widget, + } + } + + /// The real row key at `slot`, or `None` for a sentinel -- sentinels + /// have no key of their own to cache an extent under, so they are + /// simply not addressable by `note_tap`'s hit test (nothing to expand + /// there). + fn slot_key(&self, slot: isize) -> Option { + match slot { + BEFORE_SLOT | AFTER_SLOT => None, + s if s >= 0 && (s as usize) < self.items.len() => Some(self.items[s as usize].key), + _ => None, + } + } + + fn prev_slot(&self, slot: isize) -> Option { + let len = self.items.len() as isize; + match slot { + BEFORE_SLOT => None, + AFTER_SLOT => { + if len > 0 { + Some(len - 1) + } else if self.more_before.is_some() { + Some(BEFORE_SLOT) + } else { + None + } + } + 0 => { + if self.more_before.is_some() { + Some(BEFORE_SLOT) + } else { + None + } + } + s => Some(s - 1), + } + } + + fn next_slot(&self, slot: isize) -> Option { + let len = self.items.len() as isize; + match slot { + AFTER_SLOT => None, + BEFORE_SLOT => { + if len > 0 { + Some(0) + } else if self.more_after.is_some() { + Some(AFTER_SLOT) + } else { + None + } + } + s if s == len - 1 => { + if self.more_after.is_some() { + Some(AFTER_SLOT) + } else { + None + } + } + s => Some(s + 1), + } + } + + /// Repair the anchor if the row it names no longer exists (evicted by + /// a `pop_*`, or a sentinel that was cleared), and re-home a + /// still-flush-with-the-end anchor's offset when the viewport itself + /// resized. Falls back to bottom-anchored-at-the-newest-content, + /// matching this widget's default when nothing else is known. + fn repair_anchor(&mut self) { + if self.items.is_empty() && self.more_before.is_none() && self.more_after.is_none() { + self.anchor = None; + return; + } + if let Some(a) = self.anchor + && self.slot_exists(a.slot) + { + if self.ctl.pinned_to_end() && self.viewport_len != self.last_viewport_len { + self.anchor.as_mut().unwrap().offset = self.viewport_len; + } + self.last_viewport_len = self.viewport_len; + return; + } + let len = self.items.len() as isize; + self.anchor = Some(if len > 0 { + Anchor { + slot: len - 1, + edge: Edge::Trailing, + offset: self.viewport_len, + } + } else if self.more_after.is_some() { + Anchor { + slot: AFTER_SLOT, + edge: Edge::Trailing, + offset: self.viewport_len, + } + } else { + Anchor { + slot: BEFORE_SLOT, + edge: Edge::Leading, + offset: 0.0, + } + }); + } + + /// Resolve a pending tap against last frame's row extents and, if it + /// landed inside one, re-anchor to that row's nearer edge at its + /// current on-screen position -- O(visible rows), never a scan of + /// anything off-screen. See the module doc. `tap` is already in the + /// walk's direction-relative space (`note_tap` converted it), so this + /// compares like with like whichever way `dir` runs. + fn reanchor_at_tap(&mut self, tap: f32) { + for ext in self.extents.values() { + if tap >= ext.lead && tap <= ext.trail { + let mid = (ext.lead + ext.trail) * 0.5; + let (edge, offset) = if tap < mid { + (Edge::Leading, ext.lead) + } else { + (Edge::Trailing, ext.trail) + }; + self.anchor = Some(Anchor { + slot: ext.slot, + edge, + offset, + }); + return; + } + } + } + + /// Move the anchor onto a row that is actually on screen, without + /// moving anything that is drawn: the row it re-homes to keeps the + /// exact top edge this frame's layout gave it. + /// + /// [`Self::scroll`] moves the anchor's *offset* and nothing else, so + /// panning away from the anchor's own row leaves that row further and + /// further outside the viewport, and every row between it and the + /// viewport has to be walked on every frame from then on -- before + /// `place`'s intersection test, drawn too. Measured on the bench + /// fixture before this: 8 scrolls of 3000px left **64 rows** placed in + /// a 2012px viewport, ~59 of them off-screen, and the ones above it + /// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07). + /// Re-homing each frame makes the walk O(visible) again whatever + /// distance was travelled, which is what the module doc claims. + /// + /// Only when the anchor's own row has left the viewport, so + /// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom + /// edge at the viewport's own bottom, which intersects it -- is left + /// exactly as it is rather than rewritten into a top-edge anchor that + /// no longer reads as flush with the end. + fn rehome_anchor(&mut self) { + let Some(anchor) = self.anchor else { + return; + }; + if self.extents.values().any(|e| e.slot == anchor.slot) { + return; + } + // The topmost row on screen, so the anchor's offset stays a small + // number near the viewport's own leading edge rather than + // whatever the last row's bottom happens to be. + let Some(first) = self + .extents + .values() + .min_by(|a, b| a.lead.total_cmp(&b.lead)) + .copied() + else { + // Nothing on screen at all -- a list scrolled past its own + // content (`scroll` is deliberately unclamped). There is no + // on-screen row to re-home to, and inventing one would move + // the list; leave the anchor where it is and let the next + // scroll or `repair_anchor` bring content back. + return; + }; + self.anchor = Some(Anchor { + slot: first.slot, + edge: Edge::Leading, + offset: first.lead, + }); + } + + /// The empty band at one edge that content on the other side of the + /// viewport could fill -- positive to move content toward the leading + /// edge -- or `None` when the layout already sits on its content. + /// This is what makes a `scroll` or a fling past the end of the + /// content settle *on* the end rather than beyond it. + /// + /// `top`/`bottom` are the extreme edges the walk actually placed, so + /// the gap is already measured: `at_start` means nothing is above + /// `top`, and if `top` is nevertheless below the viewport's own + /// leading edge then those pixels are empty and always will be. This + /// is the whole of what the module doc used to list as deliberately + /// unsolved ("no overscroll clamping ... nothing to measure how much + /// content is left without walking it") -- true of *total* content + /// height, but the walk hands back both ends of the loaded run for + /// free, which is all a clamp needs. `tick_fling` stops a fling that + /// has reached an end, but stops it wherever the spline's last step + /// had already put it: a hard fling to the top of the bench fixture + /// left the first row **1398px below** a 600px viewport, i.e. the + /// whole screen blank, and it stayed there (docs/IRIS_TODO.md, + /// 2026-09-07: "black from the header down"). + /// + /// **Only when the opposite end is not also inside the viewport.** + /// Both at once means the content is shorter than the viewport, where + /// the space is not overscroll at all -- it is a bottom-anchored list + /// with three rows in it, and pulling those to the top would be this + /// widget rejecting its own default (`repair_anchor`). + fn overscroll_gap(&self, lead: f32, trail: f32) -> Option { + if self.at_start == self.at_end { + return None; + } + // `at_start`/`at_end` already carry the sign of their own gap + // (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap + // itself, positive to move content toward the leading edge. + let gap = if self.at_start { + lead + } else { + trail - self.viewport_len + }; + // Sub-pixel gaps are what floating-point row heights leave behind + // every frame; laying out again for one would leave another, and + // the list would never settle. + (gap.abs() >= 0.5).then_some(gap) + } + + /// Place every row that reaches the viewport, outward from the + /// anchor, and return the extreme `(leading, trailing)` edges the walk + /// reached. Rebuilds `extents` and `at_start`/`at_end` from what it + /// placed; the caller clears `extents` first, since `reanchor_at_tap` + /// reads the previous frame's copy. + /// + /// Called a second time in the same `draw` when the first pass lands + /// off the end of the content -- see [`Self::overscroll_gap`] and + /// `draw`. + fn lay_out(&mut self, painter: &mut Painter) -> (f32, f32) { + let anchor = self + .anchor + .expect("lay_out with no anchor: `draw` returns before this without one"); + let placement = match anchor.edge { + Edge::Leading => Placement::Leading(anchor.offset), + Edge::Trailing => Placement::Trailing(anchor.offset), + }; + let (mut lead, mut trail) = self.place(painter, anchor.slot, placement); + + let mut idx_lead = anchor.slot; + while lead > 0.0 { + let Some(prev) = self.prev_slot(idx_lead) else { + break; + }; + let (l, _) = self.place(painter, prev, Placement::Trailing(lead)); + lead = l; + idx_lead = prev; + } + + let mut idx_trail = anchor.slot; + while trail < self.viewport_len { + let Some(next) = self.next_slot(idx_trail) else { + break; + }; + let (_, t) = self.place(painter, next, Placement::Leading(trail)); + trail = t; + idx_trail = next; + } + + // What a fling is clamped against -- see `at_start`'s field doc. + // `lead`/`trail` are the extreme edges actually placed this frame, + // and `prev_slot`/`next_slot` returning `None` is what "no more + // content" means everywhere else in this widget. + self.at_start = self.prev_slot(idx_lead).is_none() && lead >= 0.0; + self.at_end = self.next_slot(idx_trail).is_none() && trail <= self.viewport_len; + // The structural half of the same two questions, kept apart from + // `at_start`/`at_end` because they mean different things: + // "there is nothing loaded past this edge" is what bounds a + // scroll, while `at_start`/`at_end` add "and there is a gap to + // give back", which is what the overscroll clamp acts on. + self.no_more_before = self.prev_slot(idx_lead).is_none(); + self.no_more_after = self.next_slot(idx_trail).is_none(); + self.content_lead = lead; + self.content_trail = trail; + + // Both halves of `intersects_viewport`'s rule, checked where they + // are cheap to check: what this pass put on screen is exactly what + // overlaps the viewport, and nothing above or below it can be + // seen. The first failed silently for a whole build -- an + // off-screen row draws correctly, it is just in the wrong place. + // `assert!` for R1's reason: it walks the rows *on screen*, a + // handful, once per draw, and a release build is the only build + // this fault has ever been seen in. + assert!( + self.extents + .values() + .all(|e| self.intersects_viewport(e.lead, e.trail)), + "a row outside the viewport (0..{}) is recorded as on screen: {:?}", + self.viewport_len, + self.extents + .values() + .find(|e| !self.intersects_viewport(e.lead, e.trail)), + ); + (lead, trail) + } + + fn update_snap_end(&mut self) { + let pinned = match self.anchor { + Some(a) => { + self.next_slot(a.slot).is_none() + && a.edge == Edge::Trailing + && (self.viewport_len - a.offset).abs() < 0.5 + } + None => false, + }; + self.ctl.set_pinned_to_end(pinned); + } + + /// **The one rule for what this list draws**: a row is on screen if + /// any part of it is, so a row straddling either edge is drawn *in + /// full* and one that has left the viewport entirely is not drawn at + /// all. Both halves matter and they failed in opposite directions on + /// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled + /// past were still being drawn, over the header above the list, and + /// the part of a straddling row above the viewport had nothing + /// clipping it. The viewport here is the list's own box -- `0 .. + /// viewport_len`, `painter.region()` in window terms -- which is the + /// same box `LazySpan::draw` requires a mask on, so that what this test + /// admits and what the clip keeps are one region rather than two that + /// can disagree. + fn intersects_viewport(&self, lead: f32, trail: f32) -> bool { + trail > 0.0 && lead < self.viewport_len + } + + /// The box between two edges measured **along `dir`** from this + /// widget's own leading edge, which for `Sign::Neg` is its bottom (or + /// right). Every other length in this widget -- `viewport_len`, an + /// `Anchor`'s offset, a `Placement`'s edges -- is in that same + /// direction-relative space, so the whole layout is written once and + /// only this function knows which way round the box is. The mirror is + /// `UiSpan::flip`, the same one `Span::draw` uses for a reversed + /// `Dir`. + fn abs_region(dir: Dir, start: f32, end: f32) -> UiRegion { + let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end)); + let mut region = UiRegion::from_axis(dir.axis, span, UiSpan::FULL); + if dir.sign == Sign::Neg { + region.flip(dir.axis); + } + region + } + + /// Place one slot (a real row or a sentinel) per `placement`, caching + /// its resolved extent (for the next frame's `note_tap` resolution) and + /// height (for its own next placement, see below), and return its + /// resolved `(leading, trailing)` edges in viewport pixels. + /// + /// A row already measured on some earlier frame is placed directly at + /// its cached height's exact box -- one `widget_within`/`reposition` + /// pass, the same as any other widget placed by an already-known + /// region. A row seen for the first time has no cached height to place + /// it *at*, so it is measured first (an oversized, fixed-size region) + /// and then drawn a *second* time at the tight box that measurement + /// implies, via `Painter::draw_twice` -- not `reposition` (a pure + /// translation, no resize). This distinction is required, not just an + /// optimisation: a row is not always plain wrapped text -- + /// `.background(rect(tint))` is an ordinary way to style one, and + /// `Rect::draw` is `is_size_independent` specifically because it fills + /// *whatever region it is given* (`Size::REST`, see `rect.rs`). + /// Measuring such a row at the oversized box has it paint an oversized + /// rect there; `reposition` only ever writes an offset, never a size, + /// so an every-frame reposition-only scheme would leave that primitive + /// oversized forever. Using `draw_twice` for *every* frame would fix + /// that but break the opposite property: its two calls use two + /// different regions, so whichever one `ActiveData.region` ends up + /// holding always disagrees with the *next* frame's first call, + /// forcing a real redraw every single frame instead of the cheap + /// skip-or-move `draw_inner` already provides for an unchanged or + /// merely-translated widget. Caching the height once measured is what + /// lets an already-seen row go back to that cheap path while a + /// first-seen one still gets a correctly-sized initial paint. + /// + /// **A row whose measurement disagrees with the box it was offered is + /// drawn again, this frame, at the box its own height implies** -- + /// both placements, since both offer a cached height and both can be + /// wrong the frame a row's content changes size. This is not an + /// optimisation to skip: a row is routinely `.background(rect(..))` + /// (a tool card *is* one), and `Rect::draw` fills whatever region it + /// is handed, so a row offered last frame's height paints its + /// background at last frame's height while its text lays out at the + /// new one -- Iris's 2026-09-08 report that "collapsing and opening an + /// edit card draws the card background a frame late, so it looks + /// closed even when there's text". A `reposition` does not fix it + /// (it writes an offset, never a size), which is what the bottom- + /// anchored half used to do. The extra draw happens only on the frame + /// a row actually changes height, which is a frame that was already + /// redrawing that row. + fn place(&mut self, painter: &mut Painter, slot: isize, placement: Placement) -> (f32, f32) { + // Every current caller derives `slot` from `repair_anchor`/ + // `prev_slot`/`next_slot`, which already check existence -- but + // that invariant is enforced by convention across three call + // sites, not by this function, which would otherwise fail with a + // bare "index out of bounds" and no context (docs/ + // REVIEW-2026-09-06.md finding 2). `slot_widget`, called from + // here, is what actually indexes/`.expect`s on it. Stays a + // `debug_assert!` under R1's rule: this runs once per row placed + // per frame, and its release failure is the `.expect` below rather + // than something silently wrong on screen. + debug_assert!( + self.slot_exists(slot), + "place() called with a slot that doesn't exist: {slot:?}" + ); + let dir = self.dir; + let axis = dir.axis; + let output_len = painter.output_size().axis(axis); + let container_len = painter.region().axis(axis).len(); + let density = painter.density(); + let resolve = move |used: Size| -> f32 { + used.axis(axis) + .apply_rest(density) + .within_len(container_len) + .to_abs(output_len) + }; + let key = self.slot_key(slot); + let cached = key.and_then(|k| self.heights.get(&k).copied()); + + // A row entirely outside the viewport is traversed but not drawn + // -- see `intersects_viewport`. The walk still has to *pass + // through* it, because its height is what says where the rows + // behind it land, but nothing about it reaches the screen, so + // drawing it costs a redraw (and, unclipped, paints over whatever + // is above the list) for content nobody can see. Only possible + // for a row whose height is already known: a first-time row has + // to be drawn to be measured at all, which is why the extent + // below is recorded from the intersection test rather than from + // "was this drawn". + if let Some(h) = cached { + let (lead, trail) = placement.edges(h); + if !self.intersects_viewport(lead, trail) { + return (lead, trail); + } + } + + let widget = self.slot_widget(slot); + let height = match cached { + // Offered a box sized to the *cached* height (cheap to compare + // against last frame's offer, see `place`'s doc), but the + // height kept is what this draw actually reported -- if the + // row's real content grew since it was cached (and was + // therefore redrawn: an unchanged widget never disagrees with + // its own cache), it is drawn again here, this frame, at the + // box its own height implies rather than waiting a frame to + // self-correct. + Some(h) => { + let (lead, trail) = placement.edges(h); + let used = painter.widget_within(widget, Self::abs_region(dir, lead, trail)); + let height = resolve(used); + if height != h { + let (lead, trail) = placement.edges(height); + painter.widget_within(widget, Self::abs_region(dir, lead, trail)); + } + height + } + // Never measured, so there is no height to place it at: it is + // measured at an oversized region first and drawn again at the + // box that measurement implies (`draw_twice`, not + // `reposition`, which writes an offset and never a size). + // + // A bottom-known row measures at a *zero-anchored* region + // rather than at its own box: using the real box would make + // the measurement's offered size track this list's own height, + // so a sibling growing taller (the input-box case) would look + // like a resize to every bottom-known row and force a full + // redraw of each -- despite a row's content depending only on + // width. + None => { + let measure_from = match placement { + Placement::Leading(lead) => lead, + Placement::Trailing(_) => 0.0, + }; + let first = Self::abs_region(dir, measure_from, measure_from + GENEROUS_PADDING); + let mut height = 0.0; + painter.draw_twice(widget, first, |used| { + height = resolve(used); + let (lead, trail) = placement.edges(height); + Self::abs_region(dir, lead, trail) + }); + height + } + }; + let (lead, trail) = placement.edges(height); + if let Some(k) = key { + self.heights.insert(k, height); + // `extents` is what is *on screen* (`key_at`'s doc, and + // `rehome_anchor` below reads it as exactly that), so a + // first-time row that had to be drawn to be measured and + // turned out to be off-screen does not go in it. + if self.intersects_viewport(lead, trail) { + self.extents.insert(k, RowExtent { slot, lead, trail }); + } + } + (lead, trail) + } +} + +/// The oversized bound offered along the primary axis when a row's real +/// extent isn't known yet (a fresh top-known placement) or is deliberately +/// discarded (a bottom-known measurement, see `place`). Large enough that +/// no real row's content is taller than this -- rows do not clip to the +/// height they're offered, only width drives a wrapped row's height -- and +/// a fixed module constant rather than derived from `viewport_len`, since +/// deriving it from a value that changes whenever the list itself resizes +/// (a sibling growing) would make the offered region's *size* change too, +/// defeating the same-size-different-position fast path `place` depends +/// on for an O(1) move. +const GENEROUS_PADDING: f32 = 100_000.0; + +impl LazySpan { + /// Make this span scrollable: the wheel and a finger drag, registered + /// on the span itself. + /// + /// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.** + /// That one wraps its widget in a `ScrollArea`, which is exactly what + /// must not happen here -- a lump slid about by a parent would never + /// update which rows it shows -- and this span already owns the + /// controller such an area would have brought. Rust resolves an + /// inherent method before a trait one, so `list.scrollable()` finds + /// this, and it needs neither of the other's arguments: the axis is + /// `dir`'s and the pin was chosen at construction. + /// + /// A span reached through a builder (already wrapped, already behind a + /// closure) gets the trait method instead, correctly -- by then it is + /// a different widget. + /// + /// A caller with a drag arbiter of its own registers the wheel and + /// leaves the drag out rather than calling this: one gesture, one + /// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc). + pub fn scrollable(self) -> impl WidgetIdFn { + let axis = self.dir.axis; + scroll_senses(self, axis) + } +} + +impl Scrollable for LazySpan { + fn controller(&self) -> &ScrollController { + &self.ctl + } + + fn controller_mut(&mut self) -> &mut ScrollController { + &mut self.ctl + } +} + +impl LazySpan { + /// How far this span can still travel each way, from the edges the + /// last walk actually placed -- what the controller clamps the next + /// delta against, so that a delta running 250px past the end gives + /// 250 back rather than everything or nothing. + /// + /// **The bound is exact where there is one, and `INFINITY` where there + /// is not.** With content still loaded past an edge this span + /// genuinely cannot say how far it goes without walking there, and + /// saying so is what lets the walk find the wall and `overscroll_gap` + /// hand the overshoot back inside the same frame. + fn travel(&self) -> Travel { + let forward = if self.no_more_after { + (self.content_trail - self.viewport_len).max(0.0) + } else { + f32::INFINITY + }; + let backward = if self.no_more_before { + (-self.content_lead).max(0.0) + } else { + f32::INFINITY + }; + // `forward`/`backward` are the walk's own directions -- toward + // later content and toward earlier -- and `Travel` is in screen + // space, so which is which depends on `dir` exactly as + // `flip_delta` does. A `Dir::UP` span's later content is *above* + // it, so scrolling back down the screen is what runs out first. + match self.dir.sign { + Sign::Pos => Travel { + back: backward, + fwd: forward, + }, + Sign::Neg => Travel { + back: forward, + fwd: backward, + }, + } + } +} + +impl Widget for LazySpan { + /// A lazy span animates exactly one thing, its fling -- and it drives + /// its own rather than being handed deltas by a `ScrollArea` around + /// it, since which rows exist at all is a function of where it is + /// scrolled to and a moved lump would never update them. + fn tick(&mut self, now: Instant) -> bool { + self.tick_fling(now) + } + + fn draw(&mut self, painter: &mut Painter) -> Size { + let axis = self.dir.axis; + let output_len = painter.output_size().axis(axis); + self.viewport_len = painter.region().axis(axis).len().to_abs(output_len); + + self.ctl.set_density(painter.density()); + self.repair_anchor(); + if self.anchor.is_none() { + self.extents.clear(); + return Size::REST; + } + + // What a wheel, a drag or a fling asked for since the last frame, + // already clamped to the travel that frame reported -- the walls + // it placed are the freshest answer available, and where they are + // stale (the content changed under a settled anchor) the walk + // below finds the real ones and `overscroll_gap` gives back the + // difference before this frame ends. + // + // Deliberately after the early return above: a delta that arrives + // while there is nothing to scroll stays banked rather than being + // silently spent on an empty list. + let delta = self.ctl.take_delta(); + if delta != 0.0 { + let amt = self.flip_delta(delta); + self.move_anchor(amt); + } + + // `reanchor_at_tap` reads `extents` as they stood after the + // *previous* frame's layout -- the last on-screen box for each + // visible row -- so the clear that starts rebuilding it for this + // frame has to wait until after this call, not before. + if let Some(tap) = self.pending_tap.take() { + self.reanchor_at_tap(tap); + } + self.extents.clear(); + + let (lead, trail) = self.lay_out(painter); + + // **The clamp is applied inside the frame that found it**, not + // marked for the next one: layout is a pure function of the state + // rather than of how many frames have been drawn (Iris, + // 2026-09-08), and a correction that lands next frame is a frame + // drawn wrong -- with nothing guaranteed to ask for that next + // frame, since a fling that ran out at an end has already stopped + // requesting them, which is exactly what left the list parked past + // its own first row. Same shape as `Scroll::draw`, which measures + // its content and places it again in the one frame. + // + // One further pass settles it, always: the gap is measured from + // the edges this walk actually placed, so moving the anchor by it + // puts that edge exactly on the viewport's, and the rows the + // second walk brings into view are placed outward from there. The + // opposite end cannot open a new gap -- that would mean the + // content is shorter than the viewport, which `overscroll_gap` + // already declines to touch. The extra walk is paid only on a + // frame that was overscrolled, and it re-offers every row the same + // box at a new offset, which `draw_inner` dispatches as an O(1) + // move. + if let Some(gap) = self.overscroll_gap(lead, trail) { + self.move_anchor(gap); + self.extents.clear(); + self.lay_out(painter); + } + + self.rehome_anchor(); + self.update_snap_end(); + self.ctl.set_travel(self.travel()); + Size::REST + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + /// Every row in `build_flingable_list` is this tall, which is what + /// makes `scroll_position` exact. + const FLING_ROW_H: f32 = 20.0; + + /// How far a `build_flingable_list` span has scrolled from its very + /// first row, in pixels: read off the leading row on screen, whose + /// content position is exactly `slot * FLING_ROW_H` because every row + /// there is that tall. Measures where the content actually sits rather + /// than any bookkeeping about it, and unlike a single row's extent it + /// stays defined however far the list travels -- `extents` holds only + /// what is on screen (`LazySpan::intersects_viewport`). + fn scroll_position(list: &LazySpan) -> f32 { + let first = list + .extents + .values() + .min_by(|a, b| a.lead.total_cmp(&b.lead)) + .expect("something is on screen"); + first.slot as f32 * FLING_ROW_H - first.lead + } + + struct TestRsc { + ui: UiData, + } + + impl UiRsc for TestRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + } + + /// A fixed-height row so its size is exact and predictable in tests -- + /// mutating `.y` afterward is how tests simulate a row "expanding." + fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget, StrongWidget) { + let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let sized = rsc.ui.widgets.add_strong(Sized { + inner: rect.any(), + x: None, + y: Some(Len::abs(height)), + }); + (sized.weak(), sized.any()) + } + + /// Pushes one row per key and returns the rows' own weak handles (for + /// mutating a specific row's height later), in the same order as `keys`. + fn push_rows( + rsc: &mut TestRsc, + list: &mut LazySpan, + keys: &[RowKey], + height: f32, + ) -> Vec> { + keys.iter() + .map(|&key| { + let (weak, w) = fixed_row(rsc, height); + list.push_back(LazyItem::new(key, w)); + weak + }) + .collect() + } + + /// Adds `list` to the arena and returns both a typed weak handle (for + /// calling `LazySpan`'s own methods through `Widgets::get`/`get_mut`, which + /// need a `Sized` widget type) and the erased root `UiRenderState::update` + /// draws. + /// + /// The root is a `Masked` around the list rather than the list + /// itself, because that is what every real caller has to do -- a + /// `LazySpan` draws the row straddling each edge in full and asserts + /// something is clipping it (`LazySpan::draw`). The mask is the full + /// window here, which is also the list's own box. + fn add_list(rsc: &mut TestRsc, list: LazySpan) -> (WeakWidget, StrongWidget) { + let strong = rsc.ui.widgets.add_strong(list); + let weak = strong.weak(); + let root = rsc.ui.widgets.add_strong(Masked { + shape: None, + inner: strong.any(), + }); + (weak, root.any()) + } + + /// The case the top-edge cull and the overscroll clamp both had no + /// reason to touch: fewer rows than fit. Every one of them is drawn + /// (nothing here is outside the viewport), and `overscroll_gap` + /// leaves the list bottom-anchored -- the gap above the first row is + /// not overscroll, it is where this widget puts a short list, and + /// pulling it to the top would be the clamp overriding + /// `repair_anchor`'s own default. + #[test] + fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + // Several frames, since the clamp acts on the frame *after* the + // one that measured a gap: a wrong one would walk the rows up the + // screen 40px at a time rather than settle. + for _ in 0..4 { + render.update(&root, &mut rsc); + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!( + list_ref.extents.len(), + 3, + "every row of a short list is on screen" + ); + let first = list_ref.extents[&0]; + let last = list_ref.extents[&2]; + assert!( + (first.lead - 40.0).abs() < 0.01 && (last.trail - 100.0).abs() < 0.01, + "a 60px list in a 100px viewport moved off the bottom: rows {}..{}", + first.lead, + last.trail, + ); + } + } + + /// `Dir::UP` is not a screen direction bolted on at the end: item 0 + /// sits at the **bottom** and the sequence grows toward the top, which + /// is the mirror of `Dir::DOWN` and the reason the walk is written in + /// leading/trailing terms with `abs_region` the only place that knows + /// which way round the box is. Same three rows, same viewport, so the + /// only difference from + /// `a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom` + /// is `dir` -- and the newest row moves from the bottom of the screen + /// to the top. + /// + /// Asserts on **where each row was actually drawn** + /// (`UiRenderState::active`), not on `extents`: those are kept in the + /// walk's own direction-relative space and converted on the way out, + /// so an `extent()`-only test passes even with the flip in + /// `abs_region` deleted -- it would be checking the bookkeeping + /// against itself while every row painted at the mirror of where it + /// belongs. + #[test] + fn a_dir_up_span_grows_upward_from_item_zero() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::UP, Pin::End); + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let drawn = |render: &UiRenderState, row: &WeakWidget| { + let px = render.active[&row.id()].region.to_px((100.0, 100.0).into()); + (px.top_left.y, px.bot_right.y) + }; + assert_eq!( + drawn(&render, &rows[2]), + (0.0, 20.0), + "the newest row of a Dir::UP span is drawn at the top of the screen" + ); + assert_eq!(drawn(&render, &rows[1]), (20.0, 40.0)); + assert_eq!( + drawn(&render, &rows[0]), + (40.0, 60.0), + "item 0 is drawn furthest down" + ); + + // And the public extent agrees with the pixels, in screen space. + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!(list_ref.extent(2), Some((0.0, 20.0))); + assert_eq!(list_ref.extent(0), Some((40.0, 60.0))); + } + + /// A delta means a screen direction, not a logical one: the same + /// negative delta moves the content up the screen whichever way the + /// span is laid out (Iris, 2026-09-08 -- "positive should always + /// scroll up / left, and negative down / right"). A `Dir::UP` span + /// used to pan the opposite way for the same number, because + /// the delta reached the walk without the flip its + /// positions already went through. + /// + /// Asserted on where rows were **drawn**, for the reason + /// `a_dir_up_span_grows_upward_from_item_zero` gives: an assertion in + /// the walk's own space checks the bookkeeping against itself and + /// passes with the flip deleted. + #[test] + fn a_delta_moves_both_directions_the_same_way_on_screen() { + // 10 rows of 20px in a 100px viewport. Both spans open pinned to + // the end of their content -- which is the bottom of the screen + // for `Dir::DOWN` and the top of it for `Dir::UP` -- so each is + // first walked into the middle, where both have content to move + // in either direction, and only then handed the same delta. + let moved_by = |dir: Dir| { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(dir, Pin::End); + let keys: Vec = (0..10).collect(); + let rows = push_rows(&mut rsc, &mut list, &keys, 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let push = |rsc: &mut TestRsc, render: &mut UiRenderState, delta: f32| { + let before = rsc.ui.widgets.get(&list_weak).unwrap().amt(); + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(delta); + render.update(&root, rsc); + let moved = before - rsc.ui.widgets.get(&list_weak).unwrap().amt(); + assert!( + (moved - delta).abs() < 0.01, + "there was content to take the whole delta: asked {delta}, moved {moved}", + ); + }; + // Away from the pinned end, in whichever screen direction + // that is for this `dir`. + push( + &mut rsc, + &mut render, + match dir.sign { + Sign::Pos => 60.0, + Sign::Neg => -60.0, + }, + ); + + // Row 4 is on screen in both spans now, and stays drawn + // across a move this small whichever way it goes. + let top = |render: &UiRenderState| { + render.active[&rows[4].id()] + .region + .to_px((100.0, 100.0).into()) + .top_left + .y + }; + let before = top(&render); + push(&mut rsc, &mut render, -10.0); + top(&render) - before + }; + + for dir in [Dir::DOWN, Dir::UP] { + let moved = moved_by(dir); + assert!( + (moved + 10.0).abs() < 0.5, + "a negative delta must move the content 10px up the screen, not {moved}px", + ); + } + } + + /// The conversion the reversed direction makes necessary: a pointer + /// position arrives in screen pixels while the walk works in + /// direction-relative ones, so a hit test that skipped the flip would + /// silently answer with the row mirrored across the viewport -- wrong + /// in a way that looks like a working list until you tap one. + #[test] + fn a_reversed_span_hit_tests_in_screen_space() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::UP, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!( + list_ref.key_at(10.0), + Some(2), + "10px down the screen is the newest row" + ); + assert_eq!(list_ref.key_at(50.0), Some(0), "50px down is item 0"); + assert_eq!( + list_ref.key_at(90.0), + None, + "below the content there is no row" + ); + } + + #[test] + fn bottom_anchored_by_default() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + + // 5 rows of 20px into a 60px viewport: rows 2,3,4 visible, bottom + // (row 4) flush with the viewport's own bottom edge. + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!(list_ref.extents.len(), 3); + let last = list_ref.extents[&4]; + assert!((last.trail - 60.0).abs() < 0.01); + assert!(!list_ref.extents.contains_key(&0)); + assert!(!list_ref.extents.contains_key(&1)); + } + + /// A row shaped like the ordinary `.background(rect(tint))` idiom: + /// a `Stack` whose first child is a `Rect` (`is_size_independent`, + /// fills whatever region it is given -- see `rect.rs`) and whose + /// second (the one `StackSize::Child` reports as the row's own size) + /// is a `Sized`-wrapped `Rect` of the given height. Returns the + /// background rect's own id (to check what it actually painted at) + /// alongside the row widget. + fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { + let (bg_id, _, row) = resizable_background_row(rsc, height); + (bg_id, row) + } + + /// [`background_styled_row`] with the foreground's own `Sized` handed + /// back too, so a test can change the row's height the way a tool card + /// being collapsed or opened does. + fn resizable_background_row( + rsc: &mut TestRsc, + height: f32, + ) -> (WidgetId, WeakWidget, StrongWidget) { + let bg = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let bg_id = bg.id(); + let fg_rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); + let fg = rsc.ui.widgets.add_strong(Sized { + inner: fg_rect.any(), + x: None, + y: Some(Len::abs(height)), + }); + let fg_weak = fg.weak(); + let stack = Stack { + children: vec![bg.any(), fg.any()], + size: StackSize::Child(1), + }; + (bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any()) + } + + /// Iris's 2026-09-08 report: "collapsing and opening an edit card + /// draws the card background a frame late, so it looks closed even + /// when there's text, and then looks open even when the text is + /// collapsed." + /// + /// A row is offered a box sized to its *cached* height, and a + /// `.background(rect(..))` fills whatever box it is given -- so on the + /// frame a row changes height its text is laid out at the new height + /// and its background painted at the old one. Every row is exercised, + /// in both directions, because which of `place`'s two placements a row + /// takes depends on where it sits relative to the anchor and the fault + /// was in both. + #[test] + fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() { + for key_to_change in 0..5u64 { + for new_height in [50.0f32, 8.0] { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + let mut rows = Vec::new(); + for key in 0..5u64 { + let (bg_id, fg, row) = resizable_background_row(&mut rsc, 20.0); + rows.push((bg_id, fg)); + list.push_back(LazyItem::new(key, row)); + } + let (_, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + render.update(&root, &mut rsc); + + let (bg_id, fg) = rows[key_to_change as usize]; + rsc.ui.widgets.get_mut(&fg).unwrap().y = Some(Len::abs(new_height)); + render.update(&root, &mut rsc); + + let px = render.active[&bg_id].region.to_px((100.0, 100.0).into()); + let drawn = px.size().y; + assert!( + (drawn - new_height).abs() < 0.5, + "row {key_to_change} resized to {new_height}px drew its background at {drawn}px on the same frame" + ); + } + } + } + + #[test] + fn a_fill_shaped_background_is_not_left_oversized() { + // Regression test for a real bug found building the I3 example: + // `place`'s Bottom-known branch used to measure a row at an + // oversized, fixed-size region and `reposition` (a pure + // translation) it into its final box. A row's own natural height + // is independent of that oversized offer (true for wrapped text), + // but a `Rect` background is *defined* to fill whatever it is + // given -- so it painted at the oversized size, and moving it + // afterward never shrank it back down. Only visible by checking + // what the background rect's own primitive covers, not the row's + // reported extent (which was already correct, since it comes from + // the *foreground* child). + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + let mut bg_ids = Vec::new(); + for key in 0..5u64 { + let (bg_id, row) = background_styled_row(&mut rsc, 20.0); + bg_ids.push(bg_id); + list.push_back(LazyItem::new(key, row)); + } + let (_, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + for &bg_id in &bg_ids { + let region = render.active[&bg_id].region; + let px = region.to_px((100.0, 100.0).into()); + let height = px.size().y; + assert!( + (height - 20.0).abs() < 0.5, + "background rect should be exactly the row's height (20px), got {height}px \ + -- an oversized measurement region leaking through would show as ~100000px" + ); + } + } + + #[test] + fn insert_above_anchor_is_o1_and_does_not_move_visible_rows() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[10, 11, 12], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + render.take_counters(); + + let extents_before = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); + + // Prepend far-above rows one at a time -- an O(1) push each, + // touching nothing currently on screen. + for key in 0..10u64 { + let (_, w) = fixed_row(&mut rsc, 20.0); + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .push_front(LazyItem::new(key, w)); + } + render.update(&root, &mut rsc); + let (draws, _rewrites, _moves, _shapes) = render.take_counters(); + + // None of the already-visible rows (11, 12) were touched: the + // extents for those keys are numerically unchanged, and the only + // draws possible are for the list widget itself plus any row that + // entered view (none did -- the prepended rows are far above the + // anchor's slot, which only moved by an index, not a redraw). + let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); + for key in [11u64, 12] { + assert_eq!( + (extents_before[&key].lead, extents_before[&key].trail), + (extents_after[&key].lead, extents_after[&key].trail) + ); + } + // The list widget itself draws once (it was marked dirty by the + // pushes); no row draw is attributable to the 10 prepended rows, + // since none of them ever entered the viewport. + assert!( + draws <= 2, + "insert-above touched more than the list itself: {draws} draws" + ); + } + + #[test] + fn expanding_a_row_holds_the_edge_nearest_the_tap() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + // Five rows of 20px; with a 100px viewport all are visible, + // anchored at the bottom by default (row 4's bottom at 100). + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + // Row 2 occupies [40, 60) before it grows. Tap near its top edge + // (41) so growing it should hold *that* edge fixed and push row 3 + // and row 4 further down, leaving rows 0/1 untouched above it. + let row2 = rows[2]; + { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let ext = list_ref.extents[&2]; + assert!((ext.lead - 40.0).abs() < 0.01); + assert!((ext.trail - 60.0).abs() < 0.01); + } + + rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.0); + // Grow row 2 from 20px to 50px -- marks it (and, once its size + // changes, the list) dirty via the ordinary redraw-bubble path. + rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row2_ext = list_ref.extents[&2]; + let row3_ext = list_ref.extents[&3]; + // Top edge held at 40 (nearest the tap at 41): row 2 now spans + // [40, 90), and row 3 -- below the grown row -- is pushed down to + // start at 90, not left at its old 60. + assert!( + (row2_ext.lead - 40.0).abs() < 0.01, + "top edge should stay put: {row2_ext:?}" + ); + assert!( + (row2_ext.trail - 90.0).abs() < 0.01, + "bottom edge should move by the full +30 growth: {row2_ext:?}" + ); + assert!( + (row3_ext.lead - 90.0).abs() < 0.01, + "row below the expanded row should be pushed down: {row3_ext:?}" + ); + // Rows above the expanding row are untouched. + let row1_ext = list_ref.extents[&1]; + assert!((row1_ext.lead - 20.0).abs() < 0.01); + assert!((row1_ext.trail - 40.0).abs() < 0.01); + } + + #[test] + fn expanding_a_row_holds_the_bottom_edge_when_tap_is_lower() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + // Row 2 spans [40, 60). Tap near its bottom (59) instead. + let row2 = rows[2]; + rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(59.0); + rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row2_ext = list_ref.extents[&2]; + let row1_ext = list_ref.extents[&1]; + // Bottom edge held at 60: the row grows upward instead, and row 1 + // (above it) is pushed up to end at 10, not left at 20. + assert!( + (row2_ext.trail - 60.0).abs() < 0.01, + "bottom edge should stay put: {row2_ext:?}" + ); + assert!( + (row2_ext.lead - 10.0).abs() < 0.01, + "top edge should move by the full +30 growth: {row2_ext:?}" + ); + assert!( + (row1_ext.trail - 10.0).abs() < 0.01, + "row above the expanded row should be pushed up: {row1_ext:?}" + ); + } + + #[test] + fn moves_stay_o1_across_list_size() { + for &n in &[20usize, 200, 2000] { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + let keys: Vec = (0..n as u64).collect(); + push_rows(&mut rsc, &mut list, &keys, 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 200.0)); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + render.take_counters(); + + // Backwards, into content that exists: a list opens flush with + // its newest end, so a *negative* delta from there is + // overscroll, and the clamp lays out a second time within the + // frame to give it back -- a correct extra pass, but not the + // ordinary scroll tick whose cost this test is about. + rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); + render.update(&root, &mut rsc); + let (draws, _rewrites, moves, _shapes) = render.take_counters(); + + // The visible window is a fixed ~10 rows regardless of n; an + // O(n) regression would show up as draws/moves scaling with + // list size instead of staying flat. + assert!(draws <= 12, "n={n}: expected O(visible), got {draws} draws"); + assert!(moves >= 1, "n={n}: a scroll tick should move something"); + assert!(moves <= 12, "n={n}: expected O(visible) moves, got {moves}"); + } + } + + /// The streamed-reply case (RUST.md's "streaming still costs a full + /// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta + /// swaps the last row's widget for a taller one, same key, same slot. + /// A list flush with its own end (the default, `snap_end`) must stay + /// flush -- the row grows *upward* from the pinned bottom edge, not + /// the other way around, exactly like an ordinary resize of that same + /// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`). + #[test] + fn replacing_the_last_row_stays_pinned_to_the_bottom() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + + // Row 4 is flush with the viewport's bottom edge before the replace. + { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!((list_ref.extents[&4].trail - 60.0).abs() < 0.01); + } + + let (_weak, new_row) = fixed_row(&mut rsc, 40.0); + let old = rsc + .ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(LazyItem::new(4, new_row)); + assert!( + old.is_some(), + "replace_back should hand back the row it evicted" + ); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let row4 = list_ref.extents[&4]; + assert!( + (row4.trail - 60.0).abs() < 0.01, + "still pinned to the newest end after the replace: {row4:?}" + ); + assert!( + (row4.lead - 20.0).abs() < 0.01, + "grew upward, from the pinned bottom edge: {row4:?}" + ); + } + + /// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor + /// its sibling below ever asserts the *evicted* key's own bookkeeping + /// is actually gone -- both replace row 4 with another row also keyed + /// `4`, so `heights.remove(&old.key)` removing and re-inserting the + /// same key would pass either test even if it did nothing (docs/ + /// REVIEW-2026-09-06.md finding 10; this is `Selection`'s finding 1 + /// class of bug -- a stale handle outliving what it points to -- + /// production-tested from `LazySpan`'s own side). Replacing with a + /// **different** key is what actually exercises the removal. + #[test] + fn replace_back_forgets_the_evicted_keys_own_height() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + render.update(&root, &mut rsc); + assert!( + rsc.ui + .widgets + .get(&list_weak) + .unwrap() + .heights + .contains_key(&4) + ); + + let (_weak, new_row) = fixed_row(&mut rsc, 40.0); + let old = rsc + .ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(LazyItem::new(100, new_row)); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert_eq!(old.map(|o| o.key), Some(4)); + assert!( + !list_ref.heights.contains_key(&4), + "the evicted key's cached height must not outlive the row it measured" + ); + } + + /// The other half of the same fix's contract: replacing a row that is + /// *not* on screen must not move anything that is. `replace_back` only + /// touches the last slot's own widget and this file's own `heights`/ + /// `extents` caches for that one key -- nothing about `Anchor` changes + /// -- so the already-placed rows above it should come out at the exact + /// same boxes on the next frame. + #[test] + fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 60.0)); + // Settle at the default (bottom) anchor first -- `jump_to_start` + // does not touch `snap_end`, and `repair_anchor` only leaves a + // freshly-set anchor's offset alone once `viewport_len` has + // already matched `last_viewport_len` once, the same reason + // `moves_stay_o1_across_list_size` settles before the tick it + // actually measures. + render.update(&root, &mut rsc); + // Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far + // below the viewport. + rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start(); + render.update(&root, &mut rsc); + + let (before0, before1, before2) = { + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!(!list_ref.extents.contains_key(&4)); + ( + list_ref.extents[&0], + list_ref.extents[&1], + list_ref.extents[&2], + ) + }; + + let (_weak, new_row) = fixed_row(&mut rsc, 999.0); + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(LazyItem::new(4, new_row)); + + render.update(&root, &mut rsc); + + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + for (key, before) in [(0u64, before0), (1, before1), (2, before2)] { + let after = list_ref.extents[&key]; + assert_eq!( + (after.lead, after.trail), + (before.lead, before.trail), + "row {key} moved after an off-screen replace" + ); + } + } + + /// RUST.md's P0 phone report (Iris's screenshot, 2026-09-06): a + /// replaced row's primitives drawn a second time, overlapping the + /// replacement. Reproduces the exact path `TranscriptScreen::apply`'s + /// `ReplaceLast` case drives up to 400 times during a streamed reply + /// (`bench_client.rs`'s stream phase): the last slot's widget is + /// swapped for a brand-new one, same key, and (since a fresh widget + /// has no cached height) placed via `place`'s `draw_twice` path every + /// time -- the provisional-then-real two-draw sequence LAYOUT.md + /// documents as the one place in this crate that deliberately draws a + /// widget twice. If `draw_inner`'s old-children diffing or + /// `UiRenderState::remove`'s primitive freeing ever failed to retire + /// the evicted widget (or the provisional draw's own primitives), it + /// would show up here as `active_widgets` growing without bound. + /// **Passes as written** -- this pins the widget-arena layer as + /// correct in isolation; see the P0 box for where the duplicate was + /// actually chased to instead (`Span`'s two-phase draw and the + /// `redraw_all`-vs-`redraw_updates` split, still open). + #[test] + fn replacing_the_last_row_many_times_does_not_leak_primitives() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + for key in 0..5u64 { + let (_bg_id, row) = background_styled_row(&mut rsc, 20.0); + list.push_back(LazyItem::new(key, row)); + } + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let before = render.active_widgets(); + for i in 0..400u32 { + // A varying height keeps every replace on the `draw_twice` + // (cache-miss) path rather than settling into the O(1) + // same-size `mov` fast path once the height happens to repeat. + let (_bg_id, new_row) = background_styled_row(&mut rsc, 20.0 + (i % 3) as f32); + rsc.ui + .widgets + .get_mut(&list_weak) + .unwrap() + .replace_back(LazyItem::new(4, new_row)); + render.update(&root, &mut rsc); + } + let after = render.active_widgets(); + + assert_eq!( + before, after, + "400 replaces of the last row must leave exactly the same \ + number of active widgets as before a leaked id (and the \ + primitives that live as long as its ActiveData does) would \ + show up here as growth" + ); + } + + /// The doubled `Compacted:` row from Iris's phone (docs/bench/ + /// iris-phone-v2-2026-09-06.md), reproduced at its mechanism. + /// + /// `replacing_the_last_row_many_times_does_not_leak_primitives` above + /// counts *widgets*, which is why it passed all along: the orphan's + /// owner is very much alive -- it is an earlier set of that same + /// widget's primitives that got stranded. What strands them is a row + /// marked dirty and then reached by its **ancestor's** redraw rather + /// than by its own: `draw_inner` only *read* the dirty mark, so the + /// whole branch that frees a redrawn widget's previous primitives was + /// skipped, and the fresh `ActiveData` overwrote the only handles that + /// could ever have freed them. `LazySpan` sets no mask, so that copy then + /// draws every frame at whatever region it last had -- including, + /// where the row was being measured at `GENEROUS_PADDING`, well below + /// the list's own box and under the composer. + /// + /// Two rows, two shapes of the same fault: row 2 has a cached height + /// (one `widget_within`), row 4 is replaced so it has none (`place`'s + /// `draw_twice`, which reaches `draw_inner` twice for one id in one + /// frame and so orphans a copy even with no ancestor involved). + #[test] + fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + // Rows that own a primitive *at their own id* (a background rect), + // not only through a child: an orphan is a widget's own primitive + // outliving its own redraw, so a row whose top-level widget paints + // nothing itself cannot show one however broken the path is. + let mut rows = Vec::new(); + for key in 0..5u64 { + let (bg_id, row) = background_styled_row(&mut rsc, 20.0); + rows.push((row.id(), bg_id)); + list.push_back(LazyItem::new(key, row)); + } + let (list_weak, root) = add_list(&mut rsc, list); + + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + assert!(render.orphaned_primitives().is_empty()); + + // A streamed row's content changing: the row is marked dirty (any + // `.set()` on it does this)... + let (row2, row2_bg) = rows[2]; + rsc.ui.widgets.get_dyn_mut(row2).unwrap(); + rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap(); + + // Redraw the *list* by name, so the dirty row is reached by its + // ancestor's draw rather than by `redraw_updates` happening to + // pick it first -- which is the order `HashSet` iteration makes + // arbitrary, and the reason this went unnoticed. + render.redraw(list_weak.id(), &mut rsc); + + let orphans = render.orphaned_primitives(); + assert!( + orphans.is_empty(), + "{} primitive(s) survived their own widget's redraw: {orphans:?}", + orphans.len(), + ); + } + + /// Enough rows, tall enough, that a fling toward the start has real + /// room to travel before the walls stop it -- shared by the tests + /// below. + /// + /// Built the way a real caller does: `Masked(LazySpan)`, with the span + /// driving its own `ScrollController` -- the gesture, the fling and + /// `amt` are its own, and so is the walk that says how far it can + /// actually go. The mask is outside because what needs clipping is the + /// row straddling an edge, and masks are inherited down the chain. + fn build_flingable_list( + rsc: &mut TestRsc, + ) -> (WeakWidget, StrongWidget, UiRenderState) { + let mut list = LazySpan::new(Dir::DOWN, Pin::End); + push_rows(rsc, &mut list, &(0..200).collect::>(), FLING_ROW_H); + let list = rsc.ui.widgets.add_strong(list); + let list_weak = list.weak(); + let root = rsc.ui.widgets.add_strong(Masked { + shape: None, + inner: list.any(), + }); + let root = root.any(); + let mut render = UiRenderState::new(); + render.resize((100.0, 600.0)); + render.update(&root, rsc); + (list_weak, root, render) + } + + /// Drive one frame of a fling: tick the span the way + /// `UiData::tick_animations` does, then draw. Answers whether the + /// fling is still going. + fn fling_frame( + rsc: &mut TestRsc, + scroll: &WeakWidget, + root: &StrongWidget, + render: &mut UiRenderState, + now: Instant, + ) -> bool { + let still = rsc.ui.widgets.get_mut(scroll).unwrap().tick(now); + render.update(root, rsc); + still + } + + #[test] + fn a_fling_moves_the_list_and_then_settles() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; + + // Toward the start: **positive**, which is the finger's direction + // and `ScrollController::scroll`'s convention -- the one convention a delta + // has anywhere in the crate now. It used to be negative here, + // because a `LazySpan`'s anchor offset ran the opposite way to a + // `ScrollArea`'s `amt` while both were public and both claimed to + // mirror the other. + assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); + assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); + + let start = Instant::now(); + let mut still = true; + for step in 0..600 { + let now = start + std::time::Duration::from_millis(step * 16); + still = fling_frame(&mut rsc, &scroll, &root, &mut render, now); + if !still { + break; + } + } + assert!(!still, "fling never settled within 600 steps"); + assert!(!rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); + assert!( + scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()) < 200.0 * FLING_ROW_H, + "a fling toward the start should have moved the list back through its rows" + ); + } + + /// The registration half, which is the frame loop's rather than the + /// widget's: a fling that nothing registers never moves, however right + /// its velocity is -- which is exactly what a finger fling did on + /// Iris's phone for two builds. + #[test] + fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; + let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); + + if rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0) { + let id = scroll.id(); + rsc.ui.animate(id); + } + + let start = Instant::now(); + let mut steps = 0; + let mut animating = true; + while animating && steps < 600 { + let now = start + std::time::Duration::from_millis(steps * 16); + animating = rsc.ui.tick_animations(now); + render.update(&root, &mut rsc); + steps += 1; + } + assert!(!animating, "the driver never stopped within 600 frames"); + assert!(steps > 1, "the fling settled without ever moving"); + assert_ne!( + scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()), + before, + "the fling was registered but never applied" + ); + + // Nothing left registered, so the next frame costs nothing. + let now = start + std::time::Duration::from_millis(steps * 16); + assert!(!rsc.ui.tick_animations(now)); + } + + /// The sign, pinned across the whole handoff: gesture -> `ScrollArea` -> + /// controller -> anchor. A negative delta is the finger moving the + /// negative way along the axis, which pulls **later** content up into + /// view. Getting this wrong anywhere in that chain scrolls the list + /// backwards, which no type can catch. + #[test] + fn a_negative_delta_moves_toward_the_end() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; + // Start well back from the end so there is room to move forward. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0); + render.update(&root, &mut rsc); + let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); + + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-500.0); + render.update(&root, &mut rsc); + let after = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); + assert!( + after > before, + "a negative delta should move toward the end: {before} -> {after}" + ); + assert!( + (after - before - 500.0).abs() < 0.5, + "and by exactly what was asked for, away from a wall: {before} -> {after}" + ); + } + + /// `Scroll::amt` is the accumulated movement the child actually made, + /// so it stays equal to what is on screen even when a delta runs off + /// the end of the content. This is the whole reason the span reports + /// what it *moved* rather than the caller adding up what it asked + /// for: an all-or-nothing answer would leave `amt` over-counted by + /// every overshoot, and nothing would ever correct it. + #[test] + fn amt_counts_only_what_the_child_could_take() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + // A short move away from the end, all of which is available. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0); + render.update(&root, &mut rsc); + assert!( + (rsc.ui.widgets.get(&scroll).unwrap().amt() + 100.0).abs() < 0.5, + "amt counts forward through the content, so 100px back is -100: {}", + rsc.ui.widgets.get(&scroll).unwrap().amt() + ); + + // 200 rows of 20px in a 600px viewport: 3400px of travel in all, + // so this asks for far more than is left and must be given only + // what there was. + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); + render.update(&root, &mut rsc); + let amt = rsc.ui.widgets.get(&scroll).unwrap().amt(); + assert!( + (amt + 3400.0).abs() < 0.5, + "amt should equal the content's real travel, not what was asked for: {amt}" + ); + } + + /// A fling stops when the child says it could not take the delta, + /// rather than spending its remaining distance on content that is not + /// there. Before the clamp existed, a hard fling to the top of the + /// bench fixture left the first row 1398px below a 600px viewport -- + /// the whole screen blank -- and it stayed there. + #[test] + fn a_fling_stops_at_the_first_row() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; + // An enormous velocity that would travel far past all 200 rows if + // unclamped. + rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0); + let start = Instant::now(); + for step in 0..2000 { + let now = start + std::time::Duration::from_millis(step * 16); + if !fling_frame(&mut rsc, &scroll, &root, &mut render, now) { + break; + } + } + // No settling frame: the draw that runs out of content gives the + // pixels back inside that same frame, so the last frame the loop + // drew is already flush with the top. + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!(list_ref.at_start, "the fling should have reached the start"); + // Both edges, so neither an overshoot past the top nor one left + // uncorrected can pass. `extents` used to hold every row the walk + // placed, on screen or not, so this read was once satisfied by a + // first row sitting 1398px *below* the viewport with the whole + // screen blank. + let first = list_ref.extents[&0]; + assert!( + first.lead.abs() < 0.5, + "a fling stopped at the start must leave the first row flush with the top, not {}px \ + from it", + first.lead + ); + } + + /// Asking for more travel than the content has leaves it *on* its + /// first row rather than beyond it, in the frame that asked. Since the + /// position moved into `ScrollArea`, this is prevented at the source -- + /// the clamp only allows what the walk says is there -- rather + /// than corrected afterwards, and `overscroll_gap` is left for the + /// case a scroll cannot cause: content or a viewport that changed + /// under a settled anchor. + #[test] + fn scrolling_past_the_start_lands_on_it_in_the_same_frame() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (scroll, root, mut render) = build_flingable_list(&mut rsc); + let list_weak = scroll; + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); + render.update(&root, &mut rsc); + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + let first = list_ref.extents[&0]; + assert!( + first.lead.abs() < 0.5, + "the frame that overscrolled should end flush with the first row, not {}px from it", + first.lead, + ); + } + + #[test] + fn anchor_position_display_before_any_draw_is_none() { + let list = LazySpan::new(Dir::DOWN, Pin::End); + assert_eq!(list.anchor_position_display(), "idx=none"); + } + + #[test] + fn anchor_position_display_reports_slot_and_offset() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let (list_weak, root, mut render) = build_flingable_list(&mut rsc); + let _ = (&root, &mut render); + let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); + assert!(list_ref.anchor_position_display().starts_with("idx=")); + assert!(!list_ref.anchor_position_display().contains("none")); + } +} diff --git a/src/widget/position/max_size.rs b/src/widget/position/max_size.rs index 1a9aa39..22c5766 100644 --- a/src/widget/position/max_size.rs +++ b/src/widget/position/max_size.rs @@ -7,42 +7,57 @@ pub struct MaxSize { } impl MaxSize { - fn apply_to_outer(&self, ctx: &mut SizeCtx) { - if let Some(x) = self.x { - ctx.outer.x.select_len(x.apply_rest()); + /// Caps a reported length at `max`, comparing in pixels since `Len`'s + /// rel/abs/rest components are not otherwise comparable. + fn clamp(len: Len, max: Option, output: f32, density: f32) -> Len { + let Some(max) = max else { + return len; + }; + let len_px = len.apply_rest(density).to_abs(output); + let max_px = max.apply_rest(density).to_abs(output); + // `fold_dp`, not the caller's `max` as written: a reported `Len` + // may not carry an unresolved `dp` -- see `Len::fold_dp` for the + // collapsed composer bar this caused. + if len_px > max_px { + max.fold_dp(density) + } else { + len } - if let Some(y) = self.y { - ctx.outer.y.select_len(y.apply_rest()); + } + + /// The span (in this widget's own local, `UiRegion::FULL`-relative + /// terms) to actually offer the child: unconstrained if it already fits + /// within `max`, or a box of exactly `max`, anchored at this axis's + /// start, if it does not. Needed so the child is never painted bigger + /// than the size this widget reports for it -- see the identical + /// requirement noted on `Sized::draw`. + fn clamp_region(offered_px: f32, max: Option, output: f32, density: f32) -> UiSpan { + let Some(max) = max else { + return UiSpan::FULL; + }; + let max_scalar = max.apply_rest(density); + let max_px = max_scalar.to_abs(output); + if offered_px > max_px { + max_scalar.align(AxisAlign::Neg) + } else { + UiSpan::FULL } } } impl Widget for MaxSize { - fn draw(&mut self, painter: &mut Painter) { - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - let width = ctx.width(&self.inner); - if let Some(x) = self.x { - let width_px = width.apply_rest().to_abs(ctx.output_size().x); - let x_px = x.apply_rest().to_abs(ctx.output_size().x); - if width_px > x_px { x } else { width } - } else { - width - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - let height = ctx.height(&self.inner); - if let Some(y) = self.y { - let height_px = height.apply_rest().to_abs(ctx.output_size().y); - let y_px = y.apply_rest().to_abs(ctx.output_size().y); - if height_px > y_px { y } else { height } - } else { - height + fn draw(&mut self, painter: &mut Painter) -> Size { + let output = painter.output_size(); + let density = painter.density(); + let offered = painter.px_size(); + let region = UiRegion { + x: Self::clamp_region(offered.x, self.x, output.x, density), + y: Self::clamp_region(offered.y, self.y, output.y, density), + }; + let used = painter.widget_within(&self.inner, region); + Size { + x: Self::clamp(used.x, self.x, output.x, density), + y: Self::clamp(used.y, self.y, output.y, density), } } } diff --git a/src/widget/position/mod.rs b/src/widget/position/mod.rs index c389b6c..d9c1499 100644 --- a/src/widget/position/mod.rs +++ b/src/widget/position/mod.rs @@ -1,19 +1,23 @@ mod align; mod layer; +mod lazy_span; mod max_size; mod offset; mod pad; -mod scroll; +mod scroll_area; +mod scrollable; mod sized; mod span; mod stack; pub use align::*; pub use layer::*; +pub use lazy_span::*; pub use max_size::*; pub use offset::*; pub use pad::*; -pub use scroll::*; +pub use scroll_area::*; +pub use scrollable::*; pub use sized::*; pub use span::*; pub use stack::*; diff --git a/src/widget/position/offset.rs b/src/widget/position/offset.rs index da54f69..c1df8d1 100644 --- a/src/widget/position/offset.rs +++ b/src/widget/position/offset.rs @@ -6,16 +6,8 @@ pub struct Offset { } impl Widget for Offset { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { let region = UiRegion::FULL.offset(self.amt); - painter.widget_within(&self.inner, region); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) + painter.widget_within(&self.inner, region) } } diff --git a/src/widget/position/pad.rs b/src/widget/position/pad.rs index 5619d0f..8e2b3ec 100644 --- a/src/widget/position/pad.rs +++ b/src/widget/position/pad.rs @@ -6,48 +6,43 @@ pub struct Pad { } impl Widget for Pad { - fn draw(&mut self, painter: &mut Painter) { - painter.widget_within(&self.inner, self.padding.region()); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - let width = self.padding.left + self.padding.right; - let height = self.padding.top + self.padding.bottom; - ctx.outer.x.abs -= width; - ctx.outer.y.abs -= height; - let mut size = ctx.width(&self.inner); - size.abs += width; - size - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - let width = self.padding.left + self.padding.right; - let height = self.padding.top + self.padding.bottom; - ctx.outer.x.abs -= width; - ctx.outer.y.abs -= height; - let mut size = ctx.height(&self.inner); - size.abs += height; - size + fn draw(&mut self, painter: &mut Painter) -> Size { + let density = painter.density(); + let used = painter.widget_within(&self.inner, self.padding.region(density)); + let width = + self.padding.left.apply_rest(density).abs + self.padding.right.apply_rest(density).abs; + let height = + self.padding.top.apply_rest(density).abs + self.padding.bottom.apply_rest(density).abs; + Size { + x: used.x + Len::abs(width), + y: used.y + Len::abs(height), + } } } +/// Each side is a `Len`, not a bare `f32`, so `.pad(dp(10))` resolves +/// against the display's density the same way any other size does -- see +/// `Len::dp`'s field doc. `.pad(10)` (a bare number) still works via +/// `From` below, unchanged: it becomes an `abs` (physical-pixel) +/// `Len`, exactly as a bare number always has meant elsewhere in this +/// crate. pub struct Padding { - pub left: f32, - pub right: f32, - pub top: f32, - pub bottom: f32, + pub left: Len, + pub right: Len, + pub top: Len, + pub bottom: Len, } impl Padding { pub const ZERO: Self = Self { - left: 0.0, - right: 0.0, - top: 0.0, - bottom: 0.0, + left: Len::ZERO, + right: Len::ZERO, + top: Len::ZERO, + bottom: Len::ZERO, }; - pub fn uniform(amt: impl UiNum) -> Self { - let amt = amt.to_f32(); + pub fn uniform(amt: impl Into) -> Self { + let amt = amt.into(); Self { left: amt, right: amt, @@ -55,80 +50,84 @@ impl Padding { bottom: amt, } } - pub fn region(&self) -> UiRegion { + pub fn region(&self, density: f32) -> UiRegion { let mut region = UiRegion::FULL; - region.x.start.abs += self.left; - region.y.start.abs += self.top; - region.x.end.abs -= self.right; - region.y.end.abs -= self.bottom; + region.x.start.abs += self.left.apply_rest(density).abs; + region.y.start.abs += self.top.apply_rest(density).abs; + region.x.end.abs -= self.right.apply_rest(density).abs; + region.y.end.abs -= self.bottom.apply_rest(density).abs; region } - pub fn x(amt: impl UiNum) -> Self { - let amt = amt.to_f32(); + pub fn x(amt: impl Into) -> Self { + let amt = amt.into(); Self { left: amt, right: amt, - top: 0.0, - bottom: 0.0, + top: Len::ZERO, + bottom: Len::ZERO, } } - pub fn y(amt: impl UiNum) -> Self { - let amt = amt.to_f32(); + pub fn y(amt: impl Into) -> Self { + let amt = amt.into(); Self { - left: 0.0, - right: 0.0, + left: Len::ZERO, + right: Len::ZERO, top: amt, bottom: amt, } } - pub fn top(amt: impl UiNum) -> Self { + pub fn top(amt: impl Into) -> Self { let mut s = Self::ZERO; - s.top = amt.to_f32(); + s.top = amt.into(); s } - pub fn bottom(amt: impl UiNum) -> Self { + pub fn bottom(amt: impl Into) -> Self { let mut s = Self::ZERO; - s.bottom = amt.to_f32(); + s.bottom = amt.into(); s } - pub fn left(amt: impl UiNum) -> Self { + pub fn left(amt: impl Into) -> Self { let mut s = Self::ZERO; - s.left = amt.to_f32(); + s.left = amt.into(); s } - pub fn right(amt: impl UiNum) -> Self { + pub fn right(amt: impl Into) -> Self { let mut s = Self::ZERO; - s.right = amt.to_f32(); + s.right = amt.into(); s } - pub fn with_top(mut self, amt: impl UiNum) -> Self { - self.top = amt.to_f32(); + pub fn with_top(mut self, amt: impl Into) -> Self { + self.top = amt.into(); self } - pub fn with_bottom(mut self, amt: impl UiNum) -> Self { - self.bottom = amt.to_f32(); + pub fn with_bottom(mut self, amt: impl Into) -> Self { + self.bottom = amt.into(); self } - pub fn with_left(mut self, amt: impl UiNum) -> Self { - self.left = amt.to_f32(); + pub fn with_left(mut self, amt: impl Into) -> Self { + self.left = amt.into(); self } - pub fn with_right(mut self, amt: impl UiNum) -> Self { - self.right = amt.to_f32(); + pub fn with_right(mut self, amt: impl Into) -> Self { + self.right = amt.into(); self } } -impl From for Padding { +/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From` +/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly +/// (`.pad(dp(10))`) with the one impl, since `Len: Into` is the +/// reflexive case of the same bound. +impl> From for Padding { fn from(amt: T) -> Self { - Self::uniform(amt.to_f32()) + Self::uniform(amt.into()) } } diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs deleted file mode 100644 index c789acc..0000000 --- a/src/widget/position/scroll.rs +++ /dev/null @@ -1,66 +0,0 @@ -use crate::prelude::*; - -pub struct Scroll { - inner: StrongWidget, - axis: Axis, - amt: f32, - snap_end: bool, - container_len: f32, - content_len: f32, -} - -impl Widget for Scroll { - fn draw(&mut self, painter: &mut Painter) { - let output_len = painter.output_size().axis(self.axis); - let container_len = painter.region().axis(self.axis).len(); - let content_len = painter - .len_axis(&self.inner, self.axis) - .apply_rest() - .within_len(container_len) - .to_abs(output_len); - self.container_len = container_len.to_abs(output_len); - self.content_len = content_len; - - if self.snap_end { - self.amt = self.content_len - self.container_len; - } - self.update_amt(); - - let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, -self.amt, 0.0)); - region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len); - painter.widget_within(&self.inner, region); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.width(&self.inner) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - ctx.height(&self.inner) - } -} - -impl Scroll { - pub fn new(inner: StrongWidget, axis: Axis) -> Self { - Self { - inner, - axis, - amt: 0.0, - snap_end: true, - container_len: 0.0, - content_len: 0.0, - } - } - - pub fn update_amt(&mut self) { - self.amt = self.amt.max(0.0); - let len = (self.content_len - self.container_len).max(0.0); - self.amt = self.amt.min(len); - self.snap_end = self.amt == len; - } - - pub fn scroll(&mut self, amt: f32) { - self.amt -= amt; - self.update_amt(); - } -} diff --git a/src/widget/position/scroll_area.rs b/src/widget/position/scroll_area.rs new file mode 100644 index 0000000..42fbf2e --- /dev/null +++ b/src/widget/position/scroll_area.rs @@ -0,0 +1,565 @@ +//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`]. +//! +//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what +//! `amt` means, and how this differs from a `LazySpan`, which scrolls +//! itself. Read it first; this file is the detail. + +use crate::prelude::*; +use std::time::Instant; + +/// A scrolling view over a child that is a fixed lump: it is measured +/// whole and then moved, which is what makes a scroll tick an O(1) move of +/// one subtree rather than a redraw. +/// +/// **"Area" because it only scrolls a predefined one** (Iris, 2026-09-08): +/// a child that lays out lazily cannot be measured whole or moved as a +/// lump, and virtualising it inside one of these would never update which +/// rows it shows, since a scroll tick offers a same-size moved region and +/// `draw_inner` never re-enters the child. That case is `LazySpan`, which +/// owns a controller of its own instead of being wrapped in one of these. +pub struct ScrollArea { + inner: StrongWidget, + /// The position, the gesture, the fling and the pin -- everything + /// about scrolling that is not this widget's own layout, shared with + /// `LazySpan` rather than reimplemented beside it. + ctl: ScrollController, + container_len: f32, + /// How long the content is along the axis, as of the last draw -- + /// `None` until this widget has drawn once. + /// + /// An `Option` rather than a `0.0` that stands in for both, because + /// the two answers led somewhere different and the code could not tell + /// them apart: on the first frame the clamp computed a scroll range of + /// zero, concluded from `amt == range` that the area was sitting at + /// its end, and pinned it -- so the next frame, now knowing the real + /// length, jumped to it. A code fence therefore opened at the end of + /// its longest line, mid-word (`iris/run-headless.sh phone`, + /// 2026-09-08). + content_len: Option, +} + +impl Scrollable for ScrollArea { + fn controller(&self) -> &ScrollController { + &self.ctl + } + + fn controller_mut(&mut self) -> &mut ScrollController { + &mut self.ctl + } +} + +impl Widget for ScrollArea { + /// A scroll area animates exactly one thing, its fling. The + /// registration that makes this run is `UiData::animate`, which + /// `WidgetLike::scrollable`'s own drag handler calls the frame a + /// release starts one. + fn tick(&mut self, now: Instant) -> bool { + self.tick_fling(now) + } + + /// Measure, then place -- the same idiom `LazySpan` uses, for the same + /// reason: nothing drawn may depend on a length measured last frame. + /// + /// **The child is drawn twice, and only the second decides anything.** + /// The first is handed last frame's length as a *hint*, and it exists + /// only so that the usual case, where the content's length did not + /// change, offers the same region twice: `draw_inner` then makes the + /// first call an O(1) `mov` and returns at the first line of the + /// second. A frame on which the content did grow or shrink pays one + /// real extra draw, and that is a frame on which the content was being + /// redrawn anyway. + /// + /// The alternative -- place against the hint and let the next frame + /// fix it -- is what Iris found on her phone (2026-09-08): every + /// newline typed into the composer drew the field in a box one line + /// short of its text, and since that text is centred in its box it + /// hung half a line past each end. There was no next frame: nothing + /// dirtied that subtree again, so the stale placement was the last one + /// drawn, until the keyboard closed and its inset rewrite forced a + /// redraw ("it fixes itself"). **Layout is a pure function of the + /// state, not of how many frames have been drawn** (Iris, 2026-09-08) + /// -- a correction that needs a second frame is a frame drawn wrong. + fn draw(&mut self, painter: &mut Painter) -> Size { + // Every length here is resolved against the box this widget was + // **offered** (`px_size`), never `output_size`: a scroll area is + // routinely smaller than the window -- the composer's field is + // capped at six lines by a `MaxSize` around it -- and measuring + // the window instead would make the pan range, and so where the + // content sits, a function of the screen rather than of the box. + let axis = self.ctl.axis(); + let container_len = painter.px_size().axis(axis); + self.container_len = container_len; + // Learned from the frame rather than passed in: a fling's + // deceleration is a physical quantity and needs the real display + // density, and `draw` is where this widget meets the only thing + // that knows it. + self.ctl.set_density(painter.density()); + + // Where the delta asked for since the last frame puts the content. + // Already inside the range the previous frame published, so it is + // the position to *measure* against; the clamp below is what the + // length just measured has to say about it. + let delta = self.ctl.take_delta(); + let travelled = self.ctl.amt() - delta; + self.ctl.set_amt(travelled); + + // The container's own length stands in as the hint until anything + // has been measured: a zero-length region on the first frame would + // place the child's primitives against a box of no size. + let hint = self.content_len.unwrap_or(container_len); + let used = painter.widget_within(&self.inner, self.child_region(hint)); + + // A child reporting `rel` means "this fraction of what I was + // offered", and what it was offered is this scroll area -- so the + // container, again, is what that resolves against. + let measured = used + .axis(axis) + .apply_rest(painter.density()) + .to_abs(container_len); + self.content_len = Some(measured); + let range = (measured - container_len).max(0.0); + + // The end-pin, and then the clamp, against the length just + // measured. Deliberately not also run before the measuring draw + // above -- clamping against the hint would let a stale length + // reduce `amt` in a way this pass cannot undo, and then where the + // content sits would depend on the previous frame after all. + // + // Only a frame with no delta of its own re-pins: the pin means + // "stay flush with the end as the content grows", and a reader who + // just scrolled away from that end has said otherwise. (A delta + // cannot be moving *toward* the end here -- the travel published + // below is zero that way while pinned, so `take_delta` has already + // clipped it.) + let amt = if self.ctl.pinned_to_end() && delta == 0.0 { + range + } else { + travelled.clamp(0.0, range) + }; + self.ctl.set_amt(amt); + self.ctl.set_pinned_to_end(amt >= range); + self.ctl.set_travel(Travel { + back: amt, + fwd: range - amt, + }); + + // The **content's** size, not the container's. A parent that can + // grow (the composer's bar) should hug the text until its own cap + // stops it, and reporting the container instead would make this + // widget's answer a function of the answer -- the bar is sized + // from what is reported here, so it collapses to nothing and never + // recovers. What keeps the content inside the offered box is the + // mask a caller puts around it (`.scrollable(..).masked()`), not + // this number. + painter.widget_within(&self.inner, self.child_region(measured)) + } +} + +impl ScrollArea { + /// `pin` says which end this area opens at and clings to -- see + /// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is + /// normally built. + pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self { + Self { + inner, + // A fixed child is laid out from the box's negative edge + // onward, always, so the end of its content is the positive + // one -- which is what makes `Pin::End` and `Pin::Pos` the + // same pin here and different ones in a reversed `LazySpan`. + ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin), + container_len: 0.0, + content_len: None, + } + } + + /// Where the child sits for a given content length: a box that long + /// along the scroll axis, pulled back by `amt`. The length is taken as + /// a parameter rather than read from `content_len`, because `draw` + /// places twice -- once against last frame's length and once against + /// the one it has just measured -- and the two must be the same + /// arithmetic. + fn child_region(&self, content_len: f32) -> UiRegion { + let axis = self.ctl.axis(); + let mut region = UiRegion::FULL; + region.axis_mut(axis).end = region.axis(axis).start.offset(content_len); + region.offset(Vec2::from_axis(axis, -self.ctl.amt(), 0.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::layout_tests::TestRsc; + use crate::sense::{CursorButton, DRAG_SLOP, PointerRequests}; + use iris_core::UiData; + use std::time::Duration; + + /// A scroll area with 1000px of content in a 100px box, drawn once and + /// settled somewhere in the middle so a drag has room in both + /// directions. + /// + /// Built and rendered for real rather than assembled field by field, + /// because a delta is spent in `draw` now (the controller banks it, and + /// only a layout knows where the content ends) -- so a test that never + /// draws would watch `amt` never move and read that as a broken + /// gesture. + fn area() -> (Fixture, WidgetId) { + area_on(Axis::Y) + } + + /// The same fixture on either axis -- a code fence pans sideways + /// through one of these exactly as a field pans down, and the pair of + /// them is what caught a fling that only worked vertically. + fn area_on(axis: Axis) -> (Fixture, WidgetId) { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); + let id = fill.id(); + let long = Some(Len::abs(1000.0)); + let tall = rsc.ui.widgets.add_strong(Sized { + inner: fill, + x: (axis == Axis::X).then_some(long).flatten(), + y: (axis == Axis::Y).then_some(long).flatten(), + }); + let area = rsc + .ui + .widgets + .add_strong(ScrollArea::new(tall.any(), axis, Pin::Start)); + let weak = area.weak(); + let root = area.any(); + let mut render = UiRenderState::new(); + render.resize((100.0, 100.0)); + render.update(&root, &mut rsc); + + let mut fixture = Fixture { + rsc, + area: weak, + root, + render, + }; + // 400px in, which is the middle of the 900px of travel this + // content has. + fixture.get().scroll(-400.0); + fixture.draw(); + assert!((fixture.amt() - 400.0).abs() < 0.01); + (fixture, id) + } + + /// The area under test with everything needed to draw it -- the drag + /// tests all do the same three things (reach the widget, draw, read + /// `amt`) and each of the three is a line of arena plumbing. + struct Fixture { + rsc: TestRsc, + area: WeakWidget, + root: StrongWidget, + render: UiRenderState, + } + + impl Fixture { + fn get(&mut self) -> &mut ScrollArea { + self.rsc.ui.widgets.get_mut(&self.area).unwrap() + } + + fn draw(&mut self) { + self.render.update(&self.root, &mut self.rsc); + } + + fn amt(&self) -> f32 { + self.rsc.ui.widgets.get(&self.area).unwrap().amt() + } + + /// One frame of a fling, the way `UiData::tick_animations` drives + /// it: tick, then draw. Answers whether it is still going. + fn fling_frame(&mut self, now: Instant) -> bool { + let still = self.get().tick(now); + self.draw(); + still + } + } + + /// One frame of a touch gesture, followed by the draw that spends it. + fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) { + drag(f, id, sense, Vec2::new(0.0, y), t); + } + + /// The same, for a gesture whose position is not on the Y axis. + fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) { + let pointer = PointerRequests::default(); + let flung = f.get().drag(&pointer, id, sense, pos, t); + // What `WidgetLike::scrollable`'s own handler does with the + // answer, and the half a fling does not move without. + if flung { + let id = f.area.id(); + f.rsc.ui.animate(id); + } + f.draw(); + } + + #[test] + fn a_vertical_finger_drag_pans_the_content_with_the_finger() { + let (mut f, id) = area(); + let t = Instant::now(); + press( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + 0.0, + t, + ); + // Finger down by well past the slop: the content follows it down, + // which for this widget means *less* `amt`. + press( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + DRAG_SLOP + 30.0, + t + Duration::from_millis(20), + ); + assert!( + (f.amt() - 370.0).abs() < 0.01, + "expected the 30px past the slop to be applied downward, got amt={}", + f.amt() + ); + // ...and the next frame's motion is a plain per-frame delta. + press( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + DRAG_SLOP + 50.0, + t + Duration::from_millis(40), + ); + assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt()); + } + + /// The half the change had no reason to touch: a press that never + /// leaves the slop is a tap, and must move nothing at all -- otherwise + /// every tap on a scrollable field nudges its text. + #[test] + fn a_press_that_stays_inside_the_slop_does_not_scroll() { + let (mut f, id) = area(); + let t = Instant::now(); + press( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + 0.0, + t, + ); + for (i, y) in [1.0, -2.0, DRAG_SLOP - 0.5].into_iter().enumerate() { + press( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + y, + t + Duration::from_millis(10 * (i as u64 + 1)), + ); + } + press( + &mut f, + id, + CursorSense::PressEnd(CursorButton::Left), + DRAG_SLOP - 0.5, + t + Duration::from_millis(50), + ); + assert!( + (f.amt() - 400.0).abs() < 0.01, + "a tap scrolled: amt={}", + f.amt() + ); + } + + /// A horizontal drag is not this widget's gesture: it must stay put + /// rather than pick up the vertical noise in a sideways swipe. + #[test] + fn a_horizontal_drag_does_not_scroll() { + let (mut f, id) = area(); + let t = Instant::now(); + drag( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::new(0.0, 0.0), + t, + ); + drag( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(120.0, 3.0), + t + Duration::from_millis(20), + ); + assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt()); + } + + /// Panning stops at the ends of the content rather than running off, + /// which is `update_amt`'s clamp -- checked through `drag` so the two + /// cannot drift apart. + #[test] + fn a_pan_past_the_end_clamps_instead_of_running_off() { + let (mut f, id) = area(); + let t = Instant::now(); + drag( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::new(0.0, 0.0), + t, + ); + drag( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 5000.0), + t + Duration::from_millis(20), + ); + assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt()); + } + + /// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll + /// areas. Flinging should be enabled by default in all scroll areas + /// on android to match composes behavior." A release with real + /// velocity coasts, decelerating, and settles on its own. + #[test] + fn a_released_pan_flings_and_settles() { + for axis in [Axis::X, Axis::Y] { + let (mut f, id) = area_on(axis); + let t = Instant::now(); + let at = |d: f32| Vec2::from_axis(axis, d, 0.0); + + drag( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + at(0.0), + t, + ); + // Four samples 8ms apart, accelerating away from the start -- + // three is the fewest `VelocityTracker`'s quadratic fit can + // use, so this is a gesture that genuinely has a velocity. + for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() { + drag( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + at(d), + t + Duration::from_millis(8 * (i as u64 + 1)), + ); + } + let at_release = f.amt(); + drag( + &mut f, + id, + CursorSense::PressEnd(CursorButton::Left), + at(-280.0), + t + Duration::from_millis(32), + ); + assert!( + f.get().is_scrolling(), + "{axis:?}: a released pan with velocity must fling" + ); + + // Frames at 8ms until it stops, with each step no longer than + // the one before it -- a coast that does not decelerate is + // the linear-spline bug this crate has had once already. + let mut last_step = f32::INFINITY; + let mut ticks = 0; + let mut now = t + Duration::from_millis(32); + while f.fling_frame(now) { + let before = f.amt(); + now += Duration::from_millis(8); + f.fling_frame(now); + let step = (f.amt() - before).abs(); + assert!( + step <= last_step + 0.01, + "{axis:?}: the fling sped up: {last_step} then {step}" + ); + last_step = step; + ticks += 1; + assert!(ticks < 10_000, "{axis:?}: the fling never settled"); + } + assert!( + f.amt() > at_release, + "{axis:?}: the fling moved the content the wrong way: {at_release} -> {}", + f.amt() + ); + } + } + + /// The wall: a fling must not spend its remaining distance on content + /// that is not there. Released hard toward the start, it settles + /// exactly on it. + #[test] + fn a_fling_stops_at_the_end_of_the_content() { + // Both walls. A positive delta is applied as `amt -= delta`, so a + // positive velocity runs toward the start of the content and a + // negative one toward its end; 1000px of content in a 100px box + // leaves `amt` in 0..=900. + for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] { + let (mut f, _id) = area(); + f.get().fling(velocity); + let t = Instant::now(); + let mut now = t; + for _ in 0..1_000 { + if !f.fling_frame(now) { + break; + } + now += Duration::from_millis(8); + } + assert!( + !f.get().is_scrolling(), + "the fling toward {wall} ran past the content" + ); + assert!( + (f.amt() - wall).abs() < 0.01, + "it should have settled on {wall}, got amt={}", + f.amt() + ); + } + } + + /// A finger on coasting content stops it there, from the first + /// sample, with no `DRAG_SLOP` to wait out -- the catch + /// `DragArbiter::press_start` describes, which a scroll area needs + /// for the same reason a list does now that it can coast at all. + #[test] + fn a_press_on_a_coasting_area_catches_it() { + let (mut f, id) = area(); + f.get().fling(-4_000.0); + let t = Instant::now(); + f.fling_frame(t); + f.fling_frame(t + Duration::from_millis(8)); + let caught_at = f.amt(); + assert!(f.get().is_scrolling(), "the fixture must still be moving"); + + let down = t + Duration::from_millis(16); + drag( + &mut f, + id, + CursorSense::PressStart(CursorButton::Left), + Vec2::new(0.0, 0.0), + down, + ); + assert!(!f.get().is_scrolling(), "a touch-down must end the fling"); + assert!( + (f.amt() - caught_at).abs() < 0.01, + "the down itself must not move the content, only stop it" + ); + + // A move well under `DRAG_SLOP` still tracks the finger, because + // this press caught something that was moving. + drag( + &mut f, + id, + CursorSense::Pressing(CursorButton::Left), + Vec2::new(0.0, 2.0), + down + Duration::from_millis(8), + ); + assert!( + (f.amt() - (caught_at - 2.0)).abs() < 0.01, + "a caught press must pan from its first sample: {} -> {}", + caught_at, + f.amt() + ); + } +} diff --git a/src/widget/position/scrollable.rs b/src/widget/position/scrollable.rs new file mode 100644 index 0000000..5d0fac9 --- /dev/null +++ b/src/widget/position/scrollable.rs @@ -0,0 +1,524 @@ +//! The scrolling capability: one `ScrollController` holding everything a +//! scroll position is made of, and a `Scrollable` trait for the widgets +//! that own one. +//! +//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what +//! `amt` means, and which widgets scroll. Read it first; this file is the +//! detail. +//! +//! Two widgets scroll in iris and they scroll differently: a +//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump, +//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an +//! anchor and cannot be slid at all. What they share is everything that is +//! *not* the layout -- the gesture, the fling, the pin, the position and +//! the account of how far it can still go -- so that lives here, in a +//! plain struct each of them contains, rather than in a protocol between +//! them (Iris, 2026-09-08: "what about adding a scroll controller that +//! both scroll and lazy span contain"). +//! +//! The contract with the owner is two calls, both in its `draw`: +//! +//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling +//! asked for since the last layout, already clamped to the travel the +//! owner last reported. +//! 2. [`ScrollController::set_travel`], plus whichever of +//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits +//! how that owner knows where it ended up -- movement for a layout with +//! no fixed origin, an absolute position for one that has. +//! +//! Everything between the two is the owner's own layout, and everything +//! outside them is the same for both. + +use crate::prelude::*; +use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState}; +use std::time::Instant; + +/// Which end of its content a scroll area clings to as that content +/// grows, said either way round -- an enum rather than the `at_end: bool` +/// this used to be, because the flag sat at the end of two constructors +/// and `scrollable(axis, true)` says nothing at the call site about which +/// end `true` is. +/// +/// **Two pairs, because there are two questions and they are not the same +/// one** (Iris, 2026-09-08: "that way you can select the pin based on the +/// axis's sign rather than the direction, so for example you can assure +/// it's always pinned to the bottom"): +/// +/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row +/// or the newest one, wherever the layout happens to put it. A +/// transcript wants `End` -- the newest message -- and does not care +/// which edge of the screen that is. +/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left +/// edge, and the bottom or right one, whichever end of the content sits +/// there. What to reach for when the *screen* position is the +/// requirement. +/// +/// The two coincide for content laid out along the positive axis, which is +/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) -- +/// where they are exact opposites, which is the whole reason both exist. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub enum Pin { + /// The start of the content: item 0, wherever it is drawn. + Start, + /// The end of the content: the newest item, wherever it is drawn. + End, + /// The top or left edge of the box, whichever end of the content is + /// there. + Neg, + /// The bottom or right edge of the box, whichever end of the content + /// is there. + Pos, +} + +impl Pin { + /// Resolve to the one question a scrollable actually acts on: does + /// content appended to the end bring the view with it? `dir` is the + /// way this owner's content runs, which is the only thing that tells + /// the axis-absolute pair from the content-relative one. + fn pinned_to_end(self, dir: Dir) -> bool { + match self { + Pin::Start => false, + Pin::End => true, + Pin::Neg => dir.sign == Sign::Neg, + Pin::Pos => dir.sign == Sign::Pos, + } + } +} + +/// How far a scrollable can still travel from where it is, as of its last +/// layout, in the same screen-space units a delta is in. +/// +/// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely +/// does not know how much content lies past the rows it has walked, and +/// saying "infinity" is the honest answer that `clamp` also happens to +/// take with no branch. The wall is then found by the walk, which is why +/// the owner reports what it *did* as well as what it can do. +#[derive(Clone, Copy, Debug)] +pub struct Travel { + /// The bound on a **positive** delta -- scrolling up or left, back + /// toward the start of the content. + pub back: f32, + /// The bound on a **negative** delta -- scrolling down or right, + /// onward toward the end of the content. Positive itself: it is a + /// distance, and the sign it bounds is the caller's. + pub fwd: f32, +} + +impl Travel { + /// Nothing known yet, so nothing is bounded -- what a scrollable + /// starts with and what it reports for an axis whose content it has + /// not measured. + pub const UNBOUNDED: Self = Self { + back: f32::INFINITY, + fwd: f32::INFINITY, + }; + + /// The bound on a delta of this sign, as a positive distance. + fn toward(&self, delta: f32) -> f32 { + if delta >= 0.0 { self.back } else { self.fwd } + } +} + +/// The state a scroll position is made of, owned by the widget that +/// scrolls: where it is, what it was asked to do next, how far it can go, +/// which end it clings to, and the gesture and fling that drive it. +/// +/// See the module doc for the two-call contract with its owner, and +/// [`Scrollable`] for the trait that reaches one. +pub struct ScrollController { + /// Which way this area's content runs: the axis it pans along, and the + /// sign the content grows in. A plain `ScrollArea` always grows the + /// positive way; a `LazySpan` passes its own `dir`, which is what + /// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`]. + dir: Dir, + /// Where this area has got to, counting **forward through the + /// content**: 0 at the start, growing as the reader moves on. The + /// opposite sign to a delta, which counts the way the finger moves. + /// + /// For a `ScrollArea` it is a position, clamped into the content's + /// real length. For a `LazySpan` it is **movement, not position** -- + /// paging rows in above moves the origin and the span cannot say by + /// how much, never having measured them -- so the direction is + /// comparable between the two and the absolute value is not. + amt: f32, + /// Asked for but not yet laid out: how far a wheel, a drag or a fling + /// has moved this area since the last draw. Taken and cleared by + /// [`Self::take_delta`], which is the only place it is spent, because + /// the owner's `draw` is the only place the walls are known. + pending: f32, + /// What the owner's last layout said was left, and what `take_delta` + /// clamps against. + travel: Travel, + /// Whether this area is currently flush against the end of its + /// content, so that content appended to it should bring the view + /// along. Set from [`Pin`] at construction and recomputed by the owner + /// at the end of every layout -- it is live state, not a preference: a + /// reader who scrolls away from the end stops being pinned to it, and + /// scrolling back re-pins. + pinned_to_end: bool, + /// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all + /// live in `sense.rs`; only what a committed pan *means* is decided + /// here. See [`Self::drag`]. + gesture: DragGesture, + /// The momentum a release leaves behind. Every scroll area flings, on + /// either axis and with nothing to opt into -- Compose's `scrollable` + /// attaches `ScrollableDefaults.flingBehavior()` on every axis it is + /// given, and Iris asked for the same (2026-09-08: "flinging should be + /// enabled by default in all scroll areas on android to match composes + /// behavior"). + fling: Flinger, + /// Physical pixels per dp, copied from the painter on every draw -- + /// what a fling's deceleration is computed against. 1.0 until the + /// owner has drawn once, which is also the only state in which nothing + /// can be flung, since there is no content measured yet. + density: f32, +} + +impl ScrollController { + pub fn new(dir: Dir, pin: Pin) -> Self { + Self { + dir, + amt: 0.0, + pending: 0.0, + travel: Travel::UNBOUNDED, + pinned_to_end: pin.pinned_to_end(dir), + gesture: DragGesture::on(dir.axis), + fling: Flinger::new(), + density: 1.0, + } + } + + /// Which way this area pans. + pub fn axis(&self) -> Axis { + self.dir.axis + } + + /// Which way this area's content runs -- the axis it pans along and + /// the sign it grows in. What resolves a [`Pin`]. + pub fn dir(&self) -> Dir { + self.dir + } + + /// How far the content has been pulled past the container's leading + /// edge -- see the field for what that means for each kind of owner. + pub fn amt(&self) -> f32 { + self.amt + } + + /// Pan by `amt`, in the finger's direction: **positive scrolls up or + /// left**, moving the content the positive way along the axis. One + /// convention, everywhere, and a screen direction rather than a + /// logical one so that it means the same thing to a widget laid out + /// backwards (Iris, 2026-09-08). + /// + /// Banked rather than applied: where this area can actually go is a + /// question only its owner's layout can answer, and the owner's `draw` + /// is where that answer exists. + pub fn scroll(&mut self, amt: f32) { + self.pending += amt; + } + + /// What has been asked for since the last layout, clamped to the + /// travel that layout reported. Called once at the top of the owner's + /// `draw`. + /// + /// **Clipping it stops a fling**, because a fling that keeps spending + /// its distance on content that is not there is what left a hard flick + /// parked a whole screen past the first row of the bench fixture + /// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner + /// could already see; [`Self::set_travel`] catches the one it finds by + /// walking. + pub fn take_delta(&mut self) -> f32 { + let asked = std::mem::take(&mut self.pending); + let limit = self.travel.toward(asked); + let taken = asked.clamp(-limit, limit); + if taken != asked { + self.fling.stop(); + } + taken + } + + /// Record content this area really moved, and by how much, in a + /// delta's own sign. For an owner that cannot state an absolute + /// position -- a lazy layout, whose origin moves as rows are paged in + /// above it. + pub fn moved_by(&mut self, delta: f32) { + self.amt -= delta; + } + + /// Set where this area is outright, for an owner that knows: a + /// `ScrollArea` has measured its content and clamps against its real + /// length, and a jump to an end is a position rather than travel. + pub fn set_amt(&mut self, amt: f32) { + self.amt = amt; + } + + /// Publish how far this area can still go, from the layout that just + /// ran. Stops a fling with nothing left in the direction it is + /// travelling -- the wall a lazy layout only finds by walking to it, + /// reported in the same frame that found it. + pub fn set_travel(&mut self, travel: Travel) { + self.travel = travel; + if let Some(v) = self.fling.velocity() + && travel.toward(v) <= 0.0 + { + self.fling.stop(); + } + } + + /// What the last layout said was left. Read by an owner that has to + /// reconcile its own walls with what it was allowed to take. + pub fn travel(&self) -> Travel { + self.travel + } + + /// Whether this area is flush against the end of its content, so that + /// an appended row should bring the view with it. The owner recomputes + /// this at the end of every layout; a caller may set it to re-pin (a + /// "jump to latest" button) or to let go. + pub fn pinned_to_end(&self) -> bool { + self.pinned_to_end + } + + pub fn set_pinned_to_end(&mut self, pinned: bool) { + self.pinned_to_end = pinned; + } + + /// Physical pixels per dp, which a fling's deceleration is computed + /// against. Learned from the frame rather than passed in: it is a + /// physical quantity, and the owner's `draw` is where it meets the + /// only thing that knows it. + pub fn set_density(&mut self, density: f32) { + self.density = density; + } + + /// Start a fling at `velocity`, in [`Self::scroll`]'s direction + /// convention. Answers whether one actually started, which is the + /// caller's cue to register the widget for frames (`UiData::animate`). + /// Cancels any fling already in progress. + /// + /// **Sets the fling; it does not drive it.** A fling moves only while + /// something calls [`Self::tick`] once per frame, and what does that + /// in a running app is `UiData::tick_animations`, over the ids + /// `UiData::animate` was given. Split that way because the two halves + /// have different owners: the velocity is this area's business and + /// whether anything animates at all is the frame loop's. Missing the + /// second call is what a finger fling did on Iris's phone for two + /// builds -- the velocity was right and nothing ever advanced it, + /// which looks exactly like a list that stops dead under the finger. + /// + /// The density handed on is this area's own, taken from the painter, + /// not `1.0`: it does **not** cancel out of the spline, and a + /// hardcoded 1.0 against a 2.75-density screen made a flick that + /// should coast for about a second run for 45. + pub fn fling(&mut self, velocity: f32) -> bool { + self.fling.start(velocity, self.density) + } + + /// Cancel any fling in progress with no further movement -- the next + /// touch-down's job, since `AndroidFlingSpline`'s curve has no idea a + /// finger came back down and Android's own `Scroller` relies on the + /// view calling `abortAnimation` for the same reason. + pub fn cancel_fling(&mut self) { + self.fling.stop(); + } + + /// Whether a fling is coasting here right now. What a caller polls to + /// know whether this area is moving on its own (a test, and + /// [`PressState::scrolling`]'s own condition). + pub fn is_scrolling(&self) -> bool { + self.fling.is_flinging() + } + + /// The velocity a fling in progress is coasting at, `None` when + /// nothing is flinging -- what a release's decision looks like from + /// the outside, so a test can read what the gesture measured rather + /// than re-timing the gesture itself. + pub fn fling_velocity(&self) -> Option { + self.fling.velocity() + } + + /// Advance a fling by one frame, banking the distance it covered. + /// Answers whether it is still going, which is what + /// `UiData::tick_animations` reads to decide whether to keep the + /// widget registered -- so an owner's `Widget::tick` is this one line. + /// + /// Stopping at a wall is [`Self::take_delta`]'s and + /// [`Self::set_travel`]'s, not this method's: both know where the + /// content ends and this one does not. + pub fn tick(&mut self, now: Instant) -> bool { + let delta = self.fling.tick(now); + self.scroll(delta); + self.fling.is_flinging() + } + + /// Feed one frame of a touch gesture over this area through. + /// Registered by `WidgetLike::scrollable`; a caller with an arbiter of + /// its own drives `DragGesture` itself and hands the committed pans + /// here instead (`transcript_ui::Selection`). + /// + /// `id` is the owning widget's id, which `DragGesture` takes pointer + /// capture on once the gesture commits -- so the rest of the drag + /// reaches here even after the finger has left the area, and, just as + /// importantly, stops reaching whatever is *inside* it. That is what + /// resolves a vertical drag over a focused text field: the field sees + /// the first few frames, `iris::attr`'s `on_press` gives up its + /// pending selection the moment they pass `DRAG_SLOP` vertically, and + /// this takes the gesture over. Android's own `EditText` behaves the + /// same way -- a vertical drag scrolls, and only a long press selects. + /// + /// Answers whether this frame *started a fling*, which is the caller's + /// cue to register the widget for frames (`UiData::animate`) -- see + /// [`Self::fling`]. + pub fn drag( + &mut self, + pointer: &PointerRequests, + id: WidgetId, + sense: CursorSense, + pos_window: Vec2, + now: Instant, + ) -> bool { + // A scroll area has no selection of its own to extend, so a drag + // across the axis stays `Undecided` and one along it past the slop + // pans, which is the whole contract here. + // + // `scrolling` is the other half: a finger put down on content that + // is still coasting means "stop it here", and commits to a pan on + // that very sample with no slop to wait out + // (`DragArbiter::press_start`). The fling is cancelled in the same + // breath, since the curve has no idea a finger came back down. + let mut press = PressState::default(); + if self.gesture.starts_press(sense) { + press.scrolling = self.fling.is_flinging(); + self.fling.stop(); + } + match self + .gesture + .handle(pointer, id, sense, pos_window, now, press) + { + // The content follows the finger, and the same `dy` an + // arbiter of the caller's own (`Selection::drag`) hands + // straight to `scroll`. + GestureOutcome::Pan(dy) => self.scroll(dy), + // Same sign as `Pan`, since `tick` applies it through the same + // `scroll`. + GestureOutcome::Released(Some(v)) => return self.fling(v), + GestureOutcome::Undecided + | GestureOutcome::Tapped + | GestureOutcome::SelectStart + | GestureOutcome::SelectExtend + | GestureOutcome::Cancelled + | GestureOutcome::Released(None) => {} + } + false + } +} + +/// A widget that scrolls its own content. Implementors hand back the +/// [`ScrollController`] they own and get everything a caller does with a +/// scroll position for free. +/// +/// The two implementors are [`ScrollArea`](super::ScrollArea) and +/// [`LazySpan`](super::LazySpan). What distinguishes them is only *how* +/// they spend a delta, which is their `draw`'s business -- so a caller +/// that pans, flings, reads `amt` or re-pins works through this trait and +/// never has to know which it is holding. +pub trait Scrollable { + fn controller(&self) -> &ScrollController; + fn controller_mut(&mut self) -> &mut ScrollController; + + /// Pan by `amt` -- positive scrolls up or left. See + /// [`ScrollController::scroll`]. + fn scroll(&mut self, amt: f32) { + self.controller_mut().scroll(amt); + } + + /// See [`ScrollController::fling`], including why starting one is not + /// the same as driving it. + fn fling(&mut self, velocity: f32) -> bool { + self.controller_mut().fling(velocity) + } + + fn cancel_fling(&mut self) { + self.controller_mut().cancel_fling(); + } + + /// See [`ScrollController::drag`]. + fn drag( + &mut self, + pointer: &PointerRequests, + id: WidgetId, + sense: CursorSense, + pos_window: Vec2, + now: Instant, + ) -> bool { + self.controller_mut() + .drag(pointer, id, sense, pos_window, now) + } + + /// See [`ScrollController::amt`] for what this counts, which differs + /// between the two implementors in origin though not in direction. + fn amt(&self) -> f32 { + self.controller().amt() + } + + fn axis(&self) -> Axis { + self.controller().axis() + } + + fn is_scrolling(&self) -> bool { + self.controller().is_scrolling() + } + + fn fling_velocity(&self) -> Option { + self.controller().fling_velocity() + } + + fn pinned_to_end(&self) -> bool { + self.controller().pinned_to_end() + } + + fn set_pinned_to_end(&mut self, pinned: bool) { + self.controller_mut().set_pinned_to_end(pinned); + } + + /// Advance a fling by one frame -- an implementor's `Widget::tick` is + /// this, and nothing else animates in a scroll area. + fn tick_fling(&mut self, now: Instant) -> bool { + self.controller_mut().tick(now) + } +} + +/// Register the two inputs of a scroll -- the wheel and a finger drag -- +/// on a widget that owns a [`ScrollController`], and hand back the id. +/// +/// The one place either is wired, shared by `WidgetLike::scrollable` and +/// `LazySpan::scrollable`: what differs between those two is only whether +/// there is a `ScrollArea` in the way, and a drag registered twice is a +/// gesture arbitrated twice. +pub fn scroll_senses(w: WL, axis: Axis) -> impl WidgetIdFn +where + Rsc: HasEvents, + W: Widget + Scrollable, + WL: WidgetLike, +{ + w.on(CursorSense::Scroll, move |ctx, rsc| { + let delta = ctx.data.scroll_delta.axis(axis) * 50.0; + ctx.widget(rsc).scroll(delta); + }) + .on(CursorSense::drag_senses(), |ctx, rsc: &mut Rsc| { + let id = ctx.widget.id(); + let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); + let flung = ctx + .widget(rsc) + .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); + // The half that actually makes it move -- a fling is set by the + // widget and driven by the frame loop, and only this side can + // reach the loop. Only when one actually started: registering a + // widget that is not animating asks the next frame to find that + // out. + if flung { + rsc.ui_mut().animate(id); + } + }) +} diff --git a/src/widget/position/sized.rs b/src/widget/position/sized.rs index aa1fc58..ff7897d 100644 --- a/src/widget/position/sized.rs +++ b/src/widget/position/sized.rs @@ -6,29 +6,32 @@ pub struct Sized { pub y: Option, } -impl Sized { - fn apply_to_outer(&self, ctx: &mut SizeCtx) { +impl Widget for Sized { + fn draw(&mut self, painter: &mut Painter) -> Size { + // The child is drawn within a region that actually carves out the + // fixed axes, not whatever region this widget itself happened to + // be offered -- needed so the painted geometry matches the + // declared size returned below regardless of how much room a + // parent offers. `Aligned`'s single-draw pattern (LAYOUT.md + // section 6) draws its child once at its own *full* region to + // learn its size, then moves it into place with a pure + // translation; that translation is only valid if what got painted + // is already the reported size, anchored the same way both times. + let density = painter.density(); + let mut region = UiRegion::FULL; if let Some(x) = self.x { - ctx.outer.x.select_len(x.apply_rest()); + region.x = x.apply_rest(density).align(AxisAlign::Neg); } if let Some(y) = self.y { - ctx.outer.y.select_len(y.apply_rest()); + region.y = y.apply_rest(density).align(AxisAlign::Neg); + } + let used = painter.widget_within(&self.inner, region); + // `fold_dp` on the way out: a declared size is a `Len` the caller + // wrote (`.width(dp(48))`), and a *reported* one may not carry an + // unresolved `dp` -- see `Len::fold_dp`. + Size { + x: self.x.map(|x| x.fold_dp(density)).unwrap_or(used.x), + y: self.y.map(|y| y.fold_dp(density)).unwrap_or(used.y), } } } - -impl Widget for Sized { - fn draw(&mut self, painter: &mut Painter) { - painter.widget(&self.inner); - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - self.x.unwrap_or_else(|| ctx.width(&self.inner)) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.apply_to_outer(ctx); - self.y.unwrap_or_else(|| ctx.height(&self.inner)) - } -} diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index ad4f932..9ecca40 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -4,17 +4,48 @@ use std::marker::PhantomData; pub struct Span { pub children: Vec, pub dir: Dir, - pub gap: f32, + /// A `Len` (not a bare `f32`) so `dp(4)` resolves against the display's + /// density the same way any other size in the tree does -- see + /// `Len::dp`'s field doc. Only the `abs` component (folded from `dp` at + /// draw time, `Widget::draw` below) is meaningful here; `rel`/`rest` + /// were never supported for a gap and still are not. + pub gap: Len, } impl Widget for Span { - fn draw(&mut self, painter: &mut Painter) { - let total = self.len_sum(&mut painter.size_ctx()); + fn draw(&mut self, painter: &mut Painter) -> Size { + let axis = self.dir.axis; + let gap = self.gap.apply_rest(painter.density()).abs; + + // Phase 1: draw each child once, at the ambient (unmodified, full) + // region a size-only query used to see before this migration, to + // learn its length along the layout axis. This paints real + // primitives at a provisional slot; phase 2 below places each + // child for real via the normal `widget_within` dispatch, which + // only actually redraws it when that slot's *size* differs from + // this provisional one (most children: a resize, since the + // provisional slot is the whole span, not this child's share). + let lens: Vec = self + .children + .iter() + .map(|child| painter.widget(child).axis(axis)) + .collect(); + + let gap_total = gap * self.children.len().saturating_sub(1) as f32; + let total = lens.iter().fold(Len::abs(gap_total), |s, &l| s + l); + + // Phase 2: place each child for real, using the lengths just + // learned -- the same arithmetic this loop always used. The cross- + // axis length of *this* draw (used for `Span`'s own reported size + // below) falls out of each child's real, resolved-width `used` + // here for free -- this is what replaces `desired_ortho`'s former + // duplicate simulation of this same loop (see LAYOUT.md section 4). let mut start = UiScalar::rel_min(); - for child in &self.children { + let mut ortho_len = Len::ZERO; + let mut ortho_mixed = false; + for (child, &len) in self.children.iter().zip(&lens) { let mut span = UiSpan::FULL; span.start = start; - let len = painter.len_axis(child, self.dir.axis); if len.rest > 0.0 { let offset = UiScalar::new(total.rel, total.abs); let rel_end = UiScalar::rel(len.rest / total.rest); @@ -24,27 +55,31 @@ impl Widget for Span { start.abs += len.abs; start.rel += len.rel; span.end = start; - let mut child_region = UiRegion::from_axis(self.dir.axis, span, UiSpan::FULL); + let mut child_region = UiRegion::from_axis(axis, span, UiSpan::FULL); if self.dir.sign == Sign::Neg { - child_region.flip(self.dir.axis); + child_region.flip(axis); } - painter.widget_within(child, child_region); - start.abs += self.gap; - } - } + let used = painter.widget_within(child, child_region); + start.abs += gap; - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - match self.dir.axis { - Axis::X => self.desired_len(ctx), - Axis::Y => self.desired_ortho(ctx), + let ortho = used.axis(!axis); + if ortho.rel > 0.0 || ortho.rest > 0.0 { + ortho_mixed = true; + } else { + ortho_len.abs = ortho_len.abs.max(ortho.abs); + } + } + if ortho_mixed { + ortho_len = Len::default(); } - } - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - match self.dir.axis { - Axis::X => self.desired_ortho(ctx), - Axis::Y => self.desired_len(ctx), - } + let along = if total.rest == 0.0 && total.rel == 0.0 { + total + } else { + Len::default() + }; + + Size::from_axis(axis, along, ortho_len) } } @@ -53,12 +88,12 @@ impl Span { Self { children: Vec::new(), dir, - gap: 0.0, + gap: Len::ZERO, } } - pub fn gap(mut self, gap: impl UiNum) -> Self { - self.gap = gap.to_f32(); + pub fn gap(mut self, gap: impl Into) -> Self { + self.gap = gap.into(); self } @@ -69,93 +104,12 @@ impl Span { pub fn pop(&mut self) -> Option { self.children.pop() } - - fn len_sum(&mut self, ctx: &mut SizeCtx) -> Len { - let gap = self.gap * self.children.len().saturating_sub(1) as f32; - self.children.iter().fold(Len::abs(gap), |mut s, id| { - // it's tempting to subtract the abs & rel from the ctx outer, - // but that would create inconsistent sizing if you put - // a rest first vs last & only speed up in one direction. - // I think this is only solvable by restricting how you can - // compute size, bc currently you need child to define parent's - // sectioning and you need parent's sectioning to define child. - // Fortunately, that doesn't matter in most cases - let len = ctx.len_axis(id, self.dir.axis); - s += len; - s - }) - } - - fn desired_len(&mut self, ctx: &mut SizeCtx) -> Len { - let len = self.len_sum(ctx); - if len.rest == 0.0 && len.rel == 0.0 { - len - } else { - Len::default() - } - } - - fn desired_ortho(&mut self, ctx: &mut SizeCtx) -> Len { - // this is a weird hack to get text wrapping to work properly when in a downward span - // the correct solution here is to add a function to widget that lets them - // request that ctx.outer has an axis "resolved" before checking the other, - // and panicking or warning if two request opposite axis (unsolvable in that case) - let outer = ctx.outer.axis(self.dir.axis); - if self.dir.axis == Axis::X { - // so....... this literally copies draw so that the lengths are correctly set in the - // context, which makes this slow and not cool - let total = self.len_sum(ctx); - let mut start = UiScalar::rel_min(); - let mut ortho_len = Len::ZERO; - for child in &self.children { - let mut span = UiSpan::FULL; - span.start = start; - let len = ctx.len_axis(child, self.dir.axis); - if len.rest > 0.0 { - let offset = UiScalar::new(total.rel, total.abs); - let rel_end = UiScalar::rel(len.rest / total.rest); - let end = (UiScalar::rel_max() + start) - offset; - start = rel_end.within(&start.to(end)); - } - start.abs += len.abs; - start.rel += len.rel; - span.end = start; - - let scalar = span.len(); - *ctx.outer.axis_mut(self.dir.axis) = outer.select_len(scalar); - let ortho = ctx.len_axis(child, !self.dir.axis); - // TODO: rel shouldn't do this, but no easy way before actually calculating pixels - if ortho.rel > 0.0 || ortho.rest > 0.0 { - ortho_len.rest = 1.0; - ortho_len.abs = 0.0; - break; - } - ortho_len.abs = ortho_len.abs.max(ortho.abs); - start.abs += self.gap; - } - ortho_len - } else { - let mut ortho_len = Len::ZERO; - let ortho = !self.dir.axis; - for child in &self.children { - let len = ctx.len_axis(child, ortho); - // TODO: rel shouldn't do this, but no easy way before actually calculating pixels - if len.rel > 0.0 || len.rest > 0.0 { - ortho_len.rest = 1.0; - ortho_len.abs = 0.0; - break; - } - ortho_len.abs = ortho_len.abs.max(len.abs); - } - ortho_len - } - } } pub struct SpanBuilder, Tag> { pub children: Wa, pub dir: Dir, - pub gap: f32, + pub gap: Len, _pd: PhantomData<(State, Tag)>, } @@ -181,13 +135,13 @@ impl, Tag> Self { children, dir, - gap: 0.0, + gap: Len::ZERO, _pd: PhantomData, } } - pub fn gap(mut self, gap: impl UiNum) -> Self { - self.gap = gap.to_f32(); + pub fn gap(mut self, gap: impl Into) -> Self { + self.gap = gap.into(); self } } diff --git a/src/widget/position/stack.rs b/src/widget/position/stack.rs index fb4a591..d927491 100644 --- a/src/widget/position/stack.rs +++ b/src/widget/position/stack.rs @@ -8,29 +8,26 @@ pub struct Stack { } impl Widget for Stack { - fn draw(&mut self, painter: &mut Painter) { - let mut iter = self.children.iter(); - if let Some(child) = iter.next() { + fn draw(&mut self, painter: &mut Painter) -> Size { + let mut picked = None; + let mut iter = self.children.iter().enumerate(); + if let Some((i, child)) = iter.next() { painter.child_layer(); - painter.widget(child); + let used = painter.widget(child); + if matches!(self.size, StackSize::Child(j) if j == i) { + picked = Some(used); + } } - for child in iter { + for (i, child) in iter { painter.next_layer(); - painter.widget(child); + let used = painter.widget(child); + if matches!(self.size, StackSize::Child(j) if j == i) { + picked = Some(used); + } } - } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { match self.size { - StackSize::Default => Len::default(), - StackSize::Child(i) => ctx.width(&self.children[i]), - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - match self.size { - StackSize::Default => Len::default(), - StackSize::Child(i) => ctx.height(&self.children[i]), + StackSize::Default => Size::default(), + StackSize::Child(_) => picked.unwrap_or_default(), } } } diff --git a/src/widget/ptr.rs b/src/widget/ptr.rs index 1e6241b..5f1531f 100644 --- a/src/widget/ptr.rs +++ b/src/widget/ptr.rs @@ -6,26 +6,16 @@ pub struct WidgetPtr { } impl Widget for WidgetPtr { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { if let Some(id) = &self.inner { - painter.widget(id); + painter.widget(id) + } else { + Size::ZERO } } - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(id) = &self.inner { - ctx.width(id) - } else { - Len::ZERO - } - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(id) = &self.inner { - ctx.height(id) - } else { - Len::ZERO - } + fn is_size_independent(&self) -> bool { + self.inner.is_none() } } diff --git a/src/widget/rect.rs b/src/widget/rect.rs index f72820e..28ea415 100644 --- a/src/widget/rect.rs +++ b/src/widget/rect.rs @@ -3,7 +3,13 @@ use crate::prelude::*; #[derive(Clone, Copy)] pub struct Rect { pub color: UiColor, - pub radius: f32, + /// A `Len` rather than a raw `f32` so a corner can be written in `dp` + /// and come out the same physical size on every display -- resolved + /// against `Painter::density` in [`Rect::draw`], the same place every + /// other `dp` is resolved. A plain number still works and still means + /// physical pixels (`impl From for Len`), which is what + /// a hairline wants. + pub radius: Len, pub thickness: f32, pub inner_radius: f32, } @@ -12,7 +18,7 @@ impl Rect { pub fn new(color: UiColor) -> Self { Self { color, - radius: 0.0, + radius: Len::ZERO, inner_radius: 0.0, thickness: 0.0, } @@ -21,28 +27,50 @@ impl Rect { self.color = color; self } - pub fn radius(mut self, radius: impl UiNum) -> Self { - self.radius = radius.to_f32(); + pub fn radius(mut self, radius: impl Into) -> Self { + self.radius = radius.into(); self } } impl Widget for Rect { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { painter.primitive(RectPrimitive { color: self.color, - radius: self.radius, + // `rel` has no meaning for a corner (a rect that fills its + // parent has no length of its own to take a fraction of), so + // only the `abs`/`dp` halves are folded. + radius: self.radius.fold_dp(painter.density()).abs, thickness: self.thickness, inner_radius: self.inner_radius, }); + Size::REST // fills whatever it was given -- used == available } - fn desired_width(&mut self, _: &mut SizeCtx) -> Len { - Len::rest(1) - } - - fn desired_height(&mut self, _: &mut SizeCtx) -> Len { - Len::rest(1) + /// **No** -- despite drawing one primitive and nothing else. + /// + /// `is_size_independent` asks whether the widget's *content* is + /// unaffected by how big a region it was given, so that + /// `draw_inner` may keep the primitives it already has and rewrite + /// their regions in place. A `Rect`'s content **is** its region: it + /// returns `Size::REST` and fills whatever it was handed, so the fast + /// path's `r.outside(&from).within(®ion)` remap has to reproduce + /// the whole of `draw` -- and it does not, because a region carries + /// `rel` and `abs` components that the round trip cannot recover + /// separately. + /// + /// What that looked like: a fenced code block's background + /// (`transcript-ui`'s `BlockFrame::Verbatim`, a `Rect` behind a + /// `Pad` in a `Stack`) kept the height of the *provisional* full- + /// region draw `Span` does in its first phase, so one fence's panel + /// covered every block below it -- and every row below that -- while + /// the text itself was laid out correctly. Visible in + /// `docs/bench/p1a-2026-09-06/`'s history and reproduced by this + /// crate's `transcript` example. Answering `false` costs a redraw of + /// one primitive when a rect is resized, which is what the fast path + /// was saving. + fn is_size_independent(&self) -> bool { + false } } diff --git a/src/widget/text/build.rs b/src/widget/text/build.rs index a2e8134..7b4c696 100644 --- a/src/widget/text/build.rs +++ b/src/widget/text/build.rs @@ -1,10 +1,10 @@ use crate::prelude::*; -use cosmic_text::{Attrs, Family, Metrics}; use std::marker::{PhantomData, Sized}; pub struct TextBuilder = ()> { pub content: String, pub attrs: TextAttrs, + pub spans: Vec, pub hint: H, pub output: O, state: PhantomData, @@ -20,7 +20,7 @@ impl> TextBuilder { self.attrs.color = color; self } - pub fn family(mut self, family: Family<'static>) -> Self { + pub fn family(mut self, family: Family) -> Self { self.attrs.family = family; self } @@ -40,10 +40,19 @@ impl> TextBuilder { self.attrs.wrap = wrap; self } + /// Per-range style overrides -- I5's inline rich text (bold, italic, + /// inline-code monospace, link colour/underline) within one wrapped + /// paragraph. See `SpanStyle`'s doc for why this exists and what it + /// replaces. + pub fn spans(mut self, spans: Vec) -> Self { + self.spans = spans; + self + } pub fn editable(self, mode: EditMode) -> TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: self.hint, output: TextEditOutput { mode }, state: PhantomData, @@ -59,6 +68,7 @@ impl TextBuilder { TextBuilder { content: self.content, attrs: self.attrs, + spans: self.spans, hint: move |rsc: &mut Rsc| Some(hint.add_strong(rsc).any()), output: self.output, state: PhantomData, @@ -82,19 +92,14 @@ impl TextBuilderOutput for TextOutput { state: &mut Rsc, builder: TextBuilder, ) -> Self::Output { - let mut buf = TextBuffer::new_empty(Metrics::new( - builder.attrs.font_size, - builder.attrs.line_height, - )); + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); let hint = builder.hint.get(state); - let font_system = &mut state.ui_mut().text.font_system; - buf.set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None); let mut text = Text { content: builder.content.into(), view: TextView::new(buf, builder.attrs, hint), }; text.content.changed = false; - builder.attrs.apply(font_system, &mut text.view.buf, None); text } } @@ -110,19 +115,12 @@ impl TextBuilderOutput for TextEditOutput { state: &mut State, builder: TextBuilder, ) -> Self::Output { - let buf = TextBuffer::new_empty(Metrics::new( - builder.attrs.font_size, - builder.attrs.line_height, - )); - let mut text = TextEdit::new( + let mut buf = TextBuffer::new(&builder.content); + buf.set_spans(builder.spans); + TextEdit::new( TextView::new(buf, builder.attrs, builder.hint.get(state)), builder.output.mode, - ); - let font_system = &mut state.ui_mut().text.font_system; - text.buf - .set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None); - builder.attrs.apply(font_system, &mut text.buf, None); - text + ) } } @@ -140,6 +138,7 @@ pub fn wtext(content: impl Into) -> TextBuilder { TextBuilder { content: content.into(), attrs: TextAttrs::default(), + spans: Vec::new(), hint: (), output: TextOutput, state: PhantomData, diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 846d16e..2e30746 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -1,17 +1,46 @@ use crate::prelude::*; -use cosmic_text::{Affinity, Attrs, Cursor, FontSystem, LayoutRun, Motion}; +use iris_core::{TextData, UiColor}; +use parley::{Affinity, Layout, Selection}; use std::ops::{Deref, DerefMut}; -use unicode_segmentation::UnicodeSegmentation; +#[cfg(not(target_os = "android"))] use winit::{ event::KeyEvent, keyboard::{Key, NamedKey}, }; +/// Which way a cursor movement goes. Named here rather than taken from the text +/// stack so that the key handling below does not have to change when the stack +/// does; the mapping onto parley lives in one place, in `apply_motion`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Motion { + Left, + Right, + LeftWord, + RightWord, + Up, + Down, + LineStart, + LineEnd, +} + pub struct TextEdit { view: TextView, - selection: TextSelection, - history: Vec<(String, TextSelection)>, - double_hit: Option, + /// `None` when the field is not focused -- which parley's `Selection` has no + /// way to say, since it always denotes some position in the text. A + /// collapsed selection is a caret; an uncollapsed one is a span. + selection: Option, + #[cfg_attr(target_os = "android", allow(dead_code))] + history: Vec<(String, Option)>, + double_hit: Option, + /// Where an in-flight press over this field began, while it is still + /// undecided whether the gesture is a tap (focus/show the IME) or a + /// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe + /// over the composer must not summon the keyboard). `None` both before + /// any press and once the gesture has been decided either way -- + /// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than + /// behind an accessor since it is pure bookkeeping with no invariant + /// beyond "some press is undecided," same shape as `double_hit` above. + pub(crate) press_origin: Option, pub mode: EditMode, } @@ -25,230 +54,176 @@ impl TextEdit { pub fn new(view: TextView, mode: EditMode) -> Self { Self { view, - selection: Default::default(), + selection: None, history: Default::default(), double_hit: None, + press_origin: None, mode, } } - pub fn select_content(&self, start: Cursor, end: Cursor) -> String { - let (start, end) = sort_cursors(start, end); - let mut iter = self.buf.lines.iter().skip(start.line); - let first = iter.next().unwrap(); - if start.line == end.line { - first.text()[start.index..end.index].to_string() - } else { - let mut str = first.text()[start.index..].to_string(); - for _ in (start.line + 1)..end.line { - str = str + "\n" + iter.next().unwrap().text(); - } - let last = iter.next().unwrap(); - str = str + "\n" + &last.text()[..end.index]; - str + + pub fn selected_text(&self) -> Option { + let sel = self.selection?; + if sel.is_collapsed() { + return None; } + Some(self.buf.text()[sel.text_range()].to_string()) + } + + /// The field's content. Byte-indexed, like everything else here since + /// I1 moved to parley -- an IME bridge (`android/ime.rs`) converts to + /// and from UTF-16 code units at its own edge rather than this type + /// knowing about that encoding. + pub fn text(&self) -> &str { + self.view.buf.text() + } + + /// The selection as a byte range, collapsed to `caret..caret` when + /// there is no span. `None` when the field is not focused. + pub fn selection_range(&self) -> Option> { + Some(self.selection?.text_range()) + } + + /// The caret's byte offset -- the focus end of the selection, which is + /// where typing lands regardless of which end of a span it is. + pub fn caret(&self) -> Option { + Some(self.selection?.focus().index()) } } impl Widget for TextEdit { - fn draw(&mut self, painter: &mut Painter) { + fn draw(&mut self, painter: &mut Painter) -> Size { let base = painter.layer; painter.child_layer(); - self.view.draw(painter); + let used = self.view.draw(painter); painter.layer = base; let region = self.region(); - let size = vec2(1, self.attrs.line_height); - match self.selection { - TextSelection::None => (), - TextSelection::Pos(cursor) => { - if let Some(offset) = cursor_pos(cursor, &self.buf) { - painter.primitive_within( - RectPrimitive::color(Color::WHITE), - size.align(Align::TOP_LEFT).offset(offset).within(®ion), - ); - } - } - TextSelection::Span { start, end } => { - let (start, end) = sort_cursors(start, end); - for (l, x, width) in iter_layout_lines(start, end, &self.buf) { - let top_left = vec2(x, self.attrs.line_height * l as f32); - painter.primitive_within( - RectPrimitive::color(Color::SKY), - size.with_x(width) - .align(Align::TOP_LEFT) - .offset(top_left) - .within(®ion), - ); - } - if let Some(end_offset) = cursor_pos(end, &self.buf) { - painter.primitive_within( - RectPrimitive::color(Color::WHITE), - size.align(Align::TOP_LEFT) - .offset(end_offset) - .within(®ion), - ); - } - } + let Some(selection) = self.selection else { + return used; + }; + let layout = self.view.buf.layout(); + + // parley reports selection as boxes in layout space, so bidi and + // wrapped lines come out right without this code knowing about either. + for (rect, _) in selection.geometry(layout) { + let size = vec2(rect.width() as f32, rect.height() as f32); + let top_left = vec2(rect.x0 as f32, rect.y0 as f32); + painter.primitive_within( + RectPrimitive::color(Color::SKY), + size.align(Align::TOP_LEFT).offset(top_left).within(®ion), + ); } + + let caret = selection.focus().geometry(layout, CARET_WIDTH); + let size = vec2(caret.width() as f32, caret.height() as f32); + let top_left = vec2(caret.x0 as f32, caret.y0 as f32); + painter.primitive_within( + RectPrimitive::color(Color::WHITE), + size.align(Align::TOP_LEFT).offset(top_left).within(®ion), + ); + used } - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.view.desired_width(ctx) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.view.desired_height(ctx) - } -} - -/// provides top left + width -fn iter_layout_lines( - start: Cursor, - end: Cursor, - buf: &TextBuffer, -) -> impl Iterator { - gen move { - let mut iter = buf.layout_runs().enumerate(); - for (i, line) in iter.by_ref() { - if line.line_i == start.line - && let Some(start_x) = index_x(&line, start.index) - { - if start.line == end.line - && let Some(end_x) = index_x(&line, end.index) - { - yield (i, start_x, end_x - start_x); - return; - } - yield (i, start_x, line.line_w - start_x); - break; - } - } - for (i, line) in iter { - if line.line_i > end.line { - return; - } - if line.line_i == end.line - && let Some(end_x) = index_x(&line, end.index) - { - yield (i, 0.0, end_x); - return; - } - yield (i, 0.0, line.line_w); + /// I4 (RUST.md): the one override that exists so far -- everything + /// else falls back to `Widget::access_role`'s default `Unknown`. + fn access_role(&self) -> accesskit::Role { + match self.mode { + EditMode::SingleLine => accesskit::Role::TextInput, + EditMode::MultiLine => accesskit::Role::MultilineTextInput, } } } -/// copied & modified from fn found in Editor in cosmic_text -/// returns x pos of a (non layout) index within an layout run -fn index_x(run: &LayoutRun, index: usize) -> Option { - for glyph in run.glyphs.iter() { - if index == glyph.start { - return Some(glyph.x); - } else if index > glyph.start && index < glyph.end { - // Guess x offset based on characters - let mut before = 0; - let mut total = 0; - - let cluster = &run.text[glyph.start..glyph.end]; - for (i, _) in cluster.grapheme_indices(true) { - if glyph.start + i < index { - before += 1; - } - total += 1; - } - - let offset = glyph.w * (before as f32) / (total as f32); - return Some(glyph.x + offset); - } - } - None -} - -/// returns top of line segment where cursor should visually select -fn cursor_pos(cursor: Cursor, buf: &TextBuffer) -> Option { - let mut prev = None; - for run in buf - .layout_runs() - .skip_while(|r| r.line_i < cursor.line) - .take_while(|r| r.line_i == cursor.line) - { - prev = Some(vec2(run.line_w, run.line_top)); - if let Some(pos) = index_x(&run, cursor.index) { - return Some(vec2(pos, run.line_top)); - } - } - prev -} +const CARET_WIDTH: f32 = 1.0; pub struct TextEditCtx<'a> { pub text: &'a mut TextEdit, - pub font_system: &'a mut FontSystem, + pub data: &'a mut TextData, } impl<'a> TextEditCtx<'a> { + /// The layout, brought up to date with the text first. + /// + /// Every cursor movement and hit test goes through parley's layout, so an + /// edit that left it stale would move the caret against the previous text. + /// Shaping is skipped when nothing changed, so calling this freely is fine. + fn layout(&mut self) -> &Layout { + let attrs = self.text.view.attrs.clone(); + let width = self.text.view.wrap_width(); + let density = self.data.density; + self.text.view.buf.shape(self.data, &attrs, width, density); + self.text.view.buf.layout() + } + + /// Keep the selection valid after the text underneath it changed. + #[cfg_attr(target_os = "android", allow(dead_code))] + fn refresh(&mut self) { + if let Some(sel) = self.text.selection { + let layout = self.layout(); + self.text.selection = Some(sel.refresh(layout)); + } + } + pub fn take(&mut self) -> String { - let text = self - .text - .buf - .lines - .drain(..) - .map(|l| l.into_text()) - .collect::>() - .join("\n"); - self.text - .buf - .set_text(self.font_system, "", &Attrs::new(), SHAPING, None); - self.text.selection.clear(); + let text = self.text.view.buf.text().to_string(); + self.set(""); text } pub fn set(&mut self, text: &str) { let text = self.string(text); - self.text - .buf - .set_text(self.font_system, &text, &Attrs::new(), SHAPING, None); - self.text.selection.clear(); + self.text.view.buf.set_text(text); + self.text.view.buf.changed = true; + self.text.selection = None; + } + + /// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call -- + /// what a streamed transcript row needs, since its markdown re-renders + /// to a new string *and* a new span list on every delta and the two + /// have to land together (a stale span list drawn against new text can + /// point past its end). Used by `transcript-ui`'s incremental apply + /// (RUST.md's "streaming still costs a full rebuild" fix) rather than + /// tearing the row's widget down and rebuilding it from scratch. + pub fn set_with_spans(&mut self, text: &str, spans: Vec) { + let text = self.string(text); + self.text.view.buf.set_text(text); + self.text.view.buf.set_spans(spans); + self.text.selection = None; } pub fn motion(&mut self, motion: Motion, select: bool) { - if let TextSelection::Pos(cursor) = self.text.selection - && let Some(new) = self.buf_motion(cursor, motion) - { - if select { - self.text.selection = TextSelection::Span { - start: cursor, - end: new, - }; - } else { - self.text.selection = TextSelection::Pos(new); - } - } else if let TextSelection::Span { start, end } = self.text.selection { - if select { - if let Some(cursor) = self.buf_motion(end, motion) { - self.text.selection = TextSelection::Span { start, end: cursor }; + let Some(sel) = self.text.selection else { + return; + }; + let layout = self.layout(); + // Collapsing a span with an unshifted left/right puts the caret at the + // near end rather than moving one character from the focus, which is + // what every other editor does. + let sel = if !select && !sel.is_collapsed() { + match motion { + Motion::Left | Motion::LeftWord => { + Selection::from(sel.text_range().start_cursor(layout)) } - } else { - let (start, end) = sort_cursors(start, end); - let sel = &mut self.text.selection; - match motion { - Motion::Left | Motion::LeftWord => *sel = TextSelection::Pos(start), - Motion::Right | Motion::RightWord => *sel = TextSelection::Pos(end), - _ => { - if let Some(cursor) = self.buf_motion(end, motion) { - self.text.selection = TextSelection::Pos(cursor); - } - } + Motion::Right | Motion::RightWord => { + Selection::from(sel.text_range().end_cursor(layout)) } + _ => apply_motion(sel, layout, motion, false), } - } + } else { + apply_motion(sel, layout, motion, select) + }; + self.text.selection = Some(sel); } + /// Replace the `len` characters before the caret. This is the IME's + /// preedit path: it re-sends the whole composition each time. pub fn replace(&mut self, len: usize, text: &str) { let text = self.string(text); for _ in 0..len { - self.delete(false); + self.backspace(false); } - self.insert_inner(&text, false); + self.insert_str(&text); } fn string(&self, text: &str) -> String { @@ -261,207 +236,246 @@ impl<'a> TextEditCtx<'a> { pub fn insert(&mut self, text: &str) { let text = self.string(text); - let mut lines = text.split('\n'); - let Some(first) = lines.next() else { + self.insert_str(&text); + } + + fn insert_str(&mut self, text: &str) { + if text.is_empty() { return; - }; - self.insert_inner(first, true); - for line in lines { - self.newline(); - self.insert_inner(line, true); } - } - - pub fn clear_span(&mut self) -> bool { - if let TextSelection::Span { start, end } = self.text.selection { - self.delete_between(start, end); - let (start, _) = sort_cursors(start, end); - self.text.selection = TextSelection::Pos(start); - true - } else { - false - } - } - - pub fn delete_between(&mut self, start: Cursor, end: Cursor) { - let lines = &mut self.text.view.buf.lines; - let (start, end) = sort_cursors(start, end); - if start.line == end.line { - let line = &mut lines[start.line]; - let text = line.text(); - let text = text[..start.index].to_string() + &text[end.index..]; - edit_line(line, text); - } else { - // start - let start_text = lines[start.line].text()[..start.index].to_string(); - let end_text = &lines[end.line].text()[end.index..]; - let text = start_text + end_text; - edit_line(&mut lines[start.line], text); - } - // between - let range = (start.line + 1)..=end.line; - if !range.is_empty() { - lines.splice(range, None); - } - } - - fn insert_inner(&mut self, text: &str, mov: bool) { self.clear_span(); - if let TextSelection::Pos(cursor) = &mut self.text.selection { - let line = &mut self.text.view.buf.lines[cursor.line]; - let mut line_text = line.text().to_string(); - line_text.insert_str(cursor.index, text); - edit_line(line, line_text); - if mov { - for _ in 0..text.chars().count() { - self.motion(Motion::Right, false); - } + let at = match self.text.selection { + Some(sel) => sel.focus().index(), + // No caret means nowhere to put the text, so this drops the + // keystroke -- which is invisible, and was the whole of the + // "typed text never appears" defect (see `select`'s comment). + // A field the IME is talking to has been focused, and focusing + // one places a caret, so reaching here is a bug in whoever + // routed the input rather than something to recover from. + None => { + debug_assert!( + false, + "insert into a text field with no caret: '{}' was given input \ + without being focused, so the keystroke would be dropped silently", + text, + ); + return; } + }; + let at = at.min(self.text.view.buf.text().len()); + self.text.view.buf.edit().insert_str(at, text); + self.text.view.buf.changed = true; + self.set_caret(at + text.len()); + } + + /// True when there was a span to remove. + pub fn clear_span(&mut self) -> bool { + let Some(sel) = self.text.selection else { + return false; + }; + if sel.is_collapsed() { + return false; } + let range = sel.text_range(); + self.text.view.buf.edit().replace_range(range.clone(), ""); + self.text.view.buf.changed = true; + self.set_caret(range.start); + true + } + + fn set_caret(&mut self, index: usize) { + let index = index.min(self.text.view.buf.text().len()); + let layout = self.layout(); + self.text.selection = Some(Selection::from_byte_index( + layout, + index, + Affinity::default(), + )); } pub fn newline(&mut self) { - if self.text.mode == EditMode::SingleLine { - return; - } - self.clear_span(); - if let TextSelection::Pos(cursor) = &mut self.text.selection { - let lines = &mut self.text.view.buf.lines; - let line = &mut lines[cursor.line]; - let new = line.split_off(cursor.index); - cursor.line += 1; - lines.insert(cursor.line, new); - cursor.index = 0; + if self.text.mode == EditMode::MultiLine { + self.insert_str("\n"); } } pub fn backspace(&mut self, word: bool) { - if !self.clear_span() - && let TextSelection::Pos(cursor) = &mut self.text.selection - && (cursor.index != 0 || cursor.line != 0) - { - self.motion(if word { Motion::LeftWord } else { Motion::Left }, false); - self.delete(word); + if self.clear_span() { + return; } + let Some(sel) = self.text.selection else { + return; + }; + let end = sel.focus().index(); + if end == 0 { + return; + } + let layout = self.layout(); + let start = if word { + sel.focus().previous_logical_word(layout).index() + } else { + sel.focus().previous_visual(layout).index() + }; + self.delete_range(start, end); } pub fn delete(&mut self, word: bool) { - if !self.clear_span() - && let TextSelection::Pos(cursor) = &mut self.text.selection - { - if word { - let start = *cursor; - if let Some(end) = self.buf_motion(start, Motion::RightWord) { - self.delete_between(start, end); - } - } else { - let lines = &mut self.text.view.buf.lines; - let line = &mut lines[cursor.line]; - if cursor.index == line.text().len() { - if cursor.line == lines.len() - 1 { - return; - } - let add = lines.remove(cursor.line + 1).into_text(); - let line = &mut lines[cursor.line]; - let mut cur = line.text().to_string(); - cur.push_str(&add); - edit_line(line, cur); - } else { - let mut text = line.text().to_string(); - text.remove(cursor.index); - edit_line(line, text); - } - } + if self.clear_span() { + return; } + let Some(sel) = self.text.selection else { + return; + }; + let start = sel.focus().index(); + if start >= self.text.view.buf.text().len() { + return; + } + let layout = self.layout(); + let end = if word { + sel.focus().next_logical_word(layout).index() + } else { + sel.focus().next_visual(layout).index() + }; + self.delete_range(start, end); } - fn buf_motion(&mut self, cursor: Cursor, motion: Motion) -> Option { - self.text - .buf - .cursor_motion(self.font_system, cursor, None, motion) - .map(|r| r.0) + fn delete_range(&mut self, start: usize, end: usize) { + let len = self.text.view.buf.text().len(); + let (start, end) = (start.min(end).min(len), start.max(end).min(len)); + if start == end { + return; + } + self.text.view.buf.edit().replace_range(start..end, ""); + self.text.view.buf.changed = true; + self.set_caret(start); } - pub fn select_word_at(&mut self, cursor: Cursor) { - if let (Some(start), Some(end)) = ( - self.buf_motion(cursor, Motion::LeftWord), - self.buf_motion(cursor, Motion::RightWord), - ) { - self.text.selection = TextSelection::Span { start, end }; - } + /// The same range delete, exposed for callers that already have byte + /// offsets in hand rather than a `Motion` -- the IME's + /// `deleteSurroundingText`, which android-view hands over in UTF-16 + /// code units that `android/ime.rs` converts before calling this. + pub fn delete_byte_range(&mut self, start: usize, end: usize) { + self.delete_range(start, end); } - pub fn select_line_at(&mut self, cursor: Cursor) { - let end = self.text.buf.lines[cursor.line].text().len(); - self.text.selection = TextSelection::Span { - start: Cursor::new(cursor.line, 0), - end: Cursor::new(cursor.line, end), + /// Move the caret to a byte offset, collapsing any selection -- the + /// IME's `setSelection`. + pub fn set_cursor_byte(&mut self, index: usize) { + self.set_caret(index); + } + + /// The byte offset in the text that `pos` (in the same window-space + /// coordinates a `CursorSense` reports, with `size` the region the + /// event was measured against) lands on. + /// + /// The one thing a caller outside this module needs to turn a tap into + /// a *range* of the text -- which markdown link is under the finger, + /// which inline-code chip was pressed. `layout()` is private because a + /// caller holding a parley `Layout` could shape it against stale text; + /// this hands back the answer rather than the layout, and does the + /// same region-relative transform [`select`](Self::select) does, so + /// the two cannot disagree about where a point is. + /// + /// Parley clamps a point outside the laid-out text to the nearest + /// cursor position, so a tap in the field's padding answers with the + /// nearest offset rather than failing -- a caller wanting "was this + /// actually *on* something" checks its own ranges, which is what + /// makes a tap in the padding hit no link. + pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { + let pos = pos - self.text.region().top_left().to_abs(size); + let layout = self.layout(); + Selection::from_point(layout, pos.x, pos.y).focus().index() + } + + pub fn select_all(&mut self) { + let len = self.text.view.buf.text().len(); + if len == 0 { + return; } + let layout = self.layout(); + let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default()); + let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default()); + self.text.selection = Some(Selection::new(anchor, focus)); } pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { let pos = pos - self.text.region().top_left().to_abs(size); - let hit = self.text.buf.hit(pos.x, pos.y); - let sel = &mut self.text.selection; - match sel { - TextSelection::None => { - if !drag && let Some(hit) = hit { - *sel = TextSelection::Pos(hit) - } + let prev_sel = self.text.selection; + let prev_hit = self.text.double_hit; + + // The layout borrows `self`, so the whole decision is made in here and + // only the answer escapes. + // + // **A press that reaches here has already been hit-tested to this + // widget, so there is no "outside" to clear the selection for.** + // This used to compare `pos` against the *laid-out text's* box and + // set `selection = None` for anything beyond it -- but the laid-out + // text is smaller than the field (padding, and for an empty field a + // box of literally zero width), so tapping an **empty** composer + // granted focus, opened the keyboard, and left `selection` at + // `None` -- and `insert_str` returns early on `None`, so every + // keystroke after that was silently dropped and nothing ever + // appeared. That is RUST.md's P0 box item 2, "composed text never + // becomes visible at all": the buffer was empty the whole time, and + // Gboard's suggestion strip (its own composing state, not ours) is + // what made it look otherwise. Parley's `from_point`/ + // `extend_to_point` already clamp a point outside the layout to the + // nearest cursor position, which is what a tap in a field's padding + // should do anyway. Losing focus is a separate path + // (`TextEditCtx::deselect`, called from the backend's focus + // handling), not this one. + let outcome = { + let layout = self.layout(); + if drag { + prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit)) + } else { + let hit = Selection::from_point(layout, pos.x, pos.y); + let index = hit.focus().index(); + // A second click in the same place takes the word and a third + // the line; `double_hit` is what remembers that the previous + // click had already grown to a word. + Some(if recent && prev_hit == Some(index) { + (Some(Selection::line_from_point(layout, pos.x, pos.y)), None) + } else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) { + ( + Some(Selection::word_from_point(layout, pos.x, pos.y)), + Some(index), + ) + } else { + (Some(hit), None) + }) } - TextSelection::Pos(pos) => match (hit, drag) { - (None, false) => *sel = TextSelection::None, - (None, true) => (), - (Some(hit), false) => { - if recent && hit == *pos { - self.text.double_hit = Some(hit); - return self.select_word_at(hit); - } else { - *pos = hit - } - } - (Some(end), true) => *sel = TextSelection::Span { start: *pos, end }, - }, - TextSelection::Span { start, end } => match (hit, drag) { - (None, false) => *sel = TextSelection::None, - (None, true) => *sel = TextSelection::Pos(*start), - (Some(hit), false) => { - if recent - && let Some(double) = self.text.double_hit - && double == hit - { - return self.select_line_at(hit); - } else { - *sel = TextSelection::Pos(hit) - } - } - (Some(hit), true) => *end = hit, - }, - } - if let TextSelection::Span { start, end } = sel - && start == end - { - *sel = TextSelection::Pos(*start); + }; + + if let Some((selection, double_hit)) = outcome { + self.text.selection = selection; + self.text.double_hit = double_hit; } } pub fn deselect(&mut self) { - self.text.selection = TextSelection::None; + self.text.selection = None; + self.text.double_hit = None; } + #[cfg(not(target_os = "android"))] pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { - let old = (self.text.content(), self.text.selection); + let old = (self.text.view.buf.text().to_string(), self.text.selection); let mut undo = false; let res = self.apply_event_inner(event, modifiers, &mut undo); - if undo && let Some((old, selection)) = self.text.history.pop() { - self.set(&old); - self.text.selection = selection; - } else if self.text.content() != old.0 { + if undo { + if let Some((old, selection)) = self.text.history.pop() { + self.set(&old); + self.text.selection = selection; + self.refresh(); + } + } else if self.text.view.buf.text() != old.0 { self.text.history.push(old); } res } + #[cfg(not(target_os = "android"))] fn apply_event_inner( &mut self, event: &KeyEvent, @@ -481,21 +495,25 @@ impl<'a> TextEditCtx<'a> { } } NamedKey::ArrowRight => { - if modifiers.control { - self.motion(Motion::RightWord, modifiers.shift) + let motion = if modifiers.control { + Motion::RightWord } else { - self.motion(Motion::Right, modifiers.shift) - } + Motion::Right + }; + self.motion(motion, modifiers.shift); } NamedKey::ArrowLeft => { - if modifiers.control { - self.motion(Motion::LeftWord, modifiers.shift) + let motion = if modifiers.control { + Motion::LeftWord } else { - self.motion(Motion::Left, modifiers.shift) - } + Motion::Left + }; + self.motion(motion, modifiers.shift); } NamedKey::ArrowUp => self.motion(Motion::Up, modifiers.shift), NamedKey::ArrowDown => self.motion(Motion::Down, modifiers.shift), + NamedKey::Home => self.motion(Motion::LineStart, modifiers.shift), + NamedKey::End => self.motion(Motion::LineEnd, modifiers.shift), NamedKey::Escape => { self.deselect(); return TextInputResult::Unfocus; @@ -507,34 +525,18 @@ impl<'a> TextEditCtx<'a> { match text.as_str() { "v" => return TextInputResult::Paste, "c" => { - if let TextSelection::Span { start, end } = self.text.selection { - let content = self.text.select_content(start, end); + if let Some(content) = self.text.selected_text() { return TextInputResult::Copy(content); } } "x" => { - if let TextSelection::Span { start, end } = self.text.selection { - let content = self.text.select_content(start, end); + if let Some(content) = self.text.selected_text() { self.clear_span(); return TextInputResult::Copy(content); } } - "a" => { - if !self.text.buf.lines[0].text().is_empty() - || self.text.buf.lines.len() > 1 - { - let lines = &self.text.buf.lines; - let last_line = lines.len() - 1; - let last_idx = lines[last_line].text().len(); - self.text.selection = TextSelection::Span { - start: Cursor::new(0, 0), - end: Cursor::new(last_line, last_idx), - }; - } - } - "z" => { - *undo = true; - } + "a" => self.select_all(), + "z" => *undo = true, _ => self.insert(text), } } else { @@ -547,6 +549,40 @@ impl<'a> TextEditCtx<'a> { } } +fn apply_motion( + sel: Selection, + layout: &Layout, + motion: Motion, + extend: bool, +) -> Selection { + match motion { + Motion::Left => sel.previous_visual(layout, extend), + Motion::Right => sel.next_visual(layout, extend), + Motion::LeftWord => sel.previous_visual_word(layout, extend), + Motion::RightWord => sel.next_visual_word(layout, extend), + Motion::Up => sel.previous_line(layout, extend), + Motion::Down => sel.next_line(layout, extend), + Motion::LineStart => sel.line_start(layout, extend), + Motion::LineEnd => sel.line_end(layout, extend), + } +} + +/// The ends of a byte range as cursors, so collapsing a selection can put the +/// caret at whichever end the movement asked for. +trait RangeCursors { + fn start_cursor(&self, layout: &Layout) -> parley::Cursor; + fn end_cursor(&self, layout: &Layout) -> parley::Cursor; +} + +impl RangeCursors for std::ops::Range { + fn start_cursor(&self, layout: &Layout) -> parley::Cursor { + parley::Cursor::from_byte_index(layout, self.start, Affinity::default()) + } + fn end_cursor(&self, layout: &Layout) -> parley::Cursor { + parley::Cursor::from_byte_index(layout, self.end, Affinity::default()) + } +} + #[derive(Default)] pub struct Modifiers { pub shift: bool, @@ -569,33 +605,6 @@ pub enum TextInputResult { Paste, } -#[derive(Debug, Default, Clone, Copy)] -pub enum TextSelection { - #[default] - None, - Pos(Cursor), - Span { - start: Cursor, - end: Cursor, - }, -} - -impl TextSelection { - pub fn clear(&mut self) { - match self { - TextSelection::None => (), - TextSelection::Pos(cursor) => { - cursor.line = 0; - cursor.index = 0; - cursor.affinity = Affinity::default(); - } - TextSelection::Span { start: _, end: _ } => { - *self = TextSelection::None; - } - } - } -} - impl TextInputResult { pub fn unfocus(&self) -> bool { matches!(self, TextInputResult::Unfocus) @@ -625,7 +634,256 @@ impl> TextEditable for I { let ui = ui.ui_mut(); TextEditCtx { text: ui.widgets.get_mut(self).unwrap(), - font_system: &mut ui.text.font_system, + data: &mut ui.text, } } } + +#[cfg(test)] +mod tests { + use super::*; + use iris_core::{TextAttrs, TextBuffer}; + + /// The editor is the one part of iris that is pure logic over a string and + /// a layout, and it was rewritten wholesale when the text stack changed -- + /// so it is the one part worth testing directly. Everything else here + /// needs a GPU and a window. + fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) { + let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None); + (TextEdit::new(view, mode), TextData::default()) + } + + fn ctx<'a>(text: &'a mut TextEdit, data: &'a mut TextData) -> TextEditCtx<'a> { + TextEditCtx { text, data } + } + + fn content(text: &TextEdit) -> String { + text.buf.text().to_string() + } + + #[test] + fn insert_at_the_caret() { + let (mut t, mut d) = edit("ac", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(1); + ctx(&mut t, &mut d).insert("b"); + assert_eq!(content(&t), "abc"); + assert_eq!(t.selection.unwrap().focus().index(), 2); + } + + #[test] + fn backspace_removes_the_character_before_the_caret() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(2); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "ac"); + } + + #[test] + fn backspace_at_the_start_does_nothing() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "abc"); + } + + #[test] + fn delete_removes_the_character_after_the_caret() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(1); + ctx(&mut t, &mut d).delete(false); + assert_eq!(content(&t), "ac"); + } + + #[test] + fn delete_at_the_end_does_nothing() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(3); + ctx(&mut t, &mut d).delete(false); + assert_eq!(content(&t), "abc"); + } + + #[test] + fn select_all_then_typing_replaces_everything() { + let (mut t, mut d) = edit("hello", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + assert_eq!(t.selected_text().as_deref(), Some("hello")); + ctx(&mut t, &mut d).insert("x"); + assert_eq!(content(&t), "x"); + } + + #[test] + fn clearing_a_span_leaves_the_caret_at_its_start() { + let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + assert!(ctx(&mut t, &mut d).clear_span()); + assert_eq!(content(&t), ""); + assert_eq!(t.selection.unwrap().focus().index(), 0); + } + + /// The defect itself: an empty field's laid-out text is a zero-sized + /// box, so a tap anywhere in it used to land "outside" and clear the + /// selection -- leaving a focused composer that silently swallowed + /// every keystroke (RUST.md's P0 box item 2). + #[test] + fn tapping_an_empty_field_places_a_caret_so_typing_lands() { + let (mut t, mut d) = edit("", EditMode::MultiLine); + ctx(&mut t, &mut d).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false); + assert!(t.selection.is_some(), "a tap must leave a caret behind"); + ctx(&mut t, &mut d).insert("hi"); + assert_eq!(content(&t), "hi"); + } + + /// The half the fix had no reason to touch: a field that *does* hold + /// text, tapped past the end of it (a multi-line composer's padding + /// below the last line) keeps a caret rather than losing the one it + /// had, and the caret lands at the nearest position -- the end. + #[test] + fn tapping_past_the_end_of_the_text_clamps_to_the_end() { + let (mut t, mut d) = edit("abc", EditMode::MultiLine); + ctx(&mut t, &mut d).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false); + assert_eq!(t.selection.unwrap().focus().index(), 3); + } + + /// A drag still needs something to extend: with no previous selection + /// there is nothing to drag from, and one must not be invented. + #[test] + fn dragging_without_a_previous_selection_selects_nothing() { + let (mut t, mut d) = edit("abc", EditMode::MultiLine); + ctx(&mut t, &mut d).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false); + assert!(t.selection.is_none()); + } + + #[test] + fn a_single_line_field_refuses_newlines() { + let (mut t, mut d) = edit("", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).insert("a\nb"); + assert_eq!(content(&t), "ab"); + ctx(&mut t, &mut d).newline(); + assert_eq!(content(&t), "ab"); + } + + #[test] + fn a_multi_line_field_keeps_newlines() { + let (mut t, mut d) = edit("", EditMode::MultiLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).insert("a\nb"); + assert_eq!(content(&t), "a\nb"); + } + + #[test] + fn take_empties_the_field_and_hands_back_what_was_there() { + let (mut t, mut d) = edit("some text", EditMode::SingleLine); + assert_eq!(ctx(&mut t, &mut d).take(), "some text"); + assert_eq!(content(&t), ""); + } + + /// The IME's preedit path: each keystroke resends the whole composition, + /// so `replace` has to remove exactly what it added last time. + #[test] + fn ime_preedit_replaces_its_own_previous_text() { + let (mut t, mut d) = edit("", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).replace(0, "n"); + assert_eq!(content(&t), "n"); + ctx(&mut t, &mut d).replace(1, "ni"); + assert_eq!(content(&t), "ni"); + ctx(&mut t, &mut d).replace(2, "に"); + assert_eq!(content(&t), "に"); + } + + /// `android/ime.rs`'s `set_composing_text` calls `replace` and expects + /// the caret to land right after the inserted text, growing with it on + /// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't + /// enter it until I hit space, and also doesn't move cursor forward"). + #[test] + fn composing_advances_the_caret_with_the_growing_text() { + let (mut t, mut d) = edit("", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).replace(0, "h"); + assert_eq!(t.caret(), Some(1)); + ctx(&mut t, &mut d).replace(1, "hi"); + assert_eq!(content(&t), "hi"); + assert_eq!(t.caret(), Some(2)); + ctx(&mut t, &mut d).replace(2, "hit"); + assert_eq!(content(&t), "hit"); + assert_eq!(t.caret(), Some(3)); + } + + /// The IME's `commitText` (`android_view::InputConnection::commit_text`'s + /// default body): finish a composition in place, same as a real word + /// boundary (a space) landing after Gboard's composing span. + #[test] + fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() { + let (mut t, mut d) = edit("say ", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(4); + ctx(&mut t, &mut d).replace(0, "hi"); + assert_eq!(content(&t), "say hi"); + // `finish_composing_text`/`commit_text` do not themselves touch the + // buffer -- only the IME's own `compose_len` bookkeeping resets, in + // `android/ime.rs`. Confirms the buffer already holds committed + // text as plain, uncomposed content: a further `replace(0, " ")` + // (the space that ends the word) appends rather than overwriting. + ctx(&mut t, &mut d).replace(0, " "); + assert_eq!(content(&t), "say hi "); + assert_eq!(t.caret(), Some(7)); + } + + /// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry + /// point once `android/ime.rs` has converted UTF-16 code units to + /// bytes -- exercised directly here in bytes, since the UTF-16 math + /// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`, + /// outside this widget-only test module. + #[test] + fn delete_byte_range_removes_exactly_that_range() { + let (mut t, mut d) = edit("hello world", EditMode::SingleLine); + ctx(&mut t, &mut d).delete_byte_range(5, 11); + assert_eq!(content(&t), "hello"); + assert_eq!(t.caret(), Some(5)); + } + + /// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a + /// caret at the given byte offset regardless of any span that was there. + #[test] + fn set_cursor_byte_collapses_to_a_caret_there() { + let (mut t, mut d) = edit("hello world", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + ctx(&mut t, &mut d).set_cursor_byte(5); + assert_eq!(t.selected_text(), None); + assert_eq!(t.caret(), Some(5)); + } + + #[test] + fn motion_moves_the_caret_and_shift_extends_a_span() { + let (mut t, mut d) = edit("abc", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(0); + ctx(&mut t, &mut d).motion(Motion::Right, false); + assert_eq!(t.selection.unwrap().focus().index(), 1); + ctx(&mut t, &mut d).motion(Motion::Right, true); + assert_eq!(t.selected_text().as_deref(), Some("b")); + } + + /// Collapsing a span with an unshifted arrow goes to the near end rather + /// than stepping one character from the focus. + #[test] + fn an_unshifted_arrow_collapses_a_span_to_its_edge() { + let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); + ctx(&mut t, &mut d).select_all(); + ctx(&mut t, &mut d).motion(Motion::Left, false); + assert_eq!(t.selection.unwrap().focus().index(), 0); + + ctx(&mut t, &mut d).select_all(); + ctx(&mut t, &mut d).motion(Motion::Right, false); + assert_eq!(t.selection.unwrap().focus().index(), 6); + } + + /// Byte offsets, not character counts: a caret placed after a multi-byte + /// character must not split it. + #[test] + fn multibyte_text_is_edited_by_byte_offset() { + let (mut t, mut d) = edit("aé", EditMode::SingleLine); + ctx(&mut t, &mut d).set_caret(3); + ctx(&mut t, &mut d).backspace(false); + assert_eq!(content(&t), "a"); + } +} diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index cc9c94f..03b3068 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -6,11 +6,8 @@ pub use edit::*; use iris_core::util::MutDetect; use crate::prelude::*; -use cosmic_text::{Attrs, BufferLine, Cursor, Metrics, Shaping}; use std::ops::{Deref, DerefMut}; -pub const SHAPING: Shaping = Shaping::Advanced; - pub struct Text { pub content: MutDetect, view: TextView, @@ -25,6 +22,18 @@ pub struct TextView { pub hint: Option, } +impl TextView { + fn is_blank(&self) -> bool { + self.buf.is_empty() + } + + /// The width the text was last laid out against, so an editor asking for + /// the layout gets the same wrapping the last draw used. + pub fn wrap_width(&self) -> Option { + self.width + } +} + impl TextView { pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option) -> Self { Self { @@ -45,33 +54,44 @@ impl TextView { .align(self.align) } - fn tex_region(&self, tex: &RenderedText) -> UiRegion { - let region = tex.size.align(self.align); - let dims = tex.handle.size(); - let mut region = region.offset(tex.top_left_offset); - region.x.end = region.x.start + UiScalar::abs(dims.x); - region.y.end = region.y.start + UiScalar::abs(dims.y); - region - } - - fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText { + fn render(&mut self, painter: &mut Painter) -> RenderedText { let width = if self.attrs.wrap { - Some(ctx.px_size().x) + Some(painter.px_size().x) } else { None }; + // The atlas generation is part of the cache key, not a separate + // invalidation path: a `RenderedText` is only meaningful against the + // atlas its glyphs were placed in, and a renderer rebuild clears + // that atlas out from under every widget at once + // (`GlyphAtlas::clear`). Without this the text drawn before the + // rebuild is re-emitted with the old atlas's coordinates and comes + // back as fragments of whatever now occupies them. + let generation = painter.atlas_generation(); if width == self.width && let Some(tex) = &self.tex + && tex.generation == generation && !self.attrs.changed && !self.buf.changed { return tex.clone(); } self.width = width; - let font_system = &mut ctx.text.font_system; - self.attrs.apply(font_system, &mut self.buf, width); - self.buf.shape_until_scroll(font_system, false); - let tex = ctx.draw_text(&mut self.buf, &self.attrs); + let tex = painter.render_text(&mut self.buf, &self.attrs, width); + // Gated on `iris::diagnostics::trace_enabled` since 2026-09-07 + // (docs/RUST.md's review, D1): one line per text *shape* (a cache + // miss), unconditional, is many per frame while rows compose -- + // see `android::view::IrisViewPeer::render`'s own doc for the same + // finding on its two per-frame lines. + if crate::diagnostics::trace_enabled() { + log::debug!( + target: "iris::frame", + "iris text render: chars={} width={width:?} glyphs={} size={:?}", + self.buf.text().chars().count(), + tex.glyphs.len(), + tex.size, + ); + } self.tex = Some(tex.clone()); self.attrs.changed = false; self.buf.changed = false; @@ -80,98 +100,51 @@ impl TextView { pub fn tex(&self) -> Option<&RenderedText> { self.tex.as_ref() } - pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(hint) = &self.hint - && let [line] = &self.buf.lines[..] - && line.text().is_empty() + /// Draws within `painter.region()` and reports the size used -- what + /// `desired_width`/`desired_height` used to answer separately, folded + /// into the one draw (LAYOUT.md section 4): the shaped layout this + /// reads is already memoized by width in `render`, so a second call at + /// the same width (a redraw with nothing else changed) is a cache hit, + /// not a re-shape. + pub fn draw(&mut self, painter: &mut Painter) -> Size { + let tex = self.render(painter); + if self.is_blank() + && let Some(hint) = &self.hint { - ctx.width(hint) - } else { - Len::abs(self.render(ctx).size.x) + return painter.widget(hint); } - } - pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - if let Some(hint) = &self.hint - && let [line] = &self.buf.lines[..] - && line.text().is_empty() - { - ctx.height(hint) - } else { - Len::abs(self.render(ctx).size.y) - } - } - pub fn draw(&mut self, painter: &mut Painter) -> UiRegion { - let tex = self.render(&mut painter.size_ctx()); - let region = self.tex_region(&tex); - if let Some(hint) = &self.hint - && let [line] = &self.buf.lines[..] - && line.text().is_empty() - { - painter.widget(hint); - } else { - painter.texture_within(&tex.handle, region); - } - region + let region = tex.size.align(self.align); + let within = region.within(&painter.region()); + painter.glyphs(&tex, within); + Size::abs(tex.size) } pub fn content(&self) -> String { - self.buf - .lines - .iter() - .map(|l| l.text()) - .collect::>() - .join("\n") + self.buf.text().to_string() } } impl Text { pub fn new(content: impl Into) -> Self { - let attrs = TextAttrs::default(); - let buf = TextBuffer::new_empty(Metrics::new(attrs.font_size, attrs.line_height)); + let content: String = content.into(); Self { - content: content.into().into(), - view: TextView::new(buf, attrs, None), + view: TextView::new(TextBuffer::new(&content), TextAttrs::default(), None), + content: content.into(), } } - fn update_buf(&mut self, ctx: &mut SizeCtx) { + fn update_buf(&mut self) { if self.content.changed { self.content.changed = false; - self.view.buf.set_text( - &mut ctx.text.font_system, - &self.content, - &Attrs::new().family(self.view.attrs.family), - SHAPING, - None, - ); + self.view.buf.set_text(self.content.as_str()); } } } impl Widget for Text { - fn draw(&mut self, painter: &mut Painter) { - self.update_buf(&mut painter.size_ctx()); - self.view.draw(painter); + fn draw(&mut self, painter: &mut Painter) -> Size { + self.update_buf(); + self.view.draw(painter) } - - fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { - self.update_buf(ctx); - self.view.desired_width(ctx) - } - - fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { - self.update_buf(ctx); - self.view.desired_height(ctx) - } -} - -pub fn sort_cursors(a: Cursor, b: Cursor) -> (Cursor, Cursor) { - let start = a.min(b); - let end = a.max(b); - (start, end) -} - -pub fn edit_line(line: &mut BufferLine, text: String) { - line.set_text(text, line.ending(), line.attrs_list().clone()); } impl Deref for Text { @@ -201,3 +174,55 @@ impl DerefMut for TextView { &mut self.attrs } } + +#[cfg(test)] +mod tests { + use crate::layout_tests::TestRsc; + use crate::prelude::*; + + /// A renderer rebuild empties the glyph atlas under every widget at + /// once (`iris_core::GlyphAtlas::clear`, called from + /// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything + /// still holding a `RenderedText` from before then owns UV rectangles + /// into a texture that no longer exists -- what Iris photographed on + /// 2026-09-06 as every pre-resume glyph coming back as fragments while + /// the text drawn after the resume was perfect. + /// + /// The check is the atlas repopulating: `TextView::render`'s cache + /// short-circuits before `TextData::place`, so without the generation + /// in its key the second frame rasterises nothing and the atlas stays + /// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is + /// the same finding from the submission side.) + #[test] + fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() { + let mut rsc = TestRsc { + ui: UiData::default(), + }; + let root = wtext("hello there") + .size(18) + .color(UiColor::WHITE) + .add_strong(&mut rsc) + .any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + let rasterised = rsc.ui.text.atlas.glyph_count(); + assert!(rasterised > 0, "the first frame rasterised no glyphs"); + + // Exactly what the new-renderer branch does, in order: empty the + // atlas, then redraw everything (`resize` is what marks the tree + // for a full redraw, and a real `surface_changed` always calls it). + rsc.ui.text.atlas.clear(); + assert_eq!(rsc.ui.text.atlas.glyph_count(), 0); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + + assert_eq!( + rsc.ui.text.atlas.glyph_count(), + rasterised, + "the second frame re-emitted its cached glyphs instead of \ + re-rendering them against the fresh atlas" + ); + } +} diff --git a/src/widget/trait_fns.rs b/src/widget/trait_fns.rs index effebc8..13283ef 100644 --- a/src/widget/trait_fns.rs +++ b/src/widget/trait_fns.rs @@ -83,19 +83,51 @@ widget_trait! { } } - fn scrollable(self) -> impl WidgetIdFn where Rsc: HasEvents { + /// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with + /// the wheel and a finger drag both registered -- how anything with a + /// fixed layout becomes scrollable. + /// + /// `pin` says which end the area opens at and clings to as its content + /// grows, and it is spelled out rather than defaulted because the two + /// cases are not variations on each other: a composer wants the end, + /// where what is being typed is, and a code fence opened at the end of + /// its longest line, which is the middle of a word (seen in + /// `iris/run-headless.sh phone`, 2026-09-08). + /// + /// One method with the axis and the pin passed in, rather than the + /// three named variants this used to be (Iris, 2026-09-08: "can we + /// make both scroll methods become `.scrollable`, and it takes an axis + /// and a pin instead of having two?"). A code fence pans across its + /// own long lines exactly the way a transcript pans down its rows, so + /// the two are one mechanism with the direction passed in -- + /// `DragArbiter::on` is the other half. + /// + /// A [`LazySpan`] has an inherent `scrollable` of its own that this + /// does not reach: it owns a controller already and must not be + /// wrapped in an area that would slide it about as a lump. + fn scrollable(self, axis: Axis, pin: Pin) -> impl WidgetIdFn where Rsc: HasEvents { move |state| { - Scroll::new(self.add_strong(state), Axis::Y) - .on(CursorSense::Scroll, |ctx, rsc| { - let delta = ctx.data.scroll_delta.y * 50.0; - ctx.widget(rsc).scroll(delta); - }) - .add(state) + let area = ScrollArea::new(self.add_strong(state), axis, pin); + scroll_senses(area, axis)(state) } } fn masked(self) -> impl WidgetFn { move |state| Masked { + shape: None, + inner: self.add_strong(state), + } + } + + /// Clip to `shape` rather than to a plain box: `shape` is drawn + /// behind this widget, filling the same region, and what clips is the + /// primitive it drew -- so a rounded background and the corner its + /// content is cut to are one rect, with no radius passed twice. + /// Replaces `.masked().background(w)`, which drew the two but clipped + /// to the box. + fn masked_by(self, shape: impl WidgetLike) -> impl WidgetFn { + move |state| Masked { + shape: Some(shape.add_strong(state)), inner: self.add_strong(state), } } diff --git a/tabs-ui/Cargo.toml b/tabs-ui/Cargo.toml new file mode 100644 index 0000000..21805cb --- /dev/null +++ b/tabs-ui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tabs-ui" +version.workspace = true +edition.workspace = true + +# The tabs example's widget tree, factored out of iris/examples/tabs/main.rs +# so it can be built once and driven by either backend: the winit example +# binary, and iris/android-app's cdylib. Its own crate rather than a pub +# module of `iris` because it is demo content, not library surface -- see +# RUST.md's I2. + +[dependencies] +iris = { path = ".." } diff --git a/examples/tabs/assets/sungals.png b/tabs-ui/assets/sungals.png similarity index 100% rename from examples/tabs/assets/sungals.png rename to tabs-ui/assets/sungals.png diff --git a/tabs-ui/src/lib.rs b/tabs-ui/src/lib.rs new file mode 100644 index 0000000..f678ec4 --- /dev/null +++ b/tabs-ui/src/lib.rs @@ -0,0 +1,215 @@ +//! The tabs example's widget tree -- the five demo panes plus the message +//! composer that exercises `TextEdit`. Factored out of +//! `iris/examples/tabs/main.rs` (I2, RUST.md) so the same UI runs under +//! both backends: the winit example binary calls `build` from +//! `DefaultAppState::new`, and `iris-android-app`'s cdylib calls it from +//! `AndroidAppState::new`. Nothing here mentions either backend by name -- +//! it only needs `Rsc: HasEvents` (for `.on(...)`) and `Rsc::State: +//! FocusHost` (for `.attr::(())`), both of which every backend +//! implements. + +use iris::prelude::*; +use std::{cell::RefCell, rc::Rc}; + +pub struct ClientWidgets { + pub info: WeakWidget, +} + +pub fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> ClientWidgets +where + Rsc::State: FocusHost, +{ + let rrect = rect(Color::WHITE).radius(20); + let pad_test = ( + rrect.color(Color::BLUE), + ( + rrect + .color(Color::RED) + .sized((100, 100)) + .center() + .width(rest(2)), + ( + rrect.color(Color::ORANGE), + rrect.color(Color::LIME).pad(10.0), + ) + .span(Dir::RIGHT) + .width(rest(2)), + rrect.color(Color::YELLOW), + ) + .span(Dir::RIGHT) + .pad(10) + .width(rest(3)), + ) + .span(Dir::RIGHT) + .add(rsc); + + let span_test = ( + rrect.color(Color::GREEN).width(100), + rrect.color(Color::ORANGE), + rrect.color(Color::CYAN), + rrect.color(Color::BLUE).width(rel(0.5)), + rrect.color(Color::MAGENTA).width(100), + rrect.color(Color::RED).width(100), + ) + .span(Dir::LEFT) + .add(rsc); + + let span_add = Span::empty(Dir::RIGHT).add(rsc); + + let add_button = rect(Color::LIME) + .radius(30) + .on(CursorSense::click(), move |_, rsc| { + let child = image(include_bytes!("../assets/sungals.png")) + .center() + .add_strong(rsc); + span_add(rsc).push(child); + }) + .sized((150, 150)) + .align(Align::BOT_RIGHT); + + let del_button = rect(Color::RED) + .radius(30) + .on(CursorSense::click(), move |_, rsc| { + span_add(rsc).pop(); + }) + .sized((150, 150)) + .align(Align::BOT_LEFT); + + let span_add_test = (span_add, add_button, del_button).stack().add(rsc); + + let btext = |content| wtext(content).size(30); + + let text_test = ( + btext("this is a").align(Align::LEFT), + btext("teeeeeeeest").align(Align::RIGHT), + btext("okkk\nokkkkkk!").align(Align::LEFT), + btext("hmm"), + btext("a"), + ( + btext("'").family(Family::Monospace).align(Align::TOP), + btext("'").family(Family::Monospace), + btext(":gamer mode").family(Family::Monospace), + rect(Color::CYAN).sized((10, 10)).center(), + rect(Color::RED).sized((100, 100)).center(), + rect(Color::PURPLE).sized((50, 50)).align(Align::TOP), + ) + .span(Dir::RIGHT) + .center(), + wtext("pretty cool right?").size(50), + ) + .span(Dir::DOWN) + .add(rsc); + + let texts = Span::empty(Dir::DOWN).gap(10).add(rsc); + let msg_area = texts + .scrollable(Axis::Y, Pin::Start) + .masked() + .background(rect(Color::SKY)); + let add_text = wtext("add") + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .size(30) + .attr::(()) + .on(Submit, move |ctx, rsc| { + let w = ctx.widget; + let content = w.edit(rsc).take(); + let text = wtext(content) + .editable(EditMode::MultiLine) + .size(30) + .text_align(Align::LEFT) + .wrap(true) + .attr::(()); + let msg_box = text + .background(rect(Color::WHITE.darker(0.5))) + .add_strong(rsc); + texts(rsc).push(msg_box); + }) + .add(rsc); + + let text_edit_scroll = ( + msg_area.height(rest(1)), + ( + Rect::new(Color::WHITE.darker(0.9)), + ( + add_text.width(rest(1)), + Rect::new(Color::GREEN) + .on(CursorSense::click(), move |ctx, rsc: &mut Rsc| { + rsc.run_event::(add_text, (), ctx.state); + }) + .sized((40, 40)), + ) + .span(Dir::RIGHT) + .pad(10), + ) + .stack() + .size(StackSize::Child(1)) + .layer_offset(1) + .align(Align::BOT), + ) + .span(Dir::DOWN) + .add(rsc); + + let main = WidgetPtr::new().add(rsc); + + let vals = Rc::new(RefCell::new((0, Vec::new()))); + let mut switch_button = |color, to: WeakWidget, label| { + let to = to.upgrade(rsc); + let vec = &mut vals.borrow_mut().1; + let i = vec.len(); + if vec.is_empty() { + vec.push(None); + main(rsc).set(to); + } else { + vec.push(Some(to)); + } + let vals = vals.clone(); + let rect = rect(color) + .on(CursorSense::click(), move |ctx, rsc| { + let (prev, vec) = &mut *vals.borrow_mut(); + if let Some(h) = vec[i].take() { + vec[*prev] = main(rsc).replace(h); + *prev = i; + } + ctx.widget(rsc).color = color.darker(0.3); + }) + .on( + CursorSense::HoverStart | CursorSense::unclick(), + move |ctx, rsc| { + ctx.widget(rsc).color = color.brighter(0.2); + }, + ) + .on(CursorSense::HoverEnd, move |ctx, rsc| { + ctx.widget(rsc).color = color; + }) + // I4 (RUST.md): the tabs screen's only named controls, and the + // ones the emulator step at the bottom of that box taps by + // name -- `ui-trace record --do "tap 'pad'"` and so on. `.label` + // slots into this chain like any other widget combinator + // (`RefFnTag` in `core/src/widget/tag.rs`); it does not have to + // be the last thing before `.add`. + .label(label); + (rect, wtext(label).size(30).text_align(Align::CENTER)).stack() + }; + + let tabs = ( + switch_button(Color::RED, pad_test, "pad"), + switch_button(Color::GREEN, span_test, "span"), + switch_button(Color::BLUE, span_add_test, "image span"), + switch_button(Color::MAGENTA, text_test, "text layout"), + switch_button( + Color::YELLOW.mul_rgb(0.5), + text_edit_scroll, + "text edit scroll", + ), + ) + .span(Dir::RIGHT); + + let info = wtext("").add(rsc); + let info_sect = info.pad(10).align(Align::RIGHT); + + ((tabs.height(40), main.pad(10)).span(Dir::DOWN), info_sect) + .stack() + .set_root(rsc, ui_state); + + ClientWidgets { info } +} diff --git a/tests/mask_sdf.rs b/tests/mask_sdf.rs new file mode 100644 index 0000000..11a4670 --- /dev/null +++ b/tests/mask_sdf.rs @@ -0,0 +1,407 @@ +//! The CPU rounded-rect SDF and the shader's own must agree. +//! +//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips +//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the +//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with +//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped +//! and a corner that is not drawn are the same corner only while the two +//! functions answer the same. Nothing else checks that: both sides are +//! individually plausible and drift shows up as a control that is a pixel +//! or two off, which is exactly what nobody notices. +//! +//! So this runs **the real shader text**, lifted out of +//! `iris_core::SHAPE_SHADER` by name rather than copied here, over a grid +//! of points, and compares what came back with the Rust function at the +//! same points. This is the only test in the workspace that needs a GPU; +//! everything else about masks is layer 1 (docs/RUST.md's "Three test +//! layers"). It fails rather than skips when there is no adapter, because +//! a check that quietly did not run reads exactly like a check that +//! passed. +//! +//! **It is a render pass, and it asks for `iris_core::device_limits()`, +//! because those are the two things iris itself does.** The first version +//! of this test was a compute pass, which meant asking for compute limits +//! that `device_limits()` deliberately zeroes -- docs/RUST.md, 2026-09-05: +//! nothing in `iris`/`iris-core` creates a `ComputePipeline` or writes a +//! `@compute` stage, so the limits stopped being requested rather than a +//! fallback being built for a capability nothing uses. A test that needs +//! a capability the thing under test has never needed is testing the +//! wrong device, which is reason enough. +//! +//! It is **not** why that version crashed; see [`vulkan_instance`] for +//! what that crash actually was and why nothing here has to work around +//! it any more. + +// `OnceLock` needs `Instance: Sync`, and wgpu's type +// graph is deep enough that proving it overflows rustc's default trait +// recursion limit of 128. Nothing here is recursive; the limit is a +// compile-time budget, and this is the documented way to raise it. +#![recursion_limit = "256"] + +use std::sync::OnceLock; + +use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2}; +use pollster::FutureExt; +use wgpu::util::DeviceExt; + +/// The rect the grid is sampled against, in window pixels. Deliberately +/// off the whole-pixel grid: the shader floors a primitive's corners, but +/// `rounded_rect_coverage` is handed pixels either side of that and has to +/// agree at fractional positions too -- the phone's 2.55 density puts +/// nothing on a whole pixel. +const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25); +const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0); + +/// Radii spanning what the widgets actually ask for, plus the two edges of +/// the function's own domain: a square corner, and one large enough that +/// `min(edge, radius)` stops mattering. +const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0]; + +/// The grid, as an attachment: one texel per probe point. `GRID_W` is a +/// multiple of 64 so that a row of `R32Float` is 256-byte aligned, which +/// is what `copy_texture_to_buffer` requires; at `STEP` this spans the +/// rect above and about four pixels of margin on every side, so the +/// feather is sampled rather than stepped over. +const GRID_W: u32 = 384; +const GRID_H: u32 = 192; +const STEP: f32 = 0.5; +const ORIGIN: Vec2 = Vec2::new(TOP_LEFT.x - 4.0, TOP_LEFT.y - 4.0); + +/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and +/// `smoothstep` are each allowed a unit or two in the last place, and the +/// GPU may contract a multiply-add the CPU does not. A coverage is in +/// [0, 1], so this is about six decimal digits -- four orders of magnitude +/// tighter than the half-pixel feather the hit test reads, which is what +/// the agreement is actually for. +const TOLERANCE: f32 = 1e-5; + +#[test] +fn mask_sdf_matches_the_shader() { + let gpu = Gpu::open(); + let mut worst = 0.0f32; + let mut worst_at = (Vec2::new(0.0, 0.0), 0.0f32, 0.0f32, 0.0f32); + let (mut inside, mut feather, mut outside) = (0u32, 0u32, 0u32); + + for radius in RADII { + let coverage = run_shader(&gpu, radius); + for y in 0..GRID_H { + for x in 0..GRID_W { + let pos = probe_at(x, y); + let got = coverage[(y * GRID_W + x) as usize]; + let want = rounded_rect_coverage(pos, TOP_LEFT, BOT_RIGHT, radius); + let diff = (got - want).abs(); + if diff > worst { + worst = diff; + worst_at = (pos, radius, want, got); + } + if got > 0.999 { + inside += 1; + } else if got > 0.001 { + feather += 1; + } else { + outside += 1; + } + } + } + } + + let (pos, radius, want, got) = worst_at; + assert!( + worst <= TOLERANCE, + "shader.wgsl's rounded_rect_coverage and iris_core's disagree by {worst} at {pos:?} \ + (radius {radius}): the CPU says {want}, the GPU {got}. One of the two was edited \ + without the other -- they are transliterations and have to stay so, or a masked \ + corner stops being tappable where it is drawn.", + ); + + // The half that would pass on a function returning a constant. + assert!( + inside > 0 && feather > 0 && outside > 0, + "the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \ + so agreeing proved nothing", + ); +} + +/// The probe position of texel `(x, y)` -- the one place the mapping +/// lives, so the CPU side and the fragment stage cannot walk different +/// grids. +fn probe_at(x: u32, y: u32) -> Vec2 { + Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP) +} + +/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every texel +/// of an `R32Float` attachment: one fragment per grid point, read back +/// whole. A fragment stage because that is the stage the function is +/// really called from, so what this compares is the code path that draws +/// rather than a second one built to be measurable. +fn run_shader(gpu: &Gpu, radius: f32) -> Vec { + let Gpu { device, queue, .. } = gpu; + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("mask sdf probe"), + source: wgpu::ShaderSource::Wgsl(probe_source().into()), + }); + + // R32Float, not an 8-bit colour format: a coverage quantised to 1/255 + // could not be compared against the CPU's at anything like TOLERANCE, + // and the comparison would then be measuring the texture rather than + // the two functions. + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("mask sdf coverage"), + size: wgpu::Extent3d { + width: GRID_W, + height: GRID_H, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + + let probe = Probe { + top_left: [TOP_LEFT.x, TOP_LEFT.y], + bot_right: [BOT_RIGHT.x, BOT_RIGHT.y], + origin: [ORIGIN.x, ORIGIN.y], + step: [STEP, STEP], + radius, + _pad: [0.0; 3], + }; + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mask sdf probe"), + contents: bytemuck::bytes_of(&probe), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mask sdf probe"), + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("probe_vs"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("probe_fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::TextureFormat::R32Float.into())], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mask sdf probe"), + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }); + + // `copy_texture_to_buffer` wants each row 256-byte aligned; GRID_W is + // chosen so that it already is, rather than padding and unpicking the + // padding on the way out. + let row_bytes = GRID_W * 4; + assert_eq!(row_bytes % 256, 0, "GRID_W must keep rows 256-byte aligned"); + let out_size = u64::from(row_bytes) * u64::from(GRID_H); + let read_buf = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("mask sdf readback"), + size: out_size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut enc = device.create_command_encoder(&Default::default()); + { + let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("mask sdf probe"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + multiview_mask: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } + enc.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &read_buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row_bytes), + rows_per_image: Some(GRID_H), + }, + }, + wgpu::Extent3d { + width: GRID_W, + height: GRID_H, + depth_or_array_layers: 1, + }, + ); + queue.submit([enc.finish()]); + + let slice = read_buf.slice(..); + slice.map_async(wgpu::MapMode::Read, |r| r.expect("mapping the readback")); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("waiting for the probe"); + let mapped = slice + .get_mapped_range() + .expect("reading back the mapped probe buffer"); + let coverage = bytemuck::cast_slice::(&mapped).to_vec(); + drop(mapped); + read_buf.unmap(); + coverage +} + +/// One `wgpu::Instance` for the process, created on first use and never +/// destroyed. +/// +/// **Why it is a static rather than a value the test owns.** Destroying +/// the last `VkInstance` makes the Vulkan loader `dlclose` the ICD, and +/// Mesa's ICD here registers a `pthread_key_create` destructor pointing +/// into its own text without being linked `-z nodelete`. glibc then calls +/// that destructor when the thread exits -- through an address that is no +/// longer mapped. libtest runs every `#[test]` on a spawned thread, so a +/// test that opens and closes an instance segfaults *after* printing its +/// result, which reads exactly like the test failing. Measured +/// 2026-09-08 with `rigs/gpu-probe`'s `teardown` bin: it +/// needs no wgpu (raw `ash` does it too), no GPU work, and no device -- +/// an instance created and destroyed on a spawned thread is enough, and +/// keeping any one instance alive is enough to prevent it. +/// +/// Devices, queues and everything else drop normally; only the instance +/// is held, which is what wgpu asks for anyway (one instance per +/// process). So this costs one instance for the length of a test binary +/// and buys ordinary drops everywhere else. +fn vulkan_instance() -> &'static wgpu::Instance { + static INSTANCE: OnceLock = OnceLock::new(); + INSTANCE.get_or_init(wgpu::Instance::default) +} + +/// The device this test draws with. +struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, +} + +impl Gpu { + /// Opens the device this test draws with, and reports which adapter + /// answered, because that is not a detail here: a run on llvmpipe and + /// a run on the host's GPU are otherwise indistinguishable in the + /// log, and only one of them is a check of what the phone will do. + fn open() -> Self { + let instance = vulkan_instance(); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions::default()) + .block_on() + .expect( + "no wgpu adapter on this machine, so the CPU/shader SDF agreement went \ + unchecked. This VM has a virtio-gpu render node (the `this-machine-graphics` \ + skill says what it is and how it fails); if that is gone, fix it rather \ + than deleting this test.", + ); + let info = adapter.get_info(); + eprintln!( + "mask_sdf: {} ({:?}, {})", + info.name, info.backend, info.driver + ); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + // What iris itself asks for -- see this file's header. + required_limits: iris_core::device_limits(), + ..Default::default() + }) + .block_on() + .expect("could not get a device from the adapter"); + Self { device, queue } + } +} + +/// What the fragment stage needs to turn its own texel into a probe +/// position: the rect being sampled, and where texel (0, 0) sits. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct Probe { + top_left: [f32; 2], + bot_right: [f32; 2], + origin: [f32; 2], + step: [f32; 2], + radius: f32, + _pad: [f32; 3], +} + +/// The probe module: the two functions **lifted from `shader.wgsl` +/// itself**, plus an entry point that calls the outer one. Lifted rather +/// than copied so there is nothing to keep in step -- an edit to the +/// shader is what this test is for, and a copy here would be edited along +/// with it. +fn probe_source() -> String { + format!( + "{}\n{}\n\ + struct Probe {{\n\ + top_left: vec2,\n\ + bot_right: vec2,\n\ + origin: vec2,\n\ + step: vec2,\n\ + radius: f32,\n\ + }}\n\ + @group(0) @binding(0) var probe: Probe;\n\ + @vertex\n\ + fn probe_vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4 {{\n\ + var xy = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));\n\ + return vec4(xy[vi], 0.0, 1.0);\n\ + }}\n\ + @fragment\n\ + fn probe_fs(@builtin(position) pos: vec4) -> @location(0) f32 {{\n\ + let at = probe.origin + floor(pos.xy) * probe.step;\n\ + return rounded_rect_coverage(at, probe.top_left, probe.bot_right, probe.radius);\n\ + }}\n", + wgsl_fn("distance_from_rect"), + wgsl_fn("rounded_rect_coverage"), + ) +} + +/// One WGSL function's whole text, from its `fn` keyword to the `}` that +/// closes its body, found by matching braces. Panics by name when the +/// function is not there, which is what a rename looks like from here. +fn wgsl_fn(name: &str) -> &'static str { + let start = SHAPE_SHADER + .find(&format!("fn {name}(")) + .unwrap_or_else(|| panic!("shader.wgsl has no `fn {name}(` -- renamed, or gone")); + let body = SHAPE_SHADER[start..] + .find('{') + .expect("a wgsl fn signature is followed by its body"); + let mut depth = 0usize; + for (i, c) in SHAPE_SHADER[start + body..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return &SHAPE_SHADER[start..start + body + i + 1]; + } + } + _ => {} + } + } + panic!("`fn {name}`'s body in shader.wgsl is never closed"); +}