diff --git a/.gitmodules b/.gitmodules index 6f76f13..d1200a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "wg-app-link"] path = wg-app-link url = git@git.arirex.me:iris/wg-app-link.git +[submodule "iris"] + path = iris + url = git@git.arirex.me:iris-ai/iris.git + branch = main diff --git a/AGENTS.md b/AGENTS.md index f39ac4c..0fc93f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,8 @@ # ai-app A phone and desktop interface to AI coding sessions. The backend is Rust/Axum; -the shared client and UI are Rust, drawn by the in-tree `iris` framework. The +the shared client and UI are Rust, drawn by the `iris` framework pinned as a +submodule. The Android app uses a thin Java activity and `android-view`; desktop uses winit. `docs/PLAN.md` is the design source of truth. Read it before structural work @@ -32,12 +33,14 @@ dependency runs one way. contains Iris widget trees; `android` and `desktop` are thin hosts. `android-project/` packages the Rust cdylib. The `bench` feature and `bench-fixture/` are retained performance rigs, not a second app. -- `iris/` — the framework, proc macro, tabs demo, and input rig. +- `iris/` — the pinned framework submodule: proc macro, demos, and input rig. - `scripts/` — repository-wide scripts and independent profiling rigs. -- `wg-app-link/` — a git submodule shared with dev-updater. Clone with - `--recurse-submodules` or run `git submodule update --init`. +- `wg-app-link/` — a git submodule shared with dev-updater. - `docs/` — design and working documents. +Clone with `--recurse-submodules` or run `git submodule update --init` to +populate both `iris/` and `wg-app-link/`. + Nerd Font icons are an app-owned committed subset. `app/build-icon-font.sh` produces `app/assets/fonts/nerd_icons.ttf`; its codepoints must match `app/src/ui/icon.rs`. The app registers it with Iris at startup. Body and diff --git a/docs/PLAN.md b/docs/PLAN.md index 9931ca8..1ee49be 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -39,6 +39,13 @@ backend (Rust/Axum, desktop) └─ config.ron + per-session transcript files ``` +**Iris is developed in its own repository and pinned here as a submodule** +(2026-09-12). The framework accumulated enough generally useful Android, +desktop, layout, text, input, accessibility, rendering, and packaging work to +stand independently of this product. Keeping it under `iris/` preserves the +app's local path dependency and test commands, while the repository boundary +keeps product code and framework code from sharing commits. + ## Architecture ### Setups and providers (2026-08-28) diff --git a/docs/RUST.md b/docs/RUST.md index 72854ee..b96f03c 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -1,7 +1,8 @@ # Rust app and Iris The production app is one Rust crate under `app/`, with shared Android and -desktop UI drawn by the in-tree `iris` framework. This document keeps the +desktop UI drawn by the `iris` framework pinned under `iris/` as a submodule. +This document keeps the open work and measured constraints that are expensive to rediscover. ## Boundaries diff --git a/iris b/iris new file mode 160000 index 0000000..32f6ad8 --- /dev/null +++ b/iris @@ -0,0 +1 @@ +Subproject commit 32f6ad8c795ea2badf4ade7a51c2c959e8c96921 diff --git a/iris/.gitignore b/iris/.gitignore deleted file mode 100644 index 3b1d403..0000000 --- a/iris/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -perf.data* diff --git a/iris/Cargo.lock b/iris/Cargo.lock deleted file mode 100644 index 0c452ee..0000000 --- a/iris/Cargo.lock +++ /dev/null @@ -1,4848 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android-activity" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" -dependencies = [ - "android-properties", - "bitflags 2.13.1", - "cc", - "jni 0.22.4", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "num_enum", - "thiserror 2.0.20", -] - -[[package]] -name = "android-properties" -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.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "image", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", -] - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "ash" -version = "0.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "atspi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.20", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "bit-set" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.13.1", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "camino" -version = "1.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-iris" -version = "0.1.0" -dependencies = [ - "cargo_metadata", -] - -[[package]] -name = "cargo-platform" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.20", -] - -[[package]] -name = "cc" -version = "1.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "combine" -version = "4.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -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 0.8.9", - "num-complex", - "pulp", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "fax" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", -] - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontique" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" -dependencies = [ - "hashbrown 0.17.1", - "linebender_resource_handle", - "memmap2", - "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]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "foreign-types-shared" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glow" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-allocator" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" -dependencies = [ - "ash", - "hashbrown 0.16.1", - "log", - "presser", - "thiserror 2.0.20", - "windows", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "harfrust" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "read-fonts", - "smallvec", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "hermit-abi" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "iris" -version = "0.1.0" -dependencies = [ - "accesskit", - "accesskit_android", - "accesskit_winit", - "android-view", - "arboard", - "bytemuck", - "image", - "iris-core", - "iris-macro", - "log", - "parley", - "pollster", - "send_wrapper", - "swash", - "tokio", - "wgpu", - "winit", -] - -[[package]] -name = "iris-core" -version = "0.1.0" -dependencies = [ - "accesskit", - "bytemuck", - "fxhash", - "image", - "parley", - "pollster", - "swash", - "wgpu", -] - -[[package]] -name = "iris-macro" -version = "0.1.0" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" -dependencies = [ - "bitflags 2.13.1", - "libc", - "plain", - "redox_syscall 0.9.3", -] - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] - -[[package]] -name = "naga" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.17.1", - "indexmap", - "libm", - "log", - "naga-types", - "num-traits", - "once_cell", - "rustc-hash", - "spirv", - "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.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "new_debug_unreachable" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "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 0.2.2", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-graphics", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "dispatch", - "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "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.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "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]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "orbclient" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" -dependencies = [ - "libc", - "libredox", -] - -[[package]] -name = "ordered-float" -version = "5.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - -[[package]] -name = "phf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pulp" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" -dependencies = [ - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror 2.0.20", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "raw-window-metal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" -dependencies = [ - "bytemuck", - "font-types", - "once_cell", -] - -[[package]] -name = "reborrow" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_syscall" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "rig-input" -version = "0.1.0" -dependencies = [ - "iris", - "wayland-client", - "wayland-protocols-wlr", -] - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "rustc-hash" -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.13.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sctk-adwaita" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit", - "tiny-skia", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[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 = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" -dependencies = [ - "bytemuck", - "read-fonts", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" - -[[package]] -name = "smithay-client-toolkit" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.1", - "calloop", - "calloop-wayland-source", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - -[[package]] -name = "spirv" -version = "0.4.0+sdk-1.4.341.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" - -[[package]] -name = "swash" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" -dependencies = [ - "skrifa", - "yazi", - "zeno", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" -dependencies = [ - "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 = "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.61.2", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "pin-project-lite", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow", -] - -[[package]] -name = "tracing" -version = "0.1.44" -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" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom", - "petgraph", -] - -[[package]] -name = "ttf-parser" -version = "0.25.1" -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 = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "ui-events" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4c2cc34489c685d4e7a1a1f97b7b4416c5aa789892114ae77df6cd2a60f0ec4" -dependencies = [ - "dpi", - "keyboard-types", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-width" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.5", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wayland-backend" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" -dependencies = [ - "bitflags 2.13.1", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.1", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.4", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" -dependencies = [ - "arrayvec", - "bitflags 2.13.1", - "bytemuck", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "js-sys", - "log", - "naga", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.13.1", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "indexmap", - "log", - "naga", - "naga-types", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash", - "smallvec", - "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 = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-emscripten" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.13.1", - "block2 0.6.2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "glow", - "glutin_wgl_sys", - "gpu-allocator", - "hashbrown 0.17.1", - "js-sys", - "khronos-egl", - "libc", - "libloading", - "log", - "naga", - "naga-types", - "ndk-sys", - "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", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "raw-window-metal", - "renderdoc-sys", - "smallvec", - "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 = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "js-sys", - "log", - "naga-types", - "raw-window-handle", - "static_assertions", - "web-sys", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.13.1", - "block2 0.5.1", - "bytemuck", - "calloop", - "cfg_aliases", - "concurrent-queue", - "core-foundation", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit", - "orbclient", - "percent-encoding", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", - "smol_str", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.20", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading", - "once_cell", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xcursor" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.13.1", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yazi" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -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 = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "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/iris/Cargo.toml b/iris/Cargo.toml deleted file mode 100644 index a973caa..0000000 --- a/iris/Cargo.toml +++ /dev/null @@ -1,105 +0,0 @@ -[package] -name = "iris" -version.workspace = true -edition.workspace = true - -[dependencies] -iris-core = { workspace = true } -iris-macro = { workspace = true } -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", "time"] } -# The embedding app installs the logger. -log = "0.4.34" - -# winit's Android backend conflicts with android-view, which owns that platform here. -[target.'cfg(not(target_os = "android"))'.dependencies] -winit = { workspace = true } -arboard = { workspace = true, features = ["wayland-data-control"] } -accesskit_winit = "0.34.0" - -# Advancing this measured revision requires rechecking rendering, IME, and detach. -[target.'cfg(target_os = "android")'.dependencies] -android-view = { git = "https://github.com/rust-mobile/android-view.git", rev = "bec6c62a96cef8239b0fd7fedeef9b184d02e3a1" } -# 0.8.0 still aborts on detach; `view.rs::raise_if_enabled` mitigates it. -accesskit_android = "0.8.0" -send_wrapper = "0.6.0" - -[features] -# Forces GL on Vulkan-capable hosts for comparisons. The emulator already falls back -# to hardware GLES; enabling this there would make its build unlike the phone's. -force-gles = [] - -[dev-dependencies] -bytemuck = { workspace = true } - -[[example]] -name = "bench_images" -path = "examples/bench_images/desktop.rs" - -[[example]] -name = "message_list" -path = "examples/message_list/desktop.rs" - -[[example]] -name = "minimal" -path = "examples/minimal/desktop.rs" - -[[example]] -name = "tabs" -path = "examples/tabs/desktop.rs" - -[[example]] -name = "task" -path = "examples/task/desktop.rs" - -[[example]] -name = "text" -path = "examples/text/desktop.rs" - -[[example]] -name = "view" -path = "examples/view/desktop.rs" - -[[bench]] -name = "message_list" -harness = false - -[workspace] -members = [ - "cargo-iris", - "core", - "macro", - "rig-input", -] - -[workspace.package] -version = "0.1.0" -edition = "2024" - -# Full DWARF once produced 54 GB of writes and an 88 GB target because every test -# statically links the renderer stack. Use `RUSTFLAGS="-C debuginfo=2"` when needed. -[profile.dev] -debug = "line-tables-only" - -[profile.test] -debug = "line-tables-only" - -[workspace.dependencies] -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.53.1" diff --git a/iris/TODO b/iris/TODO deleted file mode 100644 index 3084554..0000000 --- a/iris/TODO +++ /dev/null @@ -1,75 +0,0 @@ -# iris: known problems and things still to build - -Only open Iris framework work lives here. Delete an item when it lands. - -## Build (for the port) - -Framework capabilities needed by `RUST.md`'s port plan: - -- [ ] **Overflow ellipsis with an explicit retained end.** `TextAttrs` can - only wrap or clip, so a tool summary is cut with no mark. Parley has no - ellipsis primitive; use its line breaker to find the cut, but keep source - and displayed strings distinct with one byte mapping shared by spans, - links, selection and editing. Replace `wrap: bool` with an enum that can - say wrap, clip, head ellipsis and tail ellipsis—the caller must choose - because a command is identified by its head and a path by its tail. -- [ ] **Expose the distance from a `LazySpan` viewport to its unloaded - edge.** (**P1**.) `viewport_len` and the visible extents are already - measured internally, but a paging caller cannot ask whether it is within - the product's six-viewport `HISTORY_SCREENS` cushion. The API should - answer in pixels or viewport multiples, never rows: a row ranges from one - line to a screen, so a fixed row count is not a distance. -- [ ] **Let an image fit a bounded box while preserving its aspect ratio.** - (**P1**.) `Image` currently always reports and draws the decoded texture's - natural pixel size. Decoding and fetching a server-produced attachment - belong in `app`; iris only owes the generic fit/scale widget used to - draw its thumbnail. -- [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is - already monospace and coloured, but the inline-code chip also needs - the glyph run's boxes so a surface can be drawn behind exactly that byte - range. The shared `TextSelection` engine already computes the same geometry - for selection highlights; expose one shared primitive rather than giving - the app a second text-layout path. -- [ ] **A horizontal gauge/bar widget.** (**P1**.) For - `SessionUsageBar`'s equivalent — a bounded fill reflecting a fraction, - nothing fancier. -- [ ] **A toggle switch.** (**P3**.) For the delete dialog's - `deleteForeign` control; iris has no switch/checkbox widget yet as far - as this pass found. - -## Later - -- [ ] **Intern independently constructed solid paint definitions.** Inline - `rect(Srgba8::...)` values currently receive a new `PaintId` each time. - Cache them by canonical linear RGBA bits, but keep `Paints::add` explicitly - unique so two semantic theme roles that start with the same value can later - change independently. Cache entries must be weak and disappear when the - last real handle releases the slot; gradients and texture paints need their - own identity rules rather than inheriting solid-value interning blindly. - -- [ ] **Property/content animations.** Cosmetic, so after correctness and - parity. Keep them modular, like input; scrolling already animates through - `Widget::tick` and `UiData::animate`. A widget that does not opt in must - pay nothing and import nothing for them. - -- [ ] **Remove `WidgetView` unless a real composite adopts it.** Every - composite in `app/src/ui` uses ordinary child handles plus a root; - `WidgetView` and its derive are used only by `iris/examples/view/lib.rs`. - It currently demonstrates itself rather than shortening production code. - -- [ ] **A `Stack` that chooses its mask the way it chooses its size - should replace `masked_by`.** For a square-cornered surface, - `.background(rect(BAR_FILL)).masked()` was measured - against `.masked_by(rect(BAR_FILL))` on the composer at the phone's own - size and density and the two are identical to the pixel. What the pair - cannot express is a clip that is not a box: `Painter::set_mask` writes - a `RectPrimitive::color` using `PaintId::NONE` at the widget's own region, - with no radius, so `.background(rect(fill).radius(r)).masked()` draws a - rounded panel and then cuts its content square. Both other call sites - (`row.rs`'s fence, `tool.rs`'s raw output) are rounded, which is why - the method stands for now. - Let `Stack` name the mask child the way `StackSize::Child(n)` names the - sizing child. Then `.background(x)` remains the one way to add a surface - and clipping to it is a stack property; the named mask child must have - drawn before any child that uses it. Once that exists, delete - `masked_by` and `Masked::shape` rather than retaining two APIs. diff --git a/iris/benches/message_list.rs b/iris/benches/message_list.rs deleted file mode 100644 index 46a2117..0000000 --- a/iris/benches/message_list.rs +++ /dev/null @@ -1,383 +0,0 @@ -use iris::prelude::*; -use std::time::Instant; - -struct BenchRsc { - ui: Ui, -} - -impl UiRsc for BenchRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } -} - -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."; - -fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { - let text = wtext(format!("Message {i}: {BODY}")) - .overflow(TextOverflow::Wrap) - .add_strong(rsc) - .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 - } -} - -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); - (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 - ); -} - -fn bench_first_frame(n: usize) { - let mut rsc = BenchRsc { ui: Ui::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 RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = render.take_counters(); - report( - &format!("(a) first frame, N={n}"), - elapsed, - draws, - rewrites, - moves, - ); -} - -fn bench_scroll(n: usize, ticks: usize) { - let mut rsc = BenchRsc { ui: Ui::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 RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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 - ); -} - -fn bench_input_grows(n: usize, lines: usize) { - let mut rsc = BenchRsc { ui: Ui::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(PaintId::WHITE)); - let input_area = rsc.ui.widgets.add_strong(Sized { - inner: input_rect.any(), - x: None, - y: Some(abs(line_height).into()), - }); - - 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).into()); - let start = Instant::now(); - render.update(&root, &mut rsc); - total += start.elapsed(); - let RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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 - ); -} - -fn bench_insert_above_anchor(n: usize, inserts: usize) { - let mut rsc = BenchRsc { ui: Ui::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 { - 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 RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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 - ); -} - -fn bench_expand_holds_edge(n: usize, growths: usize) { - let mut rsc = BenchRsc { ui: Ui::default() }; - let mut list = LazySpan::new(Dir::DOWN, Pin::End); - 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(PaintId::WHITE)); - let sized = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(abs(40.0).into()), - }); - 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).into()); - let start = Instant::now(); - render.update(&root, &mut rsc); - total += start.elapsed(); - let RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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 - ); -} - -fn bench_redraw_big_text(chars: usize, redraws: usize) { - let mut rsc = BenchRsc { ui: Ui::default() }; - let content: String = (0..chars) - .map(|i| char::from(b'a' + (i % 26) as u8)) - .collect(); - let text = wtext(content) - .overflow(TextOverflow::Wrap) - .add_strong(&mut rsc); - 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 { - rsc.ui.widgets.get_mut(&handle).unwrap(); - let start = Instant::now(); - render.update(&root, &mut rsc); - total += start.elapsed(); - } - let RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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/iris/cargo-iris/Cargo.toml b/iris/cargo-iris/Cargo.toml deleted file mode 100644 index 557fea7..0000000 --- a/iris/cargo-iris/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "cargo-iris" -version.workspace = true -edition.workspace = true - -[dependencies] -cargo_metadata = "0.23.1" diff --git a/iris/cargo-iris/android-host/dev/iris/android/IrisView.java b/iris/cargo-iris/android-host/dev/iris/android/IrisView.java deleted file mode 100644 index 49829bd..0000000 --- a/iris/cargo-iris/android-host/dev/iris/android/IrisView.java +++ /dev/null @@ -1,50 +0,0 @@ -package dev.iris.android; - -import android.app.Activity; -import android.content.Context; -import android.view.Gravity; -import android.widget.ScrollView; -import android.widget.TextView; -import org.linebender.android.rustview.RustView; - -public final class IrisView extends RustView { - @Override - protected native long newViewPeer(Context context); - - native void applyWindowInsetsNative( - long peer, int left, int top, int right, int bottom, int imeBottom, int imeVisible); - - native void unregisterInsetsNative(long peer); - - public IrisView(Context context) { - super(context); - } - - void applyWindowInsets( - int left, int top, int right, int bottom, int imeBottom, int imeVisible) { - applyWindowInsetsNative(mViewPeer, left, top, right, bottom, imeBottom, imeVisible); - } - - @Override - protected void onDetachedFromWindow() { - unregisterInsetsNative(mViewPeer); - super.onDetachedFromWindow(); - } - - void showRendererError(String report) { - Context context = getContext(); - if (!(context instanceof Activity)) { - return; - } - Activity activity = (Activity) context; - TextView text = new TextView(activity); - text.setText(report); - text.setTextIsSelectable(true); - text.setGravity(Gravity.TOP | Gravity.START); - int pad = (int) (16 * activity.getResources().getDisplayMetrics().density); - text.setPadding(pad, pad, pad, pad); - ScrollView scroll = new ScrollView(activity); - scroll.addView(text); - activity.setContentView(scroll); - } -} diff --git a/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java b/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java deleted file mode 100644 index 1e63b44..0000000 --- a/iris/cargo-iris/android-host/dev/iris/android/MainActivity.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.iris.android; - -import android.app.Activity; -import android.os.Build; -import android.os.Bundle; -import android.view.WindowInsets; -import android.view.WindowInsetsAnimation; -import android.widget.FrameLayout; -import java.util.List; - -public final class MainActivity extends Activity { - static { - System.loadLibrary("IRIS_NATIVE_LIBRARY"); - } - - @Override - public void onCreate(Bundle state) { - super.onCreate(state); - IrisView view = new IrisView(this); - view.setLayoutParams(new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); - view.setFocusable(true); - view.setFocusableInTouchMode(true); - FrameLayout layout = new FrameLayout(this); - layout.addView(view); - setContentView(layout); - view.requestFocus(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - drawBehindSystemBars(); - view.setWindowInsetsAnimationCallback(new WindowInsetsAnimation.Callback( - WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) { - @Override - public WindowInsets onProgress( - WindowInsets insets, List running) { - sendInsets(view, insets); - return insets; - } - - @Override - public void onEnd(WindowInsetsAnimation animation) { - WindowInsets settled = view.getRootWindowInsets(); - if (settled != null) { - sendInsets(view, settled); - } - } - }); - } - - view.setOnApplyWindowInsetsListener((v, insets) -> { - sendInsets((IrisView) v, insets); - return insets; - }); - } - - // Android 15 deprecated this API in favor of edge-to-edge enforcement, - // but API 30 through 34 still need it and Iris supports that whole range. - @SuppressWarnings("deprecation") - private void drawBehindSystemBars() { - getWindow().setDecorFitsSystemWindows(false); - } - - // API 29 has no replacement for these four system-window inset getters; - // the API 30 methods cannot run on Iris's supported minimum. - @SuppressWarnings("deprecation") - private static void sendInsets(IrisView view, WindowInsets insets) { - int imeBottom = 0; - int imeVisible = 0; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - imeBottom = insets.getInsets(WindowInsets.Type.ime()).bottom; - imeVisible = insets.isVisible(WindowInsets.Type.ime()) ? 1 : 0; - } - view.applyWindowInsets( - insets.getSystemWindowInsetLeft(), - insets.getSystemWindowInsetTop(), - insets.getSystemWindowInsetRight(), - insets.getSystemWindowInsetBottom(), - imeBottom, - imeVisible); - } -} diff --git a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java deleted file mode 100644 index 0a3a94d..0000000 --- a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustInputConnection.java +++ /dev/null @@ -1,153 +0,0 @@ -package org.linebender.android.rustview; - -import android.os.Bundle; -import android.os.Handler; -import android.view.KeyEvent; -import android.view.inputmethod.CompletionInfo; -import android.view.inputmethod.CorrectionInfo; -import android.view.inputmethod.ExtractedText; -import android.view.inputmethod.ExtractedTextRequest; -import android.view.inputmethod.InputConnection; -import android.view.inputmethod.InputContentInfo; - -class RustInputConnection implements InputConnection { - private final RustView mView; - - RustInputConnection(RustView view) { - mView = view; - } - - private long getViewPeer() { - return mView.mViewPeer; - } - - @Override - public CharSequence getTextBeforeCursor(int n, int flags) { - return mView.getTextBeforeCursorNative(getViewPeer(), n); - } - - @Override - public CharSequence getTextAfterCursor(int n, int flags) { - return mView.getTextAfterCursorNative(getViewPeer(), n); - } - - @Override - public CharSequence getSelectedText(int flags) { - return mView.getSelectedTextNative(getViewPeer()); - } - - @Override - public int getCursorCapsMode(int reqModes) { - return mView.getCursorCapsModeNative(getViewPeer(), reqModes); - } - - @Override - public ExtractedText getExtractedText(ExtractedTextRequest request, int flags) { - return null; - } - - @Override - public boolean deleteSurroundingText(int beforeLength, int afterLength) { - return mView.deleteSurroundingTextNative(getViewPeer(), beforeLength, afterLength); - } - - @Override - public boolean deleteSurroundingTextInCodePoints(int beforeLength, int afterLength) { - return mView.deleteSurroundingTextInCodePointsNative(getViewPeer(), beforeLength, afterLength); - } - - @Override - public boolean setComposingText(CharSequence text, int newCursorPosition) { - return mView.setComposingTextNative(getViewPeer(), text.toString(), newCursorPosition); - } - - @Override - public boolean setComposingRegion(int start, int end) { - return mView.setComposingRegionNative(getViewPeer(), start, end); - } - - @Override - public boolean finishComposingText() { - return mView.finishComposingTextNative(getViewPeer()); - } - - @Override - public boolean commitText(CharSequence text, int newCursorPosition) { - return mView.commitTextNative(getViewPeer(), text.toString(), newCursorPosition); - } - - @Override - public boolean commitCompletion(CompletionInfo text) { - return false; - } - - @Override - public boolean commitCorrection(CorrectionInfo correctionInfo) { - return false; - } - - @Override - public boolean setSelection(int start, int end) { - return mView.setSelectionNative(getViewPeer(), start, end); - } - - @Override - public boolean performEditorAction(int editorAction) { - return mView.performEditorActionNative(getViewPeer(), editorAction); - } - - @Override - public boolean performContextMenuAction(int id) { - return mView.performContextMenuActionNative(getViewPeer(), id); - } - - @Override - public boolean beginBatchEdit() { - return mView.beginBatchEditNative(getViewPeer()); - } - - @Override - public boolean endBatchEdit() { - return mView.endBatchEditNative(getViewPeer()); - } - - @Override - public boolean sendKeyEvent(KeyEvent event) { - return mView.inputConnectionSendKeyEventNative(getViewPeer(), event); - } - - @Override - public boolean clearMetaKeyStates(int states) { - return mView.inputConnectionClearMetaKeyStatesNative(getViewPeer(), states); - } - - @Override - public boolean reportFullscreenMode(boolean enabled) { - return mView.inputConnectionReportFullscreenModeNative(getViewPeer(), enabled); - } - - @Override - public boolean performPrivateCommand(String action, Bundle data) { - return false; - } - - @Override - public boolean requestCursorUpdates(int cursorUpdateMode) { - return mView.requestCursorUpdatesNative(getViewPeer(), cursorUpdateMode); - } - - @Override - public Handler getHandler() { - return null; - } - - @Override - public void closeConnection() { - mView.closeInputConnectionNative(getViewPeer()); - } - - @Override - public boolean commitContent(InputContentInfo inputContentInfo, int flags, Bundle opts) { - return false; - } -} diff --git a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java b/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java deleted file mode 100644 index b7fa4ad..0000000 --- a/iris/cargo-iris/android-host/org/linebender/android/rustview/RustView.java +++ /dev/null @@ -1,287 +0,0 @@ -package org.linebender.android.rustview; - -import android.content.Context; -import android.graphics.Rect; -import android.os.Bundle; -import android.view.Choreographer; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.accessibility.AccessibilityNodeInfo; -import android.view.accessibility.AccessibilityNodeProvider; -import android.view.inputmethod.EditorInfo; -import android.view.inputmethod.InputConnection; -import android.view.inputmethod.InputMethodManager; - -public abstract class RustView extends SurfaceView - implements SurfaceHolder.Callback, Choreographer.FrameCallback { - // Vendored from android-view bec6c62. The only local change is `protected`, - // allowing IrisView to forward insets through this native peer. - protected final long mViewPeer; - final InputMethodManager mInputMethodManager; - - protected abstract long newViewPeer(Context context); - - public RustView(Context context) { - super(context); - mViewPeer = newViewPeer(context); - getHolder().addCallback(this); - mInputMethodManager = - (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); - } - - private native int[] onMeasureNative(long peer, int widthSpec, int heightSpec); - - @Override - protected void onMeasure(int widthSpec, int heightSpec) { - int[] result = onMeasureNative(mViewPeer, widthSpec, heightSpec); - if (result != null) { - setMeasuredDimension(result[0], result[1]); - } else { - super.onMeasure(widthSpec, heightSpec); - } - } - - private native void onLayoutNative( - long peer, boolean changed, int left, int top, int right, int bottom); - - @Override - protected void onLayout(boolean changed, int left, int top, int right, int bottom) { - onLayoutNative(mViewPeer, changed, left, top, right, bottom); - super.onLayout(changed, left, top, right, bottom); - } - - private native void onSizeChangedNative(long peer, int w, int h, int oldw, int oldh); - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - onSizeChangedNative(mViewPeer, w, h, oldw, oldh); - super.onSizeChanged(w, h, oldw, oldh); - } - - private native boolean onKeyDownNative(long peer, int keyCode, KeyEvent event); - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - return onKeyDownNative(mViewPeer, keyCode, event) || super.onKeyDown(keyCode, event); - } - - private native boolean onKeyUpNative(long peer, int keyCode, KeyEvent event); - - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - return onKeyUpNative(mViewPeer, keyCode, event) || super.onKeyUp(keyCode, event); - } - - private native boolean onTrackballEventNative(long peer, MotionEvent event); - - @Override - public boolean onTrackballEvent(MotionEvent event) { - return onTrackballEventNative(mViewPeer, event) || super.onTrackballEvent(event); - } - - private native boolean onTouchEventNative(long peer, MotionEvent event); - - @Override - public boolean onTouchEvent(MotionEvent event) { - return onTouchEventNative(mViewPeer, event) || super.onTouchEvent(event); - } - - private native boolean onGenericMotionEventNative(long peer, MotionEvent event); - - @Override - public boolean onGenericMotionEvent(MotionEvent event) { - return onGenericMotionEventNative(mViewPeer, event) || super.onGenericMotionEvent(event); - } - - private native boolean onHoverEventNative(long peer, MotionEvent event); - - @Override - public boolean onHoverEvent(MotionEvent event) { - return onHoverEventNative(mViewPeer, event) || super.onHoverEvent(event); - } - - private native void onFocusChangedNative( - long peer, boolean gainFocus, int direction, Rect previouslyFocusedRect); - - @Override - protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) { - super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); - onFocusChangedNative(mViewPeer, gainFocus, direction, previouslyFocusedRect); - } - - private native void onWindowFocusChangedNative(long peer, boolean hasWindowFocus); - - @Override - public void onWindowFocusChanged(boolean hasWindowFocus) { - super.onWindowFocusChanged(hasWindowFocus); - onWindowFocusChangedNative(mViewPeer, hasWindowFocus); - } - - private native void onAttachedToWindowNative(long peer); - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - onAttachedToWindowNative(mViewPeer); - } - - private native void onDetachedFromWindowNative(long peer); - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - onDetachedFromWindowNative(mViewPeer); - } - - private native void onWindowVisibilityChangedNative(long peer, int visibility); - - @Override - protected void onWindowVisibilityChanged(int visibility) { - super.onWindowVisibilityChanged(visibility); - onWindowVisibilityChangedNative(mViewPeer, visibility); - } - - private native void surfaceCreatedNative(long peer, SurfaceHolder holder); - - @Override - public void surfaceCreated(SurfaceHolder holder) { - surfaceCreatedNative(mViewPeer, holder); - } - - private native void surfaceChangedNative( - long peer, SurfaceHolder holder, int format, int width, int height); - - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - surfaceChangedNative(mViewPeer, holder, format, width, height); - } - - private native void surfaceDestroyedNative(long peer, SurfaceHolder holder); - - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - surfaceDestroyedNative(mViewPeer, holder); - } - - void postFrameCallback() { - Choreographer c = Choreographer.getInstance(); - c.removeFrameCallback(this); - c.postFrameCallback(this); - } - - void removeFrameCallback() { - Choreographer.getInstance().removeFrameCallback(this); - } - - private native void doFrameNative(long peer, long frameTimeNanos); - - @Override - public void doFrame(long frameTimeNanos) { - doFrameNative(mViewPeer, frameTimeNanos); - } - - private native void delayedCallbackNative(long peer); - - private final Runnable mDelayedCallback = - new Runnable() { - @Override - public void run() { - delayedCallbackNative(mViewPeer); - } - }; - - boolean postDelayed(long delayMillis) { - return postDelayed(mDelayedCallback, delayMillis); - } - - boolean removeDelayedCallbacks() { - return removeCallbacks(mDelayedCallback); - } - - private native boolean hasAccessibilityNodeProviderNative(long peer); - - private native AccessibilityNodeInfo createAccessibilityNodeInfoNative( - long peer, int virtualViewId); - - private native AccessibilityNodeInfo accessibilityFindFocusNative(long peer, int virtualViewId); - - private native boolean performAccessibilityActionNative( - long peer, int virtualViewId, int action, Bundle arguments); - - @Override - public AccessibilityNodeProvider getAccessibilityNodeProvider() { - if (!hasAccessibilityNodeProviderNative(mViewPeer)) { - return super.getAccessibilityNodeProvider(); - } - return new AccessibilityNodeProvider() { - @Override - public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualViewId) { - return createAccessibilityNodeInfoNative(mViewPeer, virtualViewId); - } - - @Override - public AccessibilityNodeInfo findFocus(int focusType) { - return accessibilityFindFocusNative(mViewPeer, focusType); - } - - @Override - public boolean performAction(int virtualViewId, int action, Bundle arguments) { - return performAccessibilityActionNative( - mViewPeer, virtualViewId, action, arguments); - } - }; - } - - private native boolean onCreateInputConnectionNative(long peer, EditorInfo outAttrs); - - @Override - public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - if (!onCreateInputConnectionNative(mViewPeer, outAttrs)) { - return null; - } - return new RustInputConnection(this); - } - - native String getTextBeforeCursorNative(long peer, int n); - - native String getTextAfterCursorNative(long peer, int n); - - native String getSelectedTextNative(long peer); - - native int getCursorCapsModeNative(long peer, int reqModes); - - native boolean deleteSurroundingTextNative(long peer, int beforeLength, int afterLength); - - native boolean deleteSurroundingTextInCodePointsNative( - long peer, int beforeLength, int afterLength); - - native boolean setComposingTextNative(long peer, String text, int newCursorPosition); - - native boolean setComposingRegionNative(long peer, int start, int end); - - native boolean finishComposingTextNative(long peer); - - native boolean commitTextNative(long peer, String text, int newCursorPosition); - - native boolean setSelectionNative(long peer, int start, int end); - - native boolean performEditorActionNative(long peer, int editorAction); - - native boolean performContextMenuActionNative(long peer, int id); - - native boolean beginBatchEditNative(long peer); - - native boolean endBatchEditNative(long peer); - - native boolean inputConnectionSendKeyEventNative(long peer, KeyEvent event); - - native boolean inputConnectionClearMetaKeyStatesNative(long peer, int states); - - native boolean inputConnectionReportFullscreenModeNative(long peer, boolean enabled); - - native boolean requestCursorUpdatesNative(long peer, int cursorUpdateMode); - - native void closeInputConnectionNative(long peer); -} diff --git a/iris/cargo-iris/src/main.rs b/iris/cargo-iris/src/main.rs deleted file mode 100644 index fcff0dd..0000000 --- a/iris/cargo-iris/src/main.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod package; - -use std::{env, process::ExitCode}; - -fn main() -> ExitCode { - match package::run(env::args().skip(1).collect()) { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("cargo iris: {error}"); - ExitCode::FAILURE - } - } -} diff --git a/iris/cargo-iris/src/package.rs b/iris/cargo-iris/src/package.rs deleted file mode 100644 index 17f8a79..0000000 --- a/iris/cargo-iris/src/package.rs +++ /dev/null @@ -1,902 +0,0 @@ -use cargo_metadata::{CrateType, MetadataCommand, Package, Target, TargetKind}; -use std::{ - env, - ffi::OsStr, - fs, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -const MIN_SDK: u32 = 29; -const ACTIVITY: &str = "dev.iris.android.MainActivity"; - -pub fn run(mut args: Vec) -> Result<(), String> { - if args.first().is_some_and(|arg| arg == "iris") { - args.remove(0); - } - let command = args.first().map(String::as_str).unwrap_or("help"); - if matches!(command, "help" | "--help" | "-h") { - print_help(); - return Ok(()); - } - if !matches!(command, "apk" | "run") { - return Err(format!( - "unknown command {command:?}; run `cargo iris --help`" - )); - } - - let options = Options::parse(&args[1..], command == "run")?; - let built = build(&options)?; - println!("{}", built.apk.display()); - if command == "run" { - install_and_run(&built, options.device.as_deref().unwrap())?; - } - Ok(()) -} - -fn print_help() { - println!( - "Build an Iris application or example as an installable Android APK.\n\n\ - Usage:\n cargo iris apk [OPTIONS]\n cargo iris run --device SERIAL [OPTIONS]\n\n\ - Options:\n --manifest-path PATH\n --package NAME\n --example NAME\n --abi arm64-v8a|x86_64\n --release\n\ - \x20 --application-id ID\n --label TEXT\n --keystore PATH --key-alias ALIAS\n\n\ - Release signing passwords come from IRIS_KEYSTORE_PASSWORD and, when different,\n\ - IRIS_KEY_PASSWORD. Iris uses the Android SDK and emulator/device supplied by you." - ); -} - -#[derive(Default)] -struct Options { - manifest_path: Option, - package: Option, - example: Option, - abi: String, - release: bool, - application_id: Option, - label: Option, - keystore: Option, - key_alias: Option, - device: Option, -} - -impl Options { - fn parse(args: &[String], run: bool) -> Result { - let mut options = Self { - abi: "arm64-v8a".into(), - ..Self::default() - }; - let mut i = 0; - while i < args.len() { - let value = |name: &str, i: &mut usize| -> Result { - *i += 1; - args.get(*i) - .cloned() - .ok_or_else(|| format!("{name} needs a value")) - }; - match args[i].as_str() { - "--manifest-path" => { - options.manifest_path = Some(value("--manifest-path", &mut i)?.into()) - } - "--package" => options.package = Some(value("--package", &mut i)?), - "--example" => options.example = Some(value("--example", &mut i)?), - "--abi" => options.abi = value("--abi", &mut i)?, - "--application-id" => { - options.application_id = Some(value("--application-id", &mut i)?) - } - "--label" => options.label = Some(value("--label", &mut i)?), - "--keystore" => options.keystore = Some(value("--keystore", &mut i)?.into()), - "--key-alias" => options.key_alias = Some(value("--key-alias", &mut i)?), - "--device" => options.device = Some(value("--device", &mut i)?), - "--release" => options.release = true, - other => return Err(format!("unknown option {other:?}")), - } - i += 1; - } - if !matches!(options.abi.as_str(), "arm64-v8a" | "x86_64") { - return Err(format!( - "unsupported ABI {:?}; use arm64-v8a or x86_64", - options.abi - )); - } - if run && options.device.is_none() { - return Err( - "`cargo iris run` needs --device SERIAL; Iris never chooses or starts an emulator" - .into(), - ); - } - if options.release && (options.keystore.is_none() || options.key_alias.is_none()) { - return Err("a release APK needs --keystore PATH and --key-alias ALIAS".into()); - } - Ok(options) - } -} - -struct Built { - apk: PathBuf, - application_id: String, - sdk: Sdk, -} - -fn build(options: &Options) -> Result { - let mut metadata = MetadataCommand::new(); - if let Some(path) = &options.manifest_path { - metadata.manifest_path(path); - } - let metadata = metadata - .exec() - .map_err(|error| format!("could not read Cargo metadata: {error}"))?; - let package = select_package( - &metadata.packages, - metadata.root_package(), - options.package.as_deref(), - )?; - let target = select_target(package, options.example.as_deref())?; - let sdk = Sdk::find()?; - let application_id = options - .application_id - .clone() - .or_else(|| { - options - .example - .is_none() - .then(|| metadata_string(package, "application-id")) - .flatten() - }) - .unwrap_or_else(|| default_application_id(&package.name, options.example.as_deref())); - validate_application_id(&application_id)?; - let label = options - .label - .clone() - .or_else(|| { - options - .example - .is_none() - .then(|| metadata_string(package, "label")) - .flatten() - }) - .unwrap_or_else(|| { - options - .example - .clone() - .unwrap_or_else(|| package.name.to_string()) - }); - let variant = if options.release { "release" } else { "debug" }; - let artifact = options.example.as_ref().map_or_else( - || package.name.to_string(), - |example| format!("{}-{example}", package.name), - ); - let output = metadata - .target_directory - .as_std_path() - .join("iris-android") - .join(&artifact) - .join(variant) - .join(&options.abi); - recreate(&output)?; - let staging = output.join("staging"); - let staging_cleanup = RemoveDirOnDrop(&staging); - let native = staging.join("native"); - let (build_manifest, library_name) = if options.example.is_some() { - let wrapper = metadata - .target_directory - .as_std_path() - .join("iris-android") - .join("example-wrappers") - .join(&artifact); - materialize_example_wrapper(&metadata.packages, package, target, &wrapper)? - } else { - ( - package.manifest_path.as_std_path().to_path_buf(), - target.name.clone(), - ) - }; - - let mut cargo = Command::new("cargo"); - cargo - .args(["ndk", "-t", &options.abi, "-P", &MIN_SDK.to_string(), "-o"]) - .arg(&native) - .arg("build") - .arg("--lib") - .arg("--manifest-path") - .arg(&build_manifest) - .env("CARGO_TARGET_DIR", metadata.target_directory.as_std_path()); - if options.release { - cargo.arg("--release"); - } - run_command( - &mut cargo, - "Rust Android library", - "install cargo-ndk with `cargo install cargo-ndk`", - )?; - - let library = native - .join(&options.abi) - .join(format!("lib{library_name}.so")); - if !library.is_file() { - return Err(format!("cargo-ndk did not produce {}", library.display())); - } - let classes = staging.join("classes"); - fs::create_dir_all(&classes).map_err(io_error("create Java output", &classes))?; - let sources = materialize_host(&staging, &library_name)?; - let java_files = files_with_extension(&sources, "java")?; - let mut javac = Command::new("javac"); - javac - .args(["--release", "17", "-classpath"]) - .arg(&sdk.android_jar) - .arg("-d") - .arg(&classes) - .args(&java_files); - run_command( - &mut javac, - "Iris Android Java host", - "install a JDK containing javac", - )?; - - let dex = staging.join("dex"); - fs::create_dir_all(&dex).map_err(io_error("create DEX output", &dex))?; - let class_files = files_with_extension(&classes, "class")?; - let mut d8 = Command::new(&sdk.d8); - d8.args(["--min-api", &MIN_SDK.to_string(), "--lib"]) - .arg(&sdk.android_jar) - .arg("--output") - .arg(&dex) - .args(&class_files); - if options.release { - d8.arg("--release"); - } else { - d8.arg("--debug"); - } - run_command( - &mut d8, - "Iris Android DEX", - "install Android SDK Build Tools", - )?; - - let manifest = staging.join("AndroidManifest.xml"); - fs::write( - &manifest, - manifest_xml(&application_id, &label, &library_name, sdk.api), - ) - .map_err(io_error("write Android manifest", &manifest))?; - let unsigned = staging.join("unsigned.apk"); - let mut aapt = Command::new(&sdk.aapt2); - aapt.arg("link") - .arg("-o") - .arg(&unsigned) - .arg("-I") - .arg(&sdk.android_jar) - .arg("--manifest") - .arg(&manifest) - .args([ - "--min-sdk-version", - &MIN_SDK.to_string(), - "--target-sdk-version", - &sdk.api.to_string(), - ]); - run_command( - &mut aapt, - "Android resources", - "install Android SDK Build Tools", - )?; - append_payload( - &unsigned, - &staging, - &dex.join("classes.dex"), - &library, - &options.abi, - &library_name, - )?; - - let aligned = staging.join("aligned.apk"); - let mut zipalign = Command::new(&sdk.zipalign); - zipalign - .args(["-P", "16", "-f", "4"]) - .arg(&unsigned) - .arg(&aligned); - run_command( - &mut zipalign, - "APK alignment", - "install Android SDK Build Tools", - )?; - let apk = output.join(format!("{artifact}-{variant}.apk")); - sign(&sdk, options, &aligned, &apk)?; - verify(&sdk, &apk)?; - fs::remove_dir_all(&staging).map_err(io_error("remove APK staging directory", &staging))?; - std::mem::forget(staging_cleanup); - Ok(Built { - apk, - application_id, - sdk, - }) -} - -fn select_package<'a>( - packages: &'a [Package], - root: Option<&'a Package>, - wanted: Option<&str>, -) -> Result<&'a Package, String> { - if let Some(wanted) = wanted { - return packages - .iter() - .find(|package| package.name == wanted) - .ok_or_else(|| format!("Cargo workspace has no package named {wanted:?}")); - } - root.ok_or_else(|| { - "this is a virtual workspace; select an application with --package NAME".into() - }) -} - -fn select_target<'a>(package: &'a Package, example: Option<&str>) -> Result<&'a Target, String> { - if let Some(example) = example { - return package - .targets - .iter() - .find(|target| { - target.name == example - && target.kind.iter().any(|kind| kind == &TargetKind::Example) - }) - .ok_or_else(|| { - format!( - "package {} has no example named {example:?}; use one of: {}", - package.name, - package - .targets - .iter() - .filter(|target| target - .kind - .iter() - .any(|kind| kind == &TargetKind::Example)) - .map(|target| target.name.as_str()) - .collect::>() - .join(", ") - ) - }); - } - package - .targets - .iter() - .find(|target| { - target - .crate_types - .iter() - .any(|kind| kind == &CrateType::CDyLib) - }) - .ok_or_else(|| { - format!( - "package {} has no cdylib target; add `[lib] crate-type = [\"cdylib\", \"rlib\"]` to {}", - package.name, package.manifest_path - ) - }) -} - -fn materialize_example_wrapper( - packages: &[Package], - package: &Package, - example: &Target, - wrapper: &Path, -) -> Result<(PathBuf, String), String> { - let android_source = example - .src_path - .as_std_path() - .parent() - .unwrap() - .join("android.rs"); - if !android_source.is_file() { - return Err(format!( - "example {:?} has no Android entry point at {}; put shared code in lib.rs and add sibling desktop.rs and android.rs entries", - example.name, - android_source.display() - )); - } - let iris = packages - .iter() - .find(|dependency| dependency.name == "iris") - .ok_or_else(|| { - format!( - "example {:?} does not depend on Iris; add `iris` to {}", - example.name, package.manifest_path - ) - })?; - let source_dir = wrapper.join("src"); - fs::create_dir_all(&source_dir) - .map_err(io_error("create Android example wrapper", &source_dir))?; - let library_name = format!( - "iris_android_{}_{}", - identifier_segment(&package.name), - identifier_segment(&example.name) - ); - let iris_root = iris.manifest_path.parent().unwrap(); - let manifest = format!( - "[package]\nname = {name:?}\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ - [lib]\nname = {library_name:?}\ncrate-type = [\"cdylib\", \"rlib\"]\n\n\ - [dependencies]\niris = {{ path = {iris_root:?} }}\n\n[workspace]\n\n\ - [profile.dev]\ndebug = \"line-tables-only\"\n", - name = format!("iris-android-{}-{}", package.name, example.name), - iris_root = iris_root.as_str(), - ); - let manifest_path = wrapper.join("Cargo.toml"); - write_if_changed(&manifest_path, &manifest)?; - let source = format!( - "#[path = {:?}]\nmod example;\n", - android_source.to_string_lossy() - ); - let source_path = source_dir.join("lib.rs"); - write_if_changed(&source_path, &source)?; - Ok((manifest_path, library_name)) -} - -fn write_if_changed(path: &Path, contents: &str) -> Result { - match fs::read(path) { - Ok(existing) if existing == contents.as_bytes() => return Ok(false), - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(format!("cannot read {}: {error}", path.display())), - } - fs::write(path, contents).map_err(io_error("write generated file", path))?; - Ok(true) -} - -fn metadata_string(package: &Package, key: &str) -> Option { - package - .metadata - .get("iris")? - .get("android")? - .get(key)? - .as_str() - .map(str::to_owned) -} - -fn identifier_segment(value: &str) -> String { - value - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_lowercase() - } else { - '_' - } - }) - .collect() -} - -fn default_application_id(package: &str, example: Option<&str>) -> String { - let package = identifier_segment(package); - match example { - Some(example) => format!("dev.iris.example.{package}.{}", identifier_segment(example)), - None => format!("dev.iris.app.{package}"), - } -} - -fn validate_application_id(id: &str) -> Result<(), String> { - let valid = id.split('.').count() >= 2 - && id.split('.').all(|segment| { - !segment.is_empty() - && segment.as_bytes()[0].is_ascii_alphabetic() - && segment - .bytes() - .all(|c| c.is_ascii_alphanumeric() || c == b'_') - }); - if valid { - Ok(()) - } else { - Err(format!( - "application ID {id:?} is invalid; use dot-separated Java identifiers" - )) - } -} - -struct Sdk { - api: u32, - android_jar: PathBuf, - aapt2: PathBuf, - d8: PathBuf, - zipalign: PathBuf, - apksigner: PathBuf, - adb: PathBuf, -} - -/// Failed builds have no reusable staging output either; the next invocation -/// starts from scratch, so do not make a failure consume disk indefinitely. -struct RemoveDirOnDrop<'a>(&'a Path); - -impl Drop for RemoveDirOnDrop<'_> { - fn drop(&mut self) { - let _ = fs::remove_dir_all(self.0); - } -} - -impl Sdk { - fn find() -> Result { - let root = env::var_os("ANDROID_HOME") - .or_else(|| env::var_os("ANDROID_SDK_ROOT")) - .map(PathBuf::from) - .ok_or_else(|| "ANDROID_HOME is unset; point it at your Android SDK".to_string())?; - let (api, platform) = newest_numbered(&root.join("platforms"), "android-")?; - let (_, tools) = newest_numbered(&root.join("build-tools"), "")?; - let executable = |name: &str, windows_extension: &str| { - tools.join(if cfg!(windows) { - format!("{name}.{windows_extension}") - } else { - name.to_string() - }) - }; - let sdk = Self { - android_jar: platform.join("android.jar"), - aapt2: executable("aapt2", "exe"), - d8: executable("d8", "bat"), - zipalign: executable("zipalign", "exe"), - apksigner: executable("apksigner", "bat"), - adb: root - .join("platform-tools") - .join(if cfg!(windows) { "adb.exe" } else { "adb" }), - api, - }; - for (name, path) in [ - ("android.jar", &sdk.android_jar), - ("aapt2", &sdk.aapt2), - ("d8", &sdk.d8), - ("zipalign", &sdk.zipalign), - ("apksigner", &sdk.apksigner), - ] { - if !path.is_file() { - return Err(format!( - "Android SDK is missing {name} at {}; install a platform and Build Tools", - path.display() - )); - } - } - Ok(sdk) - } -} - -fn newest_numbered(parent: &Path, prefix: &str) -> Result<(u32, PathBuf), String> { - let entries = fs::read_dir(parent).map_err(|error| { - format!( - "cannot read {}: {error}; install the required Android SDK component", - parent.display() - ) - })?; - entries - .filter_map(Result::ok) - .filter_map(|entry| { - let name = entry.file_name(); - let version = name - .to_string_lossy() - .strip_prefix(prefix)? - .split('.') - .map(str::parse::) - .collect::, _>>() - .ok()?; - Some((version, entry.path())) - }) - .max_by(|(left, _), (right, _)| left.cmp(right)) - .map(|(version, path)| (version[0], path)) - .ok_or_else(|| { - format!( - "no installed Android SDK component found under {}", - parent.display() - ) - }) -} - -fn materialize_host(output: &Path, library: &str) -> Result { - let root = output.join("java"); - for (relative, contents) in HOST_FILES { - let path = root.join(relative); - fs::create_dir_all(path.parent().unwrap()) - .map_err(io_error("create Java source directory", &path))?; - let contents = if relative.ends_with("MainActivity.java") { - contents.replace("IRIS_NATIVE_LIBRARY", library) - } else { - contents.to_string() - }; - fs::write(&path, contents).map_err(io_error("write Java host source", &path))?; - } - Ok(root) -} - -const HOST_FILES: &[(&str, &str)] = &[ - ( - "dev/iris/android/MainActivity.java", - include_str!("../android-host/dev/iris/android/MainActivity.java"), - ), - ( - "dev/iris/android/IrisView.java", - include_str!("../android-host/dev/iris/android/IrisView.java"), - ), - ( - "org/linebender/android/rustview/RustView.java", - include_str!("../android-host/org/linebender/android/rustview/RustView.java"), - ), - ( - "org/linebender/android/rustview/RustInputConnection.java", - include_str!("../android-host/org/linebender/android/rustview/RustInputConnection.java"), - ), -]; - -fn manifest_xml(application_id: &str, label: &str, library: &str, target_sdk: u32) -> String { - format!( - r#" - - - - - - - - - - - - -"#, - xml_escape(application_id), - xml_escape(label), - xml_escape(library) - ) -} - -fn xml_escape(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -fn files_with_extension(root: &Path, extension: &str) -> Result, String> { - fn visit(dir: &Path, extension: &str, output: &mut Vec) -> Result<(), String> { - for entry in fs::read_dir(dir).map_err(io_error("read directory", dir))? { - let path = entry - .map_err(|error| format!("cannot read entry under {}: {error}", dir.display()))? - .path(); - if path.is_dir() { - visit(&path, extension, output)?; - } else if path.extension() == Some(OsStr::new(extension)) { - output.push(path); - } - } - Ok(()) - } - let mut files = Vec::new(); - visit(root, extension, &mut files)?; - files.sort(); - Ok(files) -} - -fn append_payload( - apk: &Path, - output: &Path, - dex: &Path, - library: &Path, - abi: &str, - library_name: &str, -) -> Result<(), String> { - let payload = output.join("payload"); - let native_dir = payload.join("lib").join(abi); - fs::create_dir_all(&native_dir).map_err(io_error("create APK payload", &native_dir))?; - fs::copy(dex, payload.join("classes.dex")).map_err(io_error("stage classes.dex", dex))?; - let native_name = format!("lib{library_name}.so"); - fs::copy(library, native_dir.join(&native_name)) - .map_err(io_error("stage native library", library))?; - - // `jar` is part of the JDK already needed for javac. Storing the native - // library uncompressed lets zipalign give it the 16 KiB page alignment - // required by current Android devices. - let mut jar = Command::new("jar"); - jar.args(["--update", "--file"]) - .arg(apk) - .args(["--no-manifest", "--no-compress", "-C"]) - .arg(&payload) - .arg("classes.dex") - .arg("-C") - .arg(&payload) - .arg("lib"); - run_command( - &mut jar, - "APK native payload", - "install a JDK containing jar", - ) -} - -fn sign(sdk: &Sdk, options: &Options, input: &Path, output: &Path) -> Result<(), String> { - let (keystore, alias, store_password, key_password) = if options.release { - let store = env::var("IRIS_KEYSTORE_PASSWORD") - .map_err(|_| "IRIS_KEYSTORE_PASSWORD is unset for release signing".to_string())?; - let key = env::var("IRIS_KEY_PASSWORD").unwrap_or_else(|_| store.clone()); - ( - options.keystore.clone().unwrap(), - options.key_alias.clone().unwrap(), - store, - key, - ) - } else { - let home = env::var_os("HOME") - .ok_or_else(|| "HOME is unset; cannot locate the Android debug keystore".to_string())?; - let keystore = PathBuf::from(home).join(".android/debug.keystore"); - ensure_debug_keystore(&keystore)?; - ( - keystore, - "androiddebugkey".into(), - "android".into(), - "android".into(), - ) - }; - let mut command = Command::new(&sdk.apksigner); - // `install_and_run` uses `--no-streaming`, so it cannot consume the separate - // v4 `.idsig` file and retaining that sidecar beside the APK serves no caller. - command - .arg("sign") - .args([ - "--v4-signing-enabled", - "false", - "--ks-pass", - "env:IRIS_APK_STORE_PASSWORD", - "--key-pass", - "env:IRIS_APK_KEY_PASSWORD", - "--ks-key-alias", - ]) - .arg(alias) - .arg("--ks") - .arg(keystore) - .arg("--out") - .arg(output) - .arg(input) - .env("IRIS_APK_STORE_PASSWORD", store_password) - .env("IRIS_APK_KEY_PASSWORD", key_password); - run_command( - &mut command, - "APK signing", - "check the keystore, alias, and signing passwords", - ) -} - -fn ensure_debug_keystore(path: &Path) -> Result<(), String> { - if path.is_file() { - return Ok(()); - } - fs::create_dir_all(path.parent().unwrap()) - .map_err(io_error("create Android configuration directory", path))?; - let mut keytool = Command::new("keytool"); - keytool.args(["-genkeypair", "-keystore"]).arg(path).args([ - "-storepass", - "android", - "-alias", - "androiddebugkey", - "-keypass", - "android", - "-dname", - "CN=Android Debug,O=Android,C=US", - "-keyalg", - "RSA", - "-keysize", - "2048", - "-validity", - "10000", - ]); - run_command( - &mut keytool, - "Android debug key", - "install a JDK containing keytool", - ) -} - -fn verify(sdk: &Sdk, apk: &Path) -> Result<(), String> { - let mut align = Command::new(&sdk.zipalign); - align.args(["-c", "-P", "16", "4"]).arg(apk); - run_command( - &mut align, - "APK alignment verification", - "this indicates a cargo-iris packaging defect", - )?; - let mut sign = Command::new(&sdk.apksigner); - sign.args(["verify", "--verbose"]).arg(apk); - run_command( - &mut sign, - "APK signature verification", - "this indicates a cargo-iris signing defect", - ) -} - -fn install_and_run(built: &Built, device: &str) -> Result<(), String> { - if !built.sdk.adb.is_file() { - return Err(format!( - "Android SDK is missing adb at {}; install Platform Tools", - built.sdk.adb.display() - )); - } - let mut install = Command::new(&built.sdk.adb); - install - .args(["-s", device, "install", "--no-streaming", "-r"]) - .arg(&built.apk); - run_command( - &mut install, - "APK install", - "check that the selected device is connected and authorized", - )?; - let component = format!("{}/{}", built.application_id, ACTIVITY); - let mut launch = Command::new(&built.sdk.adb); - launch.args(["-s", device, "shell", "am", "start", "-n", &component]); - run_command( - &mut launch, - "APK launch", - "check the package activity in the generated APK", - ) -} - -fn recreate(path: &Path) -> Result<(), String> { - if path.exists() { - fs::remove_dir_all(path).map_err(io_error("clear prior APK output directory", path))?; - } - fs::create_dir_all(path).map_err(io_error("create APK output directory", path)) -} - -fn run_command(command: &mut Command, thing: &str, fix: &str) -> Result<(), String> { - command.stdin(Stdio::null()); - let status = command - .status() - .map_err(|error| format!("could not start {thing}: {error}; {fix}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("{thing} failed with {status}; {fix}")) - } -} - -fn io_error<'a>(action: &'a str, path: &'a Path) -> impl FnOnce(std::io::Error) -> String + 'a { - move |error| format!("cannot {action} {}: {error}", path.display()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn application_ids_are_validated() { - assert!(validate_application_id("dev.iris.app.demo_2").is_ok()); - assert!(validate_application_id("one").is_err()); - assert!(validate_application_id("dev.2demo").is_err()); - assert!(validate_application_id("dev.iris.bad-name").is_err()); - assert_eq!( - default_application_id("demo-app", Some("color-picker")), - "dev.iris.example.demo_app.color_picker" - ); - } - - #[test] - fn manifest_values_are_escaped() { - let manifest = manifest_xml("dev.iris.demo", "A & ", "demo", 37); - assert!(manifest.contains("A & <demo>")); - assert!(manifest.contains("android:minSdkVersion=\"29\"")); - } - - #[test] - fn an_unchanged_generated_file_is_not_rewritten() { - let root = env::temp_dir().join(format!("cargo-iris-write-test-{}", std::process::id())); - let path = root.join("generated.rs"); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).unwrap(); - - assert!(write_if_changed(&path, "first").unwrap()); - assert!(!write_if_changed(&path, "first").unwrap()); - assert!(write_if_changed(&path, "second").unwrap()); - assert_eq!(fs::read_to_string(&path).unwrap(), "second"); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn failed_build_staging_is_removed_when_its_scope_ends() { - let root = env::temp_dir().join(format!("cargo-iris-staging-test-{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).unwrap(); - fs::write(root.join("intermediate"), "not reusable").unwrap(); - - { - let _cleanup = RemoveDirOnDrop(&root); - } - - assert!(!root.exists()); - } -} diff --git a/iris/core/Cargo.toml b/iris/core/Cargo.toml deleted file mode 100644 index 3b643f0..0000000 --- a/iris/core/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "iris-core" -version.workspace = true -edition.workspace = true - -[dependencies] -wgpu = { workspace = true } -# Keeps renderer creation synchronous while retrieving wgpu's async error scope. -pollster = { workspace = true } -bytemuck ={ workspace = true } -image = { workspace = true } -parley = { workspace = true } -swash = { workspace = true } -fxhash = { workspace = true } -accesskit = { workspace = true } diff --git a/iris/core/src/attr.rs b/iris/core/src/attr.rs deleted file mode 100644 index e03fb9d..0000000 --- a/iris/core/src/attr.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike}; - -pub trait WidgetAttr { - type Input; - fn run(rsc: &mut Rsc, id: WeakWidget, input: Self::Input); -} - -pub trait Attrable { - fn attr>(self, input: A::Input) -> impl WidgetIdFn; -} - -impl, Tag> Attrable for WL { - fn attr>( - self, - input: A::Input, - ) -> impl WidgetIdFn { - |rsc| { - let id = self.add(rsc); - A::run(rsc, id, input); - id - } - } -} diff --git a/iris/core/src/event/controller.rs b/iris/core/src/event/controller.rs deleted file mode 100644 index 1c1ece7..0000000 --- a/iris/core/src/event/controller.rs +++ /dev/null @@ -1,294 +0,0 @@ -use crate::{ - UiRenderState, WidgetId, - util::{HashMap, HashSet}, -}; -use std::any::{Any, TypeId}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] -pub struct ControllerId { - host: WidgetId, - kind: TypeId, -} - -impl ControllerId { - pub fn host(self) -> WidgetId { - self.host - } - - pub fn is(self) -> bool { - self.kind == TypeId::of::() - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Command { - Copy, - SelectAll, - Escape, -} - -#[derive(Debug, Eq, PartialEq)] -pub enum CommandResult { - Unused, - Used, - Copy(String), -} - -pub trait ControllerValue: Any { - fn into_any(self: Box) -> Box; -} - -impl ControllerValue for T { - fn into_any(self: Box) -> Box { - self - } -} - -pub trait Controller: ControllerValue { - fn command(&mut self, _command: Command, _rsc: &mut Rsc) -> CommandResult { - CommandResult::Unused - } - - fn blocks_prior_input(&self) -> bool { - false - } -} - -pub struct ControllerManager { - by_widget: HashMap>>>, - borrowed: HashSet, - removed_while_borrowed: HashSet, - command_target: Option, - command_target_revision: u64, - command_boundary: Option, -} - -impl Default for ControllerManager { - fn default() -> Self { - Self { - by_widget: Default::default(), - borrowed: Default::default(), - removed_while_borrowed: Default::default(), - command_target: None, - command_target_revision: 0, - command_boundary: None, - } - } -} - -impl ControllerManager { - #[track_caller] - pub fn register>(&mut self, host: WidgetId, controller: C) { - let kind = TypeId::of::(); - let id = ControllerId { host, kind }; - assert!( - !self.borrowed.contains(&id), - "a controller cannot be replaced while it is handling input" - ); - assert!( - !self.removed_while_borrowed.contains(&host), - "a controller cannot be attached to a removed widget" - ); - let old = self - .by_widget - .entry(host) - .or_default() - .insert(kind, Box::new(controller)); - assert!( - old.is_none(), - "a widget cannot have two controllers of type {}", - std::any::type_name::() - ); - } - - pub fn id>(&self, host: WidgetId) -> Option { - let kind = TypeId::of::(); - self.by_widget - .get(&host)? - .contains_key(&kind) - .then_some(ControllerId { host, kind }) - } - - pub fn nearest_id>( - &self, - mut origin: WidgetId, - render_state: &UiRenderState, - ) -> Option { - let kind = TypeId::of::(); - loop { - let candidate = ControllerId { host: origin, kind }; - assert!( - !self.borrowed.contains(&candidate), - "a controller cannot re-enter itself while it is handling input" - ); - if let Some(id) = self.id::(origin) { - return Some(id); - } - origin = render_state.active.get(&origin)?.parent?; - } - } - - pub fn path_to>( - &self, - mut origin: WidgetId, - render_state: &UiRenderState, - ) -> Option<(ControllerId, Vec)> { - let mut path = Vec::new(); - loop { - path.push(origin); - if let Some(id) = self.id::(origin) { - return Some((id, path)); - } - origin = render_state.active.get(&origin)?.parent?; - } - } - - pub fn take>(&mut self, id: ControllerId) -> Option { - if id.kind != TypeId::of::() { - return None; - } - assert!( - !self.borrowed.contains(&id), - "a controller cannot re-enter itself while it is handling input" - ); - let boxed = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?; - self.borrowed.insert(id); - let boxed = boxed.into_any(); - boxed.downcast().ok().map(|boxed| *boxed) - } - - pub fn put>(&mut self, id: ControllerId, controller: C) { - debug_assert_eq!(id.kind, TypeId::of::()); - assert!( - self.borrowed.remove(&id), - "restored an unborrowed controller" - ); - if self.finish_removed_host(id.host) { - return; - } - let old = self - .by_widget - .entry(id.host) - .or_default() - .insert(id.kind, Box::new(controller)); - debug_assert!(old.is_none(), "a controller was re-entered while borrowed"); - } - - fn take_dyn(&mut self, id: ControllerId) -> Option>> { - assert!( - !self.borrowed.contains(&id), - "a controller cannot re-enter itself while it is handling input" - ); - let controller = self.by_widget.get_mut(&id.host)?.remove(&id.kind)?; - self.borrowed.insert(id); - Some(controller) - } - - fn put_dyn(&mut self, id: ControllerId, controller: Box>) { - assert!( - self.borrowed.remove(&id), - "restored an unborrowed controller" - ); - if self.finish_removed_host(id.host) { - return; - } - let old = self - .by_widget - .entry(id.host) - .or_default() - .insert(id.kind, controller); - debug_assert!(old.is_none(), "a controller was re-entered while borrowed"); - } - - pub fn set_command_target(&mut self, target: Option) { - self.command_target = target; - self.command_target_revision = self.command_target_revision.wrapping_add(1); - } - - pub fn command_target(&self) -> Option { - self.command_target - } - - pub fn command_target_blocks_input(&self) -> bool { - let Some(id) = self.command_target else { - return false; - }; - self.by_widget - .get(&id.host) - .and_then(|controllers| controllers.get(&id.kind)) - .is_some_and(|controller| controller.blocks_prior_input()) - } - - pub(crate) fn command_target_revision(&self) -> u64 { - self.command_target_revision - } - - pub(crate) fn command_boundary(&self) -> Option { - self.command_boundary - } - - pub(crate) fn is_below( - &self, - mut widget: WidgetId, - ancestor: WidgetId, - render_state: &UiRenderState, - ) -> bool { - loop { - let Some(parent) = render_state - .active - .get(&widget) - .and_then(|active| active.parent) - else { - return false; - }; - if parent == ancestor { - return true; - } - widget = parent; - } - } - - pub(crate) fn set_command_boundary(&mut self, boundary: Option) { - self.command_boundary = boundary; - } - - pub fn remove(&mut self, host: WidgetId) { - self.by_widget.remove(&host); - if self.borrowed.iter().any(|id| id.host == host) { - self.removed_while_borrowed.insert(host); - } - if self.command_target.is_some_and(|id| id.host == host) { - self.command_target = None; - } - } - - pub(crate) fn take_command_target( - &mut self, - ) -> Option<(ControllerId, Box>)> { - let id = self.command_target?; - match self.take_dyn(id) { - Some(controller) => Some((id, controller)), - None => { - self.command_target = None; - None - } - } - } - - pub(crate) fn restore(&mut self, id: ControllerId, controller: Box>) { - self.put_dyn(id, controller); - } - - /// Returns true when a host disappeared during its controller callback, - /// in which case restoring the temporarily extracted value would revive - /// state belonging to a dead widget generation. - fn finish_removed_host(&mut self, host: WidgetId) -> bool { - if !self.removed_while_borrowed.contains(&host) { - return false; - } - if !self.borrowed.iter().any(|id| id.host == host) { - self.removed_while_borrowed.remove(&host); - } - true - } -} diff --git a/iris/core/src/event/ctx.rs b/iris/core/src/event/ctx.rs deleted file mode 100644 index f5894a9..0000000 --- a/iris/core/src/event/ctx.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::{HasEvents, WeakWidget, Widget}; - -pub struct EventCtx<'a, Rsc: HasEvents, Data> { - pub state: &'a mut Rsc::State, - pub data: Data, -} - -pub struct EventIdCtx<'a, Rsc: HasEvents, Data, W: ?Sized> { - pub widget: WeakWidget, - pub state: &'a mut Rsc::State, - pub data: Data, -} - -impl EventIdCtx<'_, Rsc, Data, W> { - pub fn widget<'a>(&self, rsc: &'a mut Rsc) -> &'a mut W { - &mut rsc.ui_mut().widgets[self.widget] - } -} diff --git a/iris/core/src/event/manager.rs b/iris/core/src/event/manager.rs deleted file mode 100644 index d62205c..0000000 --- a/iris/core/src/event/manager.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::{ - ActiveData, ControllerManager, Event, EventCtx, EventFn, EventIdCtx, EventLike, HasEvents, - IdLike, LayerId, WeakWidget, WidgetEventFn, WidgetId, - util::{HashMap, HashSet, TypeMap}, -}; -use std::{any::TypeId, rc::Rc}; - -pub struct EventManager { - widget_to_types: HashMap>, - types: TypeMap>, - pub controllers: ControllerManager, -} - -impl Default for EventManager { - fn default() -> Self { - Self { - widget_to_types: Default::default(), - types: Default::default(), - controllers: Default::default(), - } - } -} - -impl EventManager { - pub fn get_type(&mut self) -> &mut TypeEventManager { - self.types.type_or_default() - } - - pub fn register( - &mut self, - id: I, - event: E, - f: impl for<'a> WidgetEventFn::Data<'a>, I::Widget>, - ) { - let i = id.id(); - self.get_type::().register(id, event, f); - self.widget_to_types - .entry(i) - .or_default() - .insert(Self::type_key::()); - } - - pub fn type_key() -> TypeId { - TypeId::of::>() - } -} - -pub trait EventsLike { - fn remove(&mut self, id: WidgetId); - fn draw(&mut self, active: &ActiveData); - fn undraw(&mut self, active: &ActiveData); -} - -impl EventsLike for EventManager { - fn remove(&mut self, id: WidgetId) { - for t in self.widget_to_types.get(&id).into_flat_iter() { - self.types.get_mut(t).unwrap().remove(id); - } - self.controllers.remove(id); - } - - fn draw(&mut self, active: &ActiveData) { - for t in self.widget_to_types.get(&active.id).into_flat_iter() { - self.types.get_mut(t).unwrap().draw(active); - } - } - - fn undraw(&mut self, active: &ActiveData) { - for t in self.widget_to_types.get(&active.id).into_flat_iter() { - self.types.get_mut(t).unwrap().undraw(active); - } - } -} - -pub trait EventManagerLike { - fn remove(&mut self, id: WidgetId); - fn draw(&mut self, data: &ActiveData); - fn undraw(&mut self, data: &ActiveData); -} - -type EventData = (E, Rc EventFn::Data<'a>>>); -pub struct TypeEventManager { - // TODO: reduce visiblity!! - pub active: HashMap>, - pub global: E::Global, - map: HashMap>>, -} - -impl EventManagerLike for TypeEventManager { - fn remove(&mut self, id: WidgetId) { - self.map.remove(&id); - for layer in self.active.values_mut() { - layer.remove(&id); - } - } - fn draw(&mut self, data: &ActiveData) { - self.active - .entry(data.layer) - .or_default() - .entry(data.id) - .or_default(); - } - fn undraw(&mut self, data: &ActiveData) { - if let Some(layer) = self.active.get_mut(&data.layer) { - layer.remove(&data.id); - } - } -} - -impl Default for TypeEventManager { - fn default() -> Self { - Self { - active: Default::default(), - global: Default::default(), - map: Default::default(), - } - } -} - -impl TypeEventManager { - fn register( - &mut self, - widget: I, - event: impl EventLike, - f: impl for<'a> WidgetEventFn, I::Widget>, - ) { - let event = event.into_event(); - self.map.entry(widget.id()).or_default().push(( - event, - Rc::new(move |ctx, rsc| { - f( - EventIdCtx { - widget: WeakWidget::new(widget.id()), - state: ctx.state, - data: ctx.data, - }, - rsc, - ); - }), - )); - } - - /// 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, - ) -> impl for<'b> FnOnce(EventCtx<'_, Rsc, E::Data<'b>>, &mut Rsc) + 'a { - let fs = self.map.get(&id.id()).cloned().unwrap_or_default(); - move |ctx, rsc| { - for (e, f) in fs { - if let Some(data) = e.should_run(&ctx.data) { - f( - EventCtx { - state: ctx.state, - data, - }, - rsc, - ) - } - } - } - } -} diff --git a/iris/core/src/event/mod.rs b/iris/core/src/event/mod.rs deleted file mode 100644 index 888cd5e..0000000 --- a/iris/core/src/event/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -mod controller; -mod ctx; -mod manager; -mod rsc; - -pub use controller::*; -pub use ctx::*; -pub use manager::*; -pub use rsc::*; - -pub trait Event: Sized + 'static + Clone { - type Data<'a>: Clone = (); - type State: Default = (); - type Global: Default = (); - #[allow(unused_variables)] - fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { - Some(data.clone()) - } -} - -pub trait EventLike { - type Event: Event; - fn into_event(self) -> Self::Event; -} - -impl EventLike for E { - type Event = Self; - - fn into_event(self) -> Self::Event { - self - } -} - -pub trait EventFn: Fn(EventCtx, &mut Rsc) + 'static {} -impl, &mut Rsc) + 'static, Data> EventFn - for F -{ -} - -pub trait WidgetEventFn: - Fn(EventIdCtx, &mut Rsc) + 'static -{ -} -impl, &mut Rsc) + 'static, Data, W: ?Sized> - WidgetEventFn for F -{ -} diff --git a/iris/core/src/event/rsc.rs b/iris/core/src/event/rsc.rs deleted file mode 100644 index 9fbbfa7..0000000 --- a/iris/core/src/event/rsc.rs +++ /dev/null @@ -1,121 +0,0 @@ -use crate::{ - Command, CommandResult, Controller, ControllerId, Event, EventCtx, EventLike, EventManager, - IdLike, UiRsc, WeakWidget, Widget, WidgetEventFn, -}; - -pub trait HasState: 'static { - type State; -} - -pub trait HasEvents: Sized + UiRsc + HasState { - fn events(&self) -> &EventManager; - fn events_mut(&mut self) -> &mut EventManager; - - fn register_event( - &mut self, - id: WeakWidget, - event: E, - f: impl for<'a> WidgetEventFn::Data<'a>, W>, - ) { - self.events_mut().register(id, event, f); - } - - fn register_controller>( - &mut self, - id: WeakWidget, - controller: C, - ) { - self.events_mut().controllers.register(id.id(), controller); - } - - fn with_controller, T>( - &mut self, - id: ControllerId, - f: impl FnOnce(&mut C, &mut Self) -> T, - ) -> Option { - let mut controller = self.events_mut().controllers.take::(id)?; - let result = f(&mut controller, self); - self.events_mut().controllers.put(id, controller); - Some(result) - } - - fn with_nearest_controller, T>( - &mut self, - origin: impl IdLike, - f: impl FnOnce(ControllerId, &mut C, &mut Self) -> T, - ) -> Option { - let render_handle = self.ui().render_state(); - let id = self - .events() - .controllers - .nearest_id::(origin.id(), &render_handle.get())?; - self.with_controller(id, |controller, rsc| f(id, controller, rsc)) - } - - fn set_command_target(&mut self, target: Option) { - self.events_mut().controllers.set_command_target(target); - } - - fn run_command(&mut self, command: Command) -> CommandResult { - let revision = self.events().controllers.command_target_revision(); - if let Some(boundary) = self.events().controllers.command_boundary() { - let render_handle = self.ui().render_state(); - let outside_boundary = - self.events() - .controllers - .command_target() - .is_none_or(|target| { - !self.events().controllers.is_below( - target.host(), - boundary, - &render_handle.get(), - ) - }); - if outside_boundary { - return CommandResult::Unused; - } - } - let Some((id, mut controller)) = self.events_mut().controllers.take_command_target() else { - return CommandResult::Unused; - }; - let result = controller.command(command, self); - self.events_mut().controllers.restore(id, controller); - if command == Command::Escape - && result != CommandResult::Unused - && self.events().controllers.command_target_revision() == revision - { - self.set_command_target(None); - } - result - } - - #[doc(hidden)] - fn run_command_before(&mut self, command: Command, boundary: impl IdLike) -> CommandResult { - let old = self.events().controllers.command_boundary(); - self.events_mut() - .controllers - .set_command_boundary(Some(boundary.id())); - let result = self.run_command(command); - self.events_mut().controllers.set_command_boundary(old); - result - } -} - -pub trait RunEvents: HasEvents { - fn run_event( - &mut self, - id: impl IdLike, - data: ::Data<'_>, - state: &mut Self::State, - ) { - // Keep the last completed frame read-locked for the whole callback. - // Rsc methods may take further shared reads through `render_state`, - // while any attempt to start a render from an event fails at the - // mutable-borrow boundary instead of exposing an in-progress tree. - let render_handle = self.ui().render_state(); - let _render_state = render_handle.get(); - let f = self.events_mut().get_type::().run_fn(id); - f(EventCtx { state, data }, self) - } -} -impl RunEvents for T {} diff --git a/iris/core/src/lib.rs b/iris/core/src/lib.rs deleted file mode 100644 index 3122f82..0000000 --- a/iris/core/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -#![feature(macro_metavar_expr_concat)] -#![feature(const_ops)] -#![feature(const_trait_impl)] -#![feature(const_convert)] -#![feature(unboxed_closures)] -#![feature(fn_traits)] -#![feature(const_destruct)] -#![feature(associated_type_defaults)] -#![feature(unsize)] -#![feature(coerce_unsized)] -#![feature(option_into_flat_iter)] - -mod attr; -mod event; -mod num; -mod orientation; -mod primitive; -mod render; -mod ui; -mod widget; - -pub mod util; - -pub use attr::*; -pub use event::*; -pub use num::*; -pub use orientation::*; -pub use primitive::*; -pub use render::*; -pub use ui::*; -pub use widget::*; diff --git a/iris/core/src/num.rs b/iris/core/src/num.rs deleted file mode 100644 index bcd3a08..0000000 --- a/iris/core/src/num.rs +++ /dev/null @@ -1,49 +0,0 @@ -use crate::util::Vec2; -use std::marker::Destruct; - -pub const trait UiNum { - fn to_f32(self) -> f32; -} - -const impl UiNum for f32 { - fn to_f32(self) -> f32 { - self - } -} - -const impl UiNum for u32 { - fn to_f32(self) -> f32 { - self as f32 - } -} - -const impl UiNum for i32 { - fn to_f32(self) -> f32 { - self as f32 - } -} - -pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 { - Vec2::new(x.to_f32(), y.to_f32()) -} - -const impl From for Vec2 { - fn from(v: T) -> Self { - Self { - x: v.to_f32(), - y: v.to_f32(), - } - } -} - -const impl From<(T, U)> for Vec2 -where - (T, U): const Destruct, -{ - fn from((x, y): (T, U)) -> Self { - Self { - x: x.to_f32(), - y: y.to_f32(), - } - } -} diff --git a/iris/core/src/orientation/align.rs b/iris/core/src/orientation/align.rs deleted file mode 100644 index 03c1677..0000000 --- a/iris/core/src/orientation/align.rs +++ /dev/null @@ -1,198 +0,0 @@ -use crate::vec2; - -use super::*; - -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct Align { - pub x: Option, - pub y: Option, -} - -impl Align { - pub const TOP_LEFT: RegionAlign = RegionAlign::TOP_LEFT; - pub const TOP_CENTER: RegionAlign = RegionAlign::TOP_CENTER; - pub const TOP_RIGHT: RegionAlign = RegionAlign::TOP_RIGHT; - pub const CENTER_LEFT: RegionAlign = RegionAlign::CENTER_LEFT; - pub const CENTER: RegionAlign = RegionAlign::CENTER; - pub const CENTER_RIGHT: RegionAlign = RegionAlign::CENTER_RIGHT; - pub const BOT_LEFT: RegionAlign = RegionAlign::BOT_LEFT; - pub const BOT_CENTER: RegionAlign = RegionAlign::BOT_CENTER; - pub const BOT_RIGHT: RegionAlign = RegionAlign::BOT_RIGHT; - pub const LEFT: CardinalAlign = CardinalAlign::LEFT; - pub const H_CENTER: CardinalAlign = CardinalAlign::H_CENTER; - pub const RIGHT: CardinalAlign = CardinalAlign::RIGHT; - pub const TOP: CardinalAlign = CardinalAlign::TOP; - pub const V_CENTER: CardinalAlign = CardinalAlign::V_CENTER; - pub const BOT: CardinalAlign = CardinalAlign::BOT; - - pub fn tuple(&self) -> (Option, Option) { - (self.x, self.y) - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum AxisAlign { - Neg, - Center, - Pos, -} - -impl AxisAlign { - pub const fn rel(&self) -> f32 { - match self { - Self::Neg => 0.0, - Self::Center => 0.5, - Self::Pos => 1.0, - } - } -} - -pub struct CardinalAlign { - axis: Axis, - align: AxisAlign, -} - -impl CardinalAlign { - pub const LEFT: Self = Self::new(Axis::X, AxisAlign::Neg); - pub const H_CENTER: Self = Self::new(Axis::X, AxisAlign::Center); - pub const RIGHT: Self = Self::new(Axis::X, AxisAlign::Pos); - pub const TOP: Self = Self::new(Axis::Y, AxisAlign::Neg); - pub const V_CENTER: Self = Self::new(Axis::Y, AxisAlign::Center); - pub const BOT: Self = Self::new(Axis::Y, AxisAlign::Pos); - - pub const fn new(axis: Axis, align: AxisAlign) -> Self { - Self { axis, align } - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub struct RegionAlign { - pub x: AxisAlign, - pub y: AxisAlign, -} - -impl RegionAlign { - pub const TOP_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Neg); - pub const TOP_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Neg); - pub const TOP_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Neg); - pub const CENTER_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Center); - pub const CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Center); - pub const CENTER_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Center); - pub const BOT_LEFT: Self = Self::new(AxisAlign::Neg, AxisAlign::Pos); - pub const BOT_CENTER: Self = Self::new(AxisAlign::Center, AxisAlign::Pos); - pub const BOT_RIGHT: Self = Self::new(AxisAlign::Pos, AxisAlign::Pos); - - pub const fn new(x: AxisAlign, y: AxisAlign) -> Self { - Self { x, y } - } - pub const fn rel(&self) -> Vec2 { - vec2(self.x.rel(), self.y.rel()) - } - - pub const fn pos(self) -> UiVec2 { - UiVec2::from(self) - } -} - -impl UiVec2 { - pub fn partial_align(&self, align: Align) -> UiRegion { - UiRegion { - x: if let Some(align) = align.x { - self.x.align(align) - } else { - UiSpan::FULL - }, - y: if let Some(align) = align.y { - self.y.align(align) - } else { - UiSpan::FULL - }, - } - } - - pub fn align(&self, align: RegionAlign) -> UiRegion { - UiRegion { - x: self.x.align(align.x), - y: self.y.align(align.y), - } - } -} - -impl Vec2 { - pub fn partial_align(&self, align: Align) -> UiRegion { - let s = UiVec2::from(*self); - UiRegion { - x: if let Some(align) = align.x { - s.x.align(align) - } else { - UiSpan::FULL - }, - y: if let Some(align) = align.y { - s.y.align(align) - } else { - UiSpan::FULL - }, - } - } - - pub fn align(&self, align: RegionAlign) -> UiRegion { - let s = UiVec2::from(*self); - UiRegion { - x: s.x.align(align.x), - y: s.y.align(align.y), - } - } -} - -impl UiScalar { - pub const fn align(&self, align: AxisAlign) -> UiSpan { - let rel = align.rel(); - let mut start = UiScalar::rel(rel); - start.abs -= self.abs * rel; - start.rel -= self.rel * rel; - let mut end = UiScalar::rel(rel); - end.abs += self.abs * (1.0 - rel); - end.rel += self.rel * (1.0 - rel); - UiSpan { start, end } - } -} - -impl From for Align { - fn from(region: RegionAlign) -> Self { - Self { - x: Some(region.x), - y: Some(region.y), - } - } -} - -impl From for RegionAlign { - fn from(align: Align) -> Self { - Self { - x: align.x.unwrap_or(AxisAlign::Center), - y: align.y.unwrap_or(AxisAlign::Center), - } - } -} - -impl From for RegionAlign { - fn from(align: CardinalAlign) -> Self { - Align::from(align).into() - } -} - -impl From for Align { - fn from(cardinal: CardinalAlign) -> Self { - let align = Some(cardinal.align); - match cardinal.axis { - Axis::X => Self { x: align, y: None }, - Axis::Y => Self { x: None, y: align }, - } - } -} - -const impl From for UiVec2 { - fn from(align: RegionAlign) -> Self { - Self::rel(align.rel()) - } -} diff --git a/iris/core/src/orientation/axis.rs b/iris/core/src/orientation/axis.rs deleted file mode 100644 index fa1f16a..0000000 --- a/iris/core/src/orientation/axis.rs +++ /dev/null @@ -1,115 +0,0 @@ -use super::*; - -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub enum Axis { - X, - Y, -} - -impl std::ops::Not for Axis { - type Output = Self; - - fn not(self) -> Self::Output { - match self { - Self::X => Self::Y, - Self::Y => Self::X, - } - } -} - -#[derive(Clone, Copy, Eq, PartialEq)] -pub struct Dir { - pub axis: Axis, - pub sign: Sign, -} - -impl Dir { - pub const fn new(axis: Axis, dir: Sign) -> Self { - Self { axis, sign: dir } - } - - pub const LEFT: Self = Self::new(Axis::X, Sign::Neg); - pub const RIGHT: Self = Self::new(Axis::X, Sign::Pos); - pub const UP: Self = Self::new(Axis::Y, Sign::Neg); - pub const DOWN: Self = Self::new(Axis::Y, Sign::Pos); -} - -#[derive(Clone, Copy, Eq, PartialEq)] -pub enum Sign { - Neg, - Pos, -} - -impl Vec2 { - pub fn axis(&self, axis: Axis) -> f32 { - match axis { - Axis::X => self.x, - Axis::Y => self.y, - } - } - - pub fn axis_mut(&mut self, axis: Axis) -> &mut f32 { - match axis { - Axis::X => &mut self.x, - Axis::Y => &mut self.y, - } - } - - pub const fn from_axis(axis: Axis, aligned: f32, ortho: f32) -> Self { - Self { - x: match axis { - Axis::X => aligned, - Axis::Y => ortho, - }, - y: match axis { - Axis::Y => aligned, - Axis::X => ortho, - }, - } - } -} - -pub const trait AxisT { - fn get() -> Axis; -} - -pub struct XAxis; -const impl AxisT for XAxis { - fn get() -> Axis { - Axis::X - } -} - -pub struct YAxis; -const impl AxisT for YAxis { - fn get() -> Axis { - Axis::Y - } -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct BothAxis { - pub x: T, - pub y: T, -} - -impl BothAxis { - pub const fn axis(&mut self) -> &mut T { - match A::get() { - Axis::X => &mut self.x, - Axis::Y => &mut self.y, - } - } - pub fn take_axis(self) -> T { - match A::get() { - Axis::X => self.x, - Axis::Y => self.y, - } - } - pub fn axis_dyn(&mut self, axis: Axis) -> &mut T { - match axis { - Axis::X => &mut self.x, - Axis::Y => &mut self.y, - } - } -} diff --git a/iris/core/src/orientation/len.rs b/iris/core/src/orientation/len.rs deleted file mode 100644 index 0d081ec..0000000 --- a/iris/core/src/orientation/len.rs +++ /dev/null @@ -1,404 +0,0 @@ -use super::*; -use crate::{UiNum, util::impl_op}; - -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub struct Size { - pub x: LayoutLen, - pub y: LayoutLen, -} - -/// A length resolved from physical pixels, density-independent pixels, and a -/// fraction of a reference length. Unlike [`LayoutLen`], it carries no claim -/// on space left over by a layout container. -#[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, - pub dp: f32, - pub rel: f32, -} - -/// A widget length plus its proportional claim on the space left after fixed -/// and relative lengths have been allocated. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct LayoutLen { - pub abs: f32, - pub dp: f32, - pub rel: f32, - pub rest: f32, -} - -impl From for Len { - fn from(value: N) -> Self { - Len::abs(value.to_f32()) - } -} - -impl From for LayoutLen { - fn from(value: N) -> Self { - Self::abs(value.to_f32()) - } -} - -impl From for LayoutLen { - fn from(value: Len) -> Self { - Self { - abs: value.abs, - dp: value.dp, - rel: value.rel, - rest: 0.0, - } - } -} - -impl, Y: Into> From<(X, Y)> for Size { - fn from((x, y): (X, Y)) -> Self { - Self { - x: x.into(), - y: y.into(), - } - } -} - -impl From for Size { - fn from(value: LayoutLen) -> Self { - Self { x: value, y: value } - } -} - -impl From for Size { - fn from(value: Len) -> Self { - Self::from(LayoutLen::from(value)) - } -} - -impl Size { - pub const ZERO: Self = Self { - x: LayoutLen::ZERO, - y: LayoutLen::ZERO, - }; - - pub const REST: Self = Self { - x: LayoutLen::REST, - y: LayoutLen::REST, - }; - - pub fn abs(v: Vec2) -> Self { - Self { - x: LayoutLen::abs(v.x), - y: LayoutLen::abs(v.y), - } - } - - pub fn rel(v: Vec2) -> Self { - Self { - x: LayoutLen::rel(v.x), - y: LayoutLen::rel(v.y), - } - } - - pub fn rest(v: Vec2) -> Self { - Self { - x: LayoutLen::rest(v.x), - y: LayoutLen::rest(v.y), - } - } - - pub fn to_uivec2(self, density: f32) -> UiVec2 { - UiVec2 { - x: self.x.apply_rest(density), - y: self.y.apply_rest(density), - } - } - - pub fn from_axis(axis: Axis, aligned: LayoutLen, ortho: LayoutLen) -> Self { - match axis { - Axis::X => Self { - x: aligned, - y: ortho, - }, - Axis::Y => Self { - x: ortho, - y: aligned, - }, - } - } - - pub fn axis(&self, axis: Axis) -> LayoutLen { - match axis { - Axis::X => self.x, - Axis::Y => self.y, - } - } -} - -impl LayoutLen { - 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, - }; - - /// 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 `LayoutLen` 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 + self.dp * density, - } - } - - /// The same fold as [`Self::apply_rest`] but staying a `LayoutLen`, so - /// `rest` survives: `dp` becomes physical pixels and every other - /// component is left alone. - /// - /// **A `LayoutLen` 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, - } - } - pub fn rel(rel: impl UiNum) -> Self { - Self { - abs: 0.0, - dp: 0.0, - rel: rel.to_f32(), - rest: 0.0, - } - } - pub fn rest(ratio: impl UiNum) -> Self { - Self { - abs: 0.0, - dp: 0.0, - rel: 0.0, - rest: ratio.to_f32(), - } - } -} - -impl Len { - pub const ZERO: Self = Self { - abs: 0.0, - dp: 0.0, - rel: 0.0, - }; - - pub fn abs(abs: impl UiNum) -> Self { - Self { - abs: abs.to_f32(), - dp: 0.0, - rel: 0.0, - } - } - - pub fn dp(dp: impl UiNum) -> Self { - Self { - abs: 0.0, - dp: dp.to_f32(), - rel: 0.0, - } - } - - pub fn rel(rel: impl UiNum) -> Self { - Self { - abs: 0.0, - dp: 0.0, - rel: rel.to_f32(), - } - } - - pub const fn fold_dp(self, density: f32) -> Self { - Self { - abs: self.abs + self.dp * density, - dp: 0.0, - rel: self.rel, - } - } - - pub const fn resolve(self, density: f32) -> UiScalar { - let folded = self.fold_dp(density); - UiScalar { - rel: folded.rel, - abs: folded.abs, - } - } -} - -pub mod len_fns { - use super::*; - - pub fn abs(abs: impl UiNum) -> Len { - Len::abs(abs) - } - pub fn dp(dp: impl UiNum) -> Len { - Len::dp(dp) - } - pub fn rel(rel: impl UiNum) -> Len { - Len::rel(rel) - } - pub fn rest(ratio: impl UiNum) -> LayoutLen { - LayoutLen { - abs: 0.0, - dp: 0.0, - rel: 0.0, - rest: ratio.to_f32(), - } - } -} - -impl_op!(LayoutLen Add add; abs dp rel rest); -impl_op!(LayoutLen Sub sub; abs dp rel rest); -impl_op!(Len Add add; abs dp rel); -impl_op!(Len Sub sub; abs dp rel); - -impl std::ops::Add for LayoutLen { - type Output = Self; - - fn add(self, rhs: Len) -> Self::Output { - self + Self::from(rhs) - } -} - -impl std::ops::Sub for LayoutLen { - type Output = Self; - - fn sub(self, rhs: Len) -> Self::Output { - self - Self::from(rhs) - } -} - -impl_op!(Size Add add; x y); -impl_op!(Size Sub sub; x y); - -impl Default for LayoutLen { - fn default() -> Self { - Self::rest(1.0) - } -} - -impl Default for Len { - fn default() -> Self { - Self::ZERO - } -} - -impl std::fmt::Display for Size { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} - -impl std::fmt::Display for LayoutLen { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 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)?; - } - if self.rest != 0.0 { - write!(f, "{} rest;", self.rest)?; - } - Ok(()) - } -} - -impl std::fmt::Display for Len { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 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)?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_ordinary_length_enters_layout_without_claiming_rest() { - let layout = LayoutLen::from(Len { - abs: 3.0, - dp: 4.0, - rel: 0.5, - }); - assert_eq!(layout.abs, 3.0); - assert_eq!(layout.dp, 4.0); - assert_eq!(layout.rel, 0.5); - assert_eq!(layout.rest, 0.0); - } - - #[test] - fn ordinary_and_layout_lengths_keep_their_own_defaults() { - assert_eq!(Len::default(), Len::ZERO); - assert_eq!(LayoutLen::default(), LayoutLen::REST); - } - - #[test] - fn adding_an_ordinary_length_preserves_a_layout_claim() { - assert_eq!( - LayoutLen::rest(2) + Len::dp(8), - LayoutLen { - abs: 0.0, - dp: 8.0, - rel: 0.0, - rest: 2.0, - } - ); - } -} diff --git a/iris/core/src/orientation/mod.rs b/iris/core/src/orientation/mod.rs deleted file mode 100644 index 65040ec..0000000 --- a/iris/core/src/orientation/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod align; -mod axis; -mod len; -mod pos; - -use crate::util::Vec2; - -pub use align::*; -pub use axis::*; -pub use len::*; -pub use pos::*; diff --git a/iris/core/src/orientation/pos.rs b/iris/core/src/orientation/pos.rs deleted file mode 100644 index 466022e..0000000 --- a/iris/core/src/orientation/pos.rs +++ /dev/null @@ -1,464 +0,0 @@ -use std::{fmt::Display, hash::Hash, marker::Destruct}; - -use super::*; -use crate::{ - UiNum, - util::{LerpUtil, impl_op}, -}; - -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)] -pub struct UiVec2 { - pub x: UiScalar, - pub y: UiScalar, -} - -impl UiVec2 { - pub const ZERO: Self = Self { - x: UiScalar::ZERO, - y: UiScalar::ZERO, - }; - - pub const fn new(x: UiScalar, y: UiScalar) -> Self { - Self { x, y } - } - - pub const fn abs(abs: impl const Into) -> Self { - let abs = abs.into(); - Self { - x: UiScalar::abs(abs.x), - y: UiScalar::abs(abs.y), - } - } - - pub const fn rel(rel: impl const Into) -> Self { - let rel = rel.into(); - Self { - x: UiScalar::rel(rel.x), - y: UiScalar::rel(rel.y), - } - } - - pub const fn shift(&mut self, offset: impl const Into) { - let offset = offset.into(); - *self += offset; - } - - pub const fn offset(mut self, offset: impl const Into) -> Self { - self.shift(offset); - self - } - - pub const fn within(&self, region: &UiRegion) -> UiVec2 { - UiVec2 { - x: self.x.within(®ion.x), - y: self.y.within(®ion.y), - } - } - - pub const fn outside(&self, region: &UiRegion) -> UiVec2 { - UiVec2 { - x: self.x.outside(®ion.x), - y: self.y.outside(®ion.y), - } - } - - pub fn axis_mut(&mut self, axis: Axis) -> &mut UiScalar { - match axis { - Axis::X => &mut self.x, - Axis::Y => &mut self.y, - } - } - - pub fn axis(&self, axis: Axis) -> UiScalar { - match axis { - Axis::X => self.x, - Axis::Y => self.y, - } - } - - pub fn to_abs(&self, rel: Vec2) -> Vec2 { - Vec2 { - x: self.x.to_abs(rel.x), - y: self.y.to_abs(rel.y), - } - } - - pub const FULL_SIZE: Self = Self::rel(Vec2::ONE); - - pub const fn from_axis(axis: Axis, aligned: UiScalar, ortho: UiScalar) -> Self { - match axis { - Axis::X => Self { - x: aligned, - y: ortho, - }, - Axis::Y => Self { - x: ortho, - y: aligned, - }, - } - } - - pub fn get_abs(&self) -> Vec2 { - (self.x.abs, self.y.abs).into() - } - - pub fn get_rel(&self) -> Vec2 { - (self.x.rel, self.y.rel).into() - } - - pub fn abs_mut(&mut self) -> Vec2View<'_> { - Vec2View { - x: &mut self.x.abs, - y: &mut self.y.abs, - } - } -} - -impl Display for UiVec2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "rel{};abs{}", self.get_rel(), self.get_abs()) - } -} - -impl_op!(UiVec2 Add add; x y); -impl_op!(UiVec2 Sub sub; x y); - -const impl From for UiVec2 { - fn from(abs: Vec2) -> Self { - Self::abs(abs) - } -} - -const impl From<(T, U)> for UiVec2 -where - (T, U): const Destruct, -{ - fn from(abs: (T, U)) -> Self { - Self::abs(abs) - } -} - -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)] -pub struct UiScalar { - pub rel: f32, - pub abs: f32, -} - -impl Eq for UiScalar {} -impl Hash for UiScalar { - fn hash(&self, state: &mut H) { - state.write_u32(self.rel.to_bits()); - state.write_u32(self.abs.to_bits()); - } -} - -impl_op!(UiScalar Add add; rel abs); -impl_op!(UiScalar Sub sub; rel abs); - -impl UiScalar { - pub const ZERO: Self = Self { rel: 0.0, abs: 0.0 }; - pub const FULL: Self = Self { rel: 1.0, abs: 0.0 }; - - pub const fn new(rel: f32, abs: f32) -> Self { - Self { rel, abs } - } - - pub const fn rel(rel: f32) -> Self { - Self { rel, abs: 0.0 } - } - - pub const fn abs(abs: f32) -> Self { - Self { rel: 0.0, abs } - } - - pub const fn rel_min() -> Self { - Self::new(0.0, 0.0) - } - - pub const fn rel_max() -> Self { - Self::new(1.0, 0.0) - } - - pub const fn max(&self, other: Self) -> Self { - Self { - rel: self.rel.max(other.rel), - abs: self.abs.max(other.abs), - } - } - - pub const fn min(&self, other: Self) -> Self { - Self { - rel: self.rel.min(other.rel), - abs: self.abs.min(other.abs), - } - } - - pub const fn offset(mut self, amt: f32) -> Self { - self.abs += amt; - self - } - - pub const fn within(&self, span: &UiSpan) -> Self { - let anchor = self.rel.lerp(span.start.rel, span.end.rel); - let offset = self.abs + self.rel.lerp(span.start.abs, span.end.abs); - Self { - rel: anchor, - abs: offset, - } - } - - pub const fn outside(&self, span: &UiSpan) -> Self { - let rel = self.rel.lerp_inv(span.start.rel, span.end.rel); - let abs = self.abs - rel.lerp(span.start.abs, span.end.abs); - Self { rel, abs } - } - - pub fn within_len(&self, len: UiScalar) -> Self { - self.within(&UiSpan { - start: UiScalar::ZERO, - end: len, - }) - } - - pub fn select_len(&self, len: UiScalar) -> Self { - len.within_len(*self) - } - - pub const fn flip(&mut self) { - self.rel = 1.0 - self.rel; - self.abs = -self.abs; - } - - pub const fn to(&self, end: Self) -> UiSpan { - UiSpan { start: *self, end } - } - - pub const fn to_abs(&self, rel: f32) -> f32 { - self.rel * rel + self.abs - } -} - -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] -pub struct UiSpan { - pub start: UiScalar, - pub end: UiScalar, -} - -impl UiSpan { - pub const FULL: Self = Self { - start: UiScalar::ZERO, - end: UiScalar::FULL, - }; - - pub const fn rel(rel: f32) -> Self { - Self { - start: UiScalar::rel(rel), - end: UiScalar::rel(rel), - } - } - - pub const fn new(start: UiScalar, end: UiScalar) -> Self { - Self { start, end } - } - - pub const fn flip(&mut self) { - self.start.flip(); - self.end.flip(); - std::mem::swap(&mut self.start.rel, &mut self.end.rel); - std::mem::swap(&mut self.start.abs, &mut self.end.abs); - } - - pub const fn shift(&mut self, offset: UiScalar) { - self.start += offset; - self.end += offset; - } - - pub const fn within(&self, parent: &Self) -> Self { - Self { - start: self.start.within(parent), - end: self.end.within(parent), - } - } - - pub const fn outside(&self, parent: &Self) -> Self { - Self { - start: self.start.outside(parent), - end: self.end.outside(parent), - } - } - - pub const fn len(&self) -> UiScalar { - self.end - self.start - } -} - -#[repr(C)] -#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] -pub struct UiRegion { - pub x: UiSpan, - pub y: UiSpan, -} - -impl UiRegion { - pub const FULL: Self = Self { - x: UiSpan::FULL, - y: UiSpan::FULL, - }; - - pub const fn new(x: UiSpan, y: UiSpan) -> Self { - Self { x, y } - } - - pub const fn rel(rel: Vec2) -> Self { - Self { - x: UiSpan::rel(rel.x), - y: UiSpan::rel(rel.y), - } - } - pub const fn within(&self, parent: &Self) -> Self { - Self { - x: self.x.within(&parent.x), - y: self.y.within(&parent.y), - } - } - pub const fn outside(&self, parent: &Self) -> Self { - Self { - x: self.x.outside(&parent.x), - y: self.y.outside(&parent.y), - } - } - - pub const fn axis(&mut self, axis: Axis) -> &UiSpan { - match axis { - Axis::X => &self.x, - Axis::Y => &self.y, - } - } - - pub const fn axis_mut(&mut self, axis: Axis) -> &mut UiSpan { - match axis { - Axis::X => &mut self.x, - Axis::Y => &mut self.y, - } - } - - pub const fn flip(&mut self, axis: Axis) { - match axis { - Axis::X => self.x.flip(), - Axis::Y => self.y.flip(), - } - } - - pub fn shift(&mut self, offset: impl Into) { - let offset = offset.into(); - self.x.shift(offset.x); - self.y.shift(offset.y); - } - - pub fn offset(mut self, offset: impl Into) -> Self { - self.shift(offset); - self - } - - pub fn to_px(&self, size: Vec2) -> PixelRegion { - PixelRegion { - top_left: self.top_left().get_rel() * size + self.top_left().get_abs(), - bot_right: self.bot_right().get_rel() * size + self.bot_right().get_abs(), - } - } - - pub const fn center(&self) -> UiVec2 { - Align::CENTER.pos().within(self) - } - - pub const fn size(&self) -> UiVec2 { - UiVec2 { - x: self.x.len(), - y: self.y.len(), - } - } - - pub const fn top_left(&self) -> UiVec2 { - UiVec2 { - x: self.x.start, - y: self.y.start, - } - } - - pub const fn bot_right(&self) -> UiVec2 { - UiVec2 { - x: self.x.end, - y: self.y.end, - } - } - - pub const fn from_axis(axis: Axis, aligned: UiSpan, ortho: UiSpan) -> Self { - Self { - x: match axis { - Axis::X => aligned, - Axis::Y => ortho, - }, - y: match axis { - Axis::X => ortho, - Axis::Y => aligned, - }, - } - } -} - -impl Display for UiRegion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{} -> {} (size: {})", - self.top_left(), - self.bot_right(), - self.size() - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PixelRegion { - pub top_left: Vec2, - pub bot_right: Vec2, -} - -impl PixelRegion { - pub fn contains(&self, pos: Vec2) -> bool { - pos.x >= self.top_left.x - && pos.x <= self.bot_right.x - && pos.y >= self.top_left.y - && pos.y <= self.bot_right.y - } - - pub fn size(&self) -> Vec2 { - self.bot_right - self.top_left - } -} - -impl Display for PixelRegion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} -> {}", self.top_left, self.bot_right) - } -} - -pub struct Vec2View<'a> { - pub x: &'a mut f32, - pub y: &'a mut f32, -} - -impl Vec2View<'_> { - pub fn set(&mut self, other: Vec2) { - *self.x = other.x; - *self.y = other.y; - } - - pub fn add(&mut self, other: Vec2) { - *self.x += other.x; - *self.y += other.y; - } -} diff --git a/iris/core/src/primitive/color.rs b/iris/core/src/primitive/color.rs deleted file mode 100644 index cb0746b..0000000 --- a/iris/core/src/primitive/color.rs +++ /dev/null @@ -1,488 +0,0 @@ -use crate::util::{Dirty, Resources, StrongRscId}; -use std::{cell::RefCell, fmt, rc::Rc}; - -/// Encoded, straight-alpha sRGB at an input boundary. -/// -/// Palette literals, decoded images and colour glyph bitmaps use this -/// convention. A solid paint is converted to linear light when it enters the -/// paint table; the renderer never performs colour arithmetic on these bytes. -#[repr(C)] -#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, bytemuck::Pod, bytemuck::Zeroable)] -pub struct Srgba8 { - pub r: u8, - pub g: u8, - pub b: u8, - pub a: u8, -} - -impl Srgba8 { - pub const BLACK: Self = Self::rgb(0, 0, 0); - pub const WHITE: Self = Self::rgb(255, 255, 255); - pub const GRAY: Self = Self::rgb(127, 127, 127); - pub const RED: Self = Self::rgb(255, 0, 0); - pub const ORANGE: Self = Self::rgb(255, 127, 0); - pub const YELLOW: Self = Self::rgb(255, 255, 0); - pub const LIME: Self = Self::rgb(127, 255, 0); - pub const GREEN: Self = Self::rgb(0, 255, 0); - pub const TURQUOISE: Self = Self::rgb(0, 255, 127); - pub const CYAN: Self = Self::rgb(0, 255, 255); - pub const SKY: Self = Self::rgb(0, 127, 255); - pub const BLUE: Self = Self::rgb(0, 0, 255); - pub const PURPLE: Self = Self::rgb(127, 0, 255); - pub const MAGENTA: Self = Self::rgb(255, 0, 255); - pub const NONE: Self = Self::new(0, 0, 0, 0); - - pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { - Self { r, g, b, a } - } - - pub const fn rgb(r: u8, g: u8, b: u8) -> Self { - Self::new(r, g, b, 255) - } - - pub fn to_linear(self) -> LinearRgba { - LinearRgba::new( - srgb_to_linear(self.r as f32 / 255.0), - srgb_to_linear(self.g as f32 / 255.0), - srgb_to_linear(self.b as f32 / 255.0), - self.a as f32 / 255.0, - ) - } -} - -/// Straight-alpha RGBA in linear-light sRGB primaries. -/// -/// This is Iris's working representation: manipulate and interpolate colours -/// here, then put the result in [`Paints`]. The GPU paint buffer stores this -/// exact layout as `vec4`. -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)] -pub struct LinearRgba { - pub r: f32, - pub g: f32, - pub b: f32, - pub a: f32, -} - -impl LinearRgba { - pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0); - pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0); - pub const NONE: Self = Self::new(0.0, 0.0, 0.0, 0.0); - - pub const fn new(r: f32, g: f32, b: f32, a: f32) -> Self { - Self { r, g, b, a } - } - - pub const fn rgb(r: f32, g: f32, b: f32) -> Self { - Self::new(r, g, b, 1.0) - } - - pub fn mul_rgb(self, amount: f32) -> Self { - Self::new(self.r * amount, self.g * amount, self.b * amount, self.a) - } - - pub fn darker(self, amount: f32) -> Self { - self.mul_rgb(1.0 - amount) - } - - pub fn brighter(self, amount: f32) -> Self { - Self::new( - self.r + (1.0 - self.r) * amount, - self.g + (1.0 - self.g) * amount, - self.b + (1.0 - self.b) * amount, - self.a, - ) - } - - pub fn to_wgpu(self) -> wgpu::Color { - wgpu::Color { - r: self.r as f64, - g: self.g as f64, - b: self.b as f64, - a: self.a as f64, - } - } -} - -fn srgb_to_linear(value: f32) -> f32 { - if value <= 0.04045 { - value / 12.92 - } else { - ((value + 0.055) / 1.055).powf(2.4) - } -} - -/// A description that can be registered in Iris's paint table. -/// -/// Only solid paints exist today. Keeping registration behind this trait and -/// making primitives carry [`PaintId`] leaves one place to add gradient or -/// texture paint records later. -pub trait Paint: private::Sealed + 'static { - #[doc(hidden)] - fn add_to(&self, paints: &mut Paints) -> PaintId; - - #[doc(hidden)] - fn replace(&self, paints: &mut Paints, slot: u32); - - /// Erases this definition so a widget can register it lazily on its - /// first draw. [`PaintId`] overrides this to stay a direct handle. - #[doc(hidden)] - fn into_value(self) -> PaintValue - where - Self: Sized, - { - PaintValue::pending(self) - } -} - -impl Paint for Srgba8 { - fn add_to(&self, paints: &mut Paints) -> PaintId { - paints.add_linear(self.to_linear()) - } - - fn replace(&self, paints: &mut Paints, slot: u32) { - paints.replace_linear(slot, self.to_linear()); - } -} - -impl Paint for LinearRgba { - fn add_to(&self, paints: &mut Paints) -> PaintId { - paints.add_linear(*self) - } - - fn replace(&self, paints: &mut Paints, slot: u32) { - paints.replace_linear(slot, *self); - } -} - -mod private { - pub trait Sealed {} - impl Sealed for super::Srgba8 {} - impl Sealed for super::LinearRgba {} - impl Sealed for super::PaintId {} -} - -struct PaintRsc; - -/// A stable reference to one entry in a UI's paint table. -/// -/// Built-in IDs name the same reserved entries in every [`Paints`]. IDs -/// returned by [`Paints::add`] retain their slot until the last clone held by a -/// widget, shaped text or retained draw is dropped. -#[derive(Clone, Debug)] -pub struct PaintId { - slot: u32, - strong: Option>, -} - -impl PaintId { - pub const BLACK: Self = Self::builtin(0); - pub const WHITE: Self = Self::builtin(1); - pub const GRAY: Self = Self::builtin(2); - pub const RED: Self = Self::builtin(3); - pub const ORANGE: Self = Self::builtin(4); - pub const YELLOW: Self = Self::builtin(5); - pub const LIME: Self = Self::builtin(6); - pub const GREEN: Self = Self::builtin(7); - pub const TURQUOISE: Self = Self::builtin(8); - pub const CYAN: Self = Self::builtin(9); - pub const SKY: Self = Self::builtin(10); - pub const BLUE: Self = Self::builtin(11); - pub const PURPLE: Self = Self::builtin(12); - pub const MAGENTA: Self = Self::builtin(13); - pub const NONE: Self = Self::builtin(14); - - const fn builtin(slot: u32) -> Self { - Self { slot, strong: None } - } - - pub(crate) fn slot(&self) -> u32 { - self.slot - } - - fn is_managed(&self) -> bool { - self.strong.is_some() - } -} - -impl Default for PaintId { - fn default() -> Self { - Self::BLACK - } -} - -impl PartialEq for PaintId { - fn eq(&self, other: &Self) -> bool { - self.slot == other.slot - } -} - -impl Eq for PaintId {} - -impl Paint for PaintId { - fn add_to(&self, _paints: &mut Paints) -> PaintId { - self.clone() - } - - fn replace(&self, paints: &mut Paints, slot: u32) { - let value = paints.entries[self.slot as usize]; - paints.replace_linear(slot, value); - } - - fn into_value(self) -> PaintValue { - PaintValue(PaintValueInner::Id(self)) - } -} - -struct PendingPaint { - definition: Box, - resolved: RefCell>, -} - -#[derive(Clone)] -enum PaintValueInner { - Id(PaintId), - Pending(Rc), -} - -/// A widget property containing either an existing paint-table ID or a paint -/// definition that will receive an ID the first time it is drawn. -/// -/// Pending definitions are shared across clones and registered only once. -/// Each property replaces its own pending variant with the resulting direct -/// ID after that first resolution, so later draws take the direct path. -#[derive(Clone)] -pub struct PaintValue(PaintValueInner); - -impl PaintValue { - fn pending(paint: impl Paint) -> Self { - Self(PaintValueInner::Pending(Rc::new(PendingPaint { - definition: Box::new(paint), - resolved: RefCell::new(None), - }))) - } - - pub fn resolve(&mut self, paints: &mut Paints) -> &PaintId { - if let PaintValueInner::Pending(pending) = &self.0 { - let resolved = pending.resolved.borrow().clone(); - let id = match resolved { - Some(id) => id, - None => { - let id = pending.definition.add_to(paints); - *pending.resolved.borrow_mut() = Some(id.clone()); - id - } - }; - self.0 = PaintValueInner::Id(id); - } - let PaintValueInner::Id(id) = &self.0 else { - unreachable!() - }; - id - } - - pub fn is(&self, id: &PaintId) -> bool { - match &self.0 { - PaintValueInner::Id(current) => current == id, - PaintValueInner::Pending(pending) => pending.resolved.borrow().as_ref() == Some(id), - } - } -} - -impl fmt::Debug for PaintValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self.0 { - PaintValueInner::Id(id) => f.debug_tuple("PaintValue").field(id).finish(), - PaintValueInner::Pending(_) => f.write_str("PaintValue(Pending)"), - } - } -} - -const BUILTIN_PAINTS: [Srgba8; 15] = [ - Srgba8::BLACK, - Srgba8::WHITE, - Srgba8::GRAY, - Srgba8::RED, - Srgba8::ORANGE, - Srgba8::YELLOW, - Srgba8::LIME, - Srgba8::GREEN, - Srgba8::TURQUOISE, - Srgba8::CYAN, - Srgba8::SKY, - Srgba8::BLUE, - Srgba8::PURPLE, - Srgba8::MAGENTA, - Srgba8::NONE, -]; - -/// CPU-side paint table and the dirty set for its GPU mirror. -pub struct Paints { - resources: Resources, - entries: Vec, - dirty: Dirty, -} - -impl Paints { - pub fn new() -> Self { - let mut resources = Resources::new(); - for (slot, _) in BUILTIN_PAINTS.iter().enumerate() { - let id = resources.add_static(PaintRsc); - assert_eq!(id.slot(), slot as u32); - } - Self { - resources, - entries: BUILTIN_PAINTS.map(Srgba8::to_linear).to_vec(), - dirty: Dirty::new_all(), - } - } - - pub fn add(&mut self, paint: impl Paint) -> PaintId { - paint.add_to(self) - } - - fn add_linear(&mut self, value: LinearRgba) -> PaintId { - self.free_released(); - let old_capacity = self.resources.capacity(); - let strong = self.resources.add(PaintRsc); - let slot = strong.slot(); - if (slot as usize) < old_capacity { - self.entries[slot as usize] = value; - self.dirty.mark(slot as usize); - } else { - self.entries.push(value); - self.dirty.mark(slot as usize); - } - PaintId { - slot, - strong: Some(strong), - } - } - - /// Replaces one managed paint in place. Every primitive keeps the same - /// index, so a theme change dirties this table and no primitive buffer. - pub fn set(&mut self, id: &PaintId, paint: impl Paint) { - assert!( - id.is_managed(), - "a reserved built-in paint cannot be replaced; allocate a theme slot with Paints::add" - ); - paint.replace(self, id.slot); - } - - fn replace_linear(&mut self, slot: u32, value: LinearRgba) { - self.entries[slot as usize] = value; - self.dirty.mark(slot as usize); - } - - pub fn get(&self, id: &PaintId) -> LinearRgba { - self.entries[id.slot as usize] - } - - pub fn free_released(&mut self) { - let entries = &mut self.entries; - let dirty = &mut self.dirty; - self.resources.apply(|id, _| { - let slot = id.slot(); - entries[slot as usize] = LinearRgba::NONE; - dirty.mark(slot as usize); - }); - } - - /// A new GPU device has no copy of this table even when the CPU-side UI - /// and its paint IDs survived an Android surface recreation. - pub fn reupload(&mut self) { - self.dirty.mark_all(); - } - - pub fn for_upload(&mut self) -> (&[LinearRgba], &mut Dirty) { - (&self.entries, &mut self.dirty) - } -} - -impl Default for Paints { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn srgb_bytes_become_linear_without_transforming_alpha() { - let got = Srgba8::new(17, 127, 255, 64).to_linear(); - assert!((got.r - 0.005605).abs() < 0.000001); - assert!((got.g - 0.212231).abs() < 0.000001); - assert_eq!(got.b, 1.0); - assert!((got.a - 64.0 / 255.0).abs() < f32::EPSILON); - } - - #[test] - fn changing_a_paint_keeps_its_id_and_dirties_only_its_slot() { - let mut paints = Paints::new(); - let id = paints.add(Srgba8::rgb(17, 17, 27)); - let slot = id.slot(); - let (_, dirty) = paints.for_upload(); - dirty.clear(); - - paints.set(&id, Srgba8::rgb(205, 214, 244)); - let (_, dirty) = paints.for_upload(); - assert!(dirty.contains(slot as usize)); - assert_eq!(id.slot(), slot); - } - - #[test] - fn a_released_paint_slot_is_reused_only_after_the_last_clone() { - let mut paints = Paints::new(); - let id = paints.add(Srgba8::RED); - let slot = id.slot(); - let held = id.clone(); - drop(id); - paints.free_released(); - let other = paints.add(Srgba8::GREEN); - assert_ne!(other.slot(), slot); - - drop(held); - paints.free_released(); - let reused = paints.add(Srgba8::BLUE); - assert_eq!(reused.slot(), slot); - } - - #[test] - fn cloned_pending_paints_register_once_and_then_become_direct_ids() { - let mut paints = Paints::new(); - let mut first = Srgba8::rgb(17, 17, 27).into_value(); - let mut second = first.clone(); - let before = paints.entries.len(); - - let first_id = first.resolve(&mut paints).clone(); - let second_id = second.resolve(&mut paints).clone(); - - assert_eq!(first_id, second_id); - assert_eq!(paints.entries.len(), before + 1); - assert!(first.is(&first_id)); - assert!(second.is(&first_id)); - } - - #[test] - fn independently_constructed_inline_solids_get_independent_slots() { - let mut paints = Paints::new(); - let mut first = Srgba8::rgb(23, 42, 71).into_value(); - let mut second = Srgba8::rgb(23, 42, 71).into_value(); - let before = paints.entries.len(); - - let first_id = first.resolve(&mut paints).clone(); - let second_id = second.resolve(&mut paints).clone(); - assert_ne!(first_id, second_id); - assert_eq!(paints.entries.len(), before + 2); - } - - #[test] - fn explicit_theme_paints_with_equal_values_remain_independent() { - let mut paints = Paints::new(); - let first = paints.add(Srgba8::rgb(23, 42, 71)); - let second = paints.add(Srgba8::rgb(23, 42, 71)); - assert_ne!(first.slot(), second.slot()); - } -} diff --git a/iris/core/src/primitive/layer.rs b/iris/core/src/primitive/layer.rs deleted file mode 100644 index b3c7dd9..0000000 --- a/iris/core/src/primitive/layer.rs +++ /dev/null @@ -1,252 +0,0 @@ -use std::ops::{Index, IndexMut}; - -use crate::{render::LayerOrder, util::to_mut}; - -pub type LayerId = usize; - -struct LayerNode { - next: Ptr, - prev: Ptr, - child: Option, - depth: usize, - data: T, -} - -#[derive(Clone, Copy, Debug)] -enum Ptr { - Next(usize), - Parent(usize), - None, -} - -pub struct Layers { - vec: Vec>, - last: usize, -} - -#[derive(Clone, Copy)] -struct Child { - head: usize, - tail: usize, -} - -pub type PrimitiveLayers = Layers; - -impl Layers { - pub fn new() -> Layers { - Self { - vec: vec![LayerNode::head()], - last: 0, - } - } - - pub fn clear(&mut self) { - self.vec.clear(); - self.vec.push(LayerNode::head()); - } - - fn push(&mut self, node: LayerNode) -> LayerId { - let i = self.vec.len(); - self.vec.push(node); - i - } - - pub fn next(&mut self, i: LayerId) -> LayerId { - if let Ptr::Next(i) = self.vec[i].next { - return i; - } - let i_new = self.push(LayerNode::new( - T::default(), - self.vec[i].next, - Ptr::Next(i), - self.vec[i].depth, - )); - self.vec[i].next = Ptr::Next(i_new); - self.vec[i_new].prev = Ptr::Next(i); - match self.vec[i_new].next { - Ptr::Next(i) => self.vec[i].prev = Ptr::Next(i_new), - Ptr::Parent(i) => self.vec[i].child.as_mut().unwrap().tail = i_new, - Ptr::None => self.last = i_new, - } - i_new - } - - pub fn child(&mut self, i: LayerId) -> LayerId { - if let Some(c) = self.vec[i].child { - return c.head; - } - let i_child = self.push(LayerNode::new( - T::default(), - Ptr::Parent(i), - Ptr::Parent(i), - self.vec[i].depth + 1, - )); - self.vec[i].child = Some(Child { - head: i_child, - tail: i_child, - }); - i_child - } - - pub fn iter_mut(&mut self) -> LayerIteratorMut<'_, T> { - LayerIteratorMut::new(&mut self.vec, self.last) - } - - pub fn iter_orderless_mut(&mut self) -> impl Iterator { - self.vec.iter_mut().map(|n| &mut n.data).enumerate() - } - - pub fn iter(&self) -> impl Iterator { - self.indices().map(|i| (i, &self.vec[i].data)) - } - - pub fn iter_depth(&self) -> impl Iterator { - self.indices() - .map(|i| ((i, self.vec[i].depth), &self.vec[i].data)) - } - - pub fn indices(&self) -> LayerIndexIterator<'_, T> { - LayerIndexIterator::new(&self.vec, self.last) - } -} - -impl Default for Layers { - fn default() -> Self { - Self::new() - } -} - -impl Index for Layers { - type Output = T; - - fn index(&self, index: LayerId) -> &Self::Output { - &self.vec[index].data - } -} - -impl IndexMut for Layers { - fn index_mut(&mut self, index: LayerId) -> &mut Self::Output { - &mut self.vec[index].data - } -} - -impl LayerNode { - pub fn new(data: T, next: Ptr, prev: Ptr, depth: usize) -> Self { - Self { - next, - prev, - child: None, - data, - depth, - } - } - - pub fn head() -> Self { - Self::new(T::default(), Ptr::None, Ptr::None, 0) - } -} - -pub struct LayerIteratorMut<'a, T> { - inner: LayerIndexIterator<'a, T>, -} - -impl<'a, T> Iterator for LayerIteratorMut<'a, T> { - type Item = (usize, &'a mut T); - - fn next(&mut self) -> Option { - let i = self.inner.next()?; - // SAFETY: requires index iterator to work properly - let layer = unsafe { to_mut(&self.inner.vec[i].data) }; - Some((i, layer)) - } -} - -impl<'a, T> DoubleEndedIterator for LayerIteratorMut<'a, T> { - fn next_back(&mut self) -> Option { - let i = self.inner.next_back()?; - // SAFETY: requires index iterator to work properly - let layer = unsafe { to_mut(&self.inner.vec[i].data) }; - Some((i, layer)) - } -} - -impl<'a, T> LayerIteratorMut<'a, T> { - fn new(vec: &'a mut Vec>, last: usize) -> Self { - Self { - inner: LayerIndexIterator::new(vec, last), - } - } -} - -pub struct LayerIndexIterator<'a, T> { - next: Option, - next_back: Option, - vec: &'a Vec>, -} - -impl<'a, T> Iterator for LayerIndexIterator<'a, T> { - type Item = usize; - - fn next(&mut self) -> Option { - let ret_i = self.next?; - let node = &self.vec[ret_i]; - self.next = if let Some(c) = node.child { - Some(c.head) - } else if let Ptr::Next(i) = node.next { - Some(i) - } else if let Ptr::Parent(i) = node.next { - let mut node = &self.vec[i]; - while let Ptr::Parent(i) = node.next { - node = &self.vec[i]; - } - if let Ptr::Next(i) = node.next { - Some(i) - } else { - None - } - } else { - None - }; - if self.next_back.unwrap() == ret_i { - self.next = None; - self.next_back = None; - } - Some(ret_i) - } -} - -impl<'a, T> DoubleEndedIterator for LayerIndexIterator<'a, T> { - fn next_back(&mut self) -> Option { - let ret_i = self.next_back?; - let node = &self.vec[ret_i]; - self.next_back = if let Ptr::Next(mut i) = node.prev { - while let Some(c) = self.vec[i].child { - i = c.tail - } - Some(i) - } else if let Ptr::Parent(i) = node.prev { - Some(i) - } else { - None - }; - if self.next.unwrap() == ret_i { - self.next = None; - self.next_back = None; - } - Some(ret_i) - } -} - -impl<'a, T> LayerIndexIterator<'a, T> { - fn new(vec: &'a Vec>, last: usize) -> Self { - let mut last = last; - while let Some(c) = vec[last].child { - last = c.tail; - } - Self { - next: Some(0), - next_back: Some(last), - vec, - } - } -} diff --git a/iris/core/src/primitive/mod.rs b/iris/core/src/primitive/mod.rs deleted file mode 100644 index 8284a05..0000000 --- a/iris/core/src/primitive/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -mod color; -mod layer; -mod text; -mod texture; - -pub use color::*; -pub use layer::*; -pub use text::*; -pub use texture::*; diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs deleted file mode 100644 index e8a41fe..0000000 --- a/iris/core/src/primitive/text.rs +++ /dev/null @@ -1,1135 +0,0 @@ -use crate::{ - Align, GlyphAtlas, GlyphKey, Len, PaintId, PlacedGlyph, RegionAlign, Textures, WidgetId, - util::{Resources, RscHandle, StrongRscId, Vec2, WeakRscId}, -}; -use parley::{ - Alignment, AlignmentOptions, FontContext, FontFamily, FontStyle, FontWeight, GenericFamily, - Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, - fontique::{Blob, Collection, CollectionOptions, FamilyId, FontInfoOverride, FontWidth}, -}; -use std::{ - cell::{Ref, RefCell, RefMut}, - collections::HashMap, - fmt, - ops::{Deref, DerefMut, Range}, - rc::Rc, - sync::Arc, -}; -use swash::{ - FontRef, - scale::{Render, ScaleContext, Source, StrikeWith}, - zeno::{Format, Vector}, -}; - -#[derive(Clone, Debug)] -pub struct FontDiagnostics { - pub families_found: usize, - pub default_family: Option, - pub default_mono_family: Option, - pub regular_resolved: Option, - pub bold_resolved: Option, - pub italic_resolved: Option, - pub mono_resolved: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum FontRegistrationError { - AlreadyRegistered(String), - NotRegistered(String), - InvalidFont(String), -} - -impl fmt::Display for FontRegistrationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::AlreadyRegistered(family) => { - write!(f, "font data is already registered for {family:?}") - } - Self::NotRegistered(family) => { - write!(f, "no font data is registered for {family:?}") - } - Self::InvalidFont(family) => { - write!( - f, - "font data registered for {family:?} contains no usable fonts" - ) - } - } - } -} - -impl std::error::Error for FontRegistrationError {} - -/// The bucket used by [`crate::Ui::register_font`] when none is specified. -pub const DEFAULT_GLYPH_BUCKET: &str = "default"; - -struct RegisteredFace { - family: FamilyId, - width: FontWidth, - style: FontStyle, - weight: FontWeight, -} - -struct RegisteredFont { - private_name: String, - bucket: u64, - font_id: u64, - faces: Vec, -} - -pub struct TextData { - 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 cursor movement and - /// hit-testing shape text from event callbacks that have no `Painter`, - /// so they have 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, - registered_families: HashMap, - glyph_buckets: HashMap, - font_buckets: HashMap, - next_registered_family: u64, - next_glyph_bucket: u64, -} - -impl Default for TextData { - fn default() -> Self { - let mut font_cx = FontContext::new(); - patch_android_monospace(&mut font_cx); - Self { - font_cx, - layout_cx: LayoutContext::new(), - scale_cx: ScaleContext::new(), - atlas: GlyphAtlas::default(), - density: 1.0, - registered_families: HashMap::new(), - glyph_buckets: HashMap::new(), - font_buckets: HashMap::new(), - next_registered_family: 0, - next_glyph_bucket: 1, - } - } -} - -/// 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; - } - } -} - -#[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 { - pub(crate) fn register_font( - &mut self, - family: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), FontRegistrationError> { - self.register_font_in(family, DEFAULT_GLYPH_BUCKET, data) - } - - pub(crate) fn register_font_in( - &mut self, - family: impl AsRef, - bucket: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), FontRegistrationError> { - let family = family.as_ref().to_owned(); - if self.registered_families.contains_key(&family) { - return Err(FontRegistrationError::AlreadyRegistered(family)); - } - - let private_name = format!("__iris_registered_font_{}__", self.next_registered_family); - let blob = Blob::new(Arc::new(data)); - Self::validate_font(&family, blob.clone())?; - let bucket = self.glyph_bucket(bucket.as_ref()); - let registered = self.add_font(private_name, bucket, blob); - self.next_registered_family += 1; - self.font_buckets.insert(registered.font_id, bucket); - self.registered_families.insert(family, registered); - Ok(()) - } - - pub(crate) fn replace_font( - &mut self, - family: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), FontRegistrationError> { - let family = family.as_ref(); - let Some(bucket) = self.registered_families.get(family).map(|font| font.bucket) else { - return Err(FontRegistrationError::NotRegistered(family.to_owned())); - }; - self.replace_font_with(family, bucket, data) - } - - pub(crate) fn replace_font_in( - &mut self, - family: impl AsRef, - bucket: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), FontRegistrationError> { - let family = family.as_ref(); - if !self.registered_families.contains_key(family) { - return Err(FontRegistrationError::NotRegistered(family.to_owned())); - } - let bucket = self.glyph_bucket(bucket.as_ref()); - self.replace_font_with(family, bucket, data) - } - - fn replace_font_with( - &mut self, - family: &str, - bucket: u64, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), FontRegistrationError> { - let blob = Blob::new(Arc::new(data)); - Self::validate_font(family, blob.clone())?; - let old = self.registered_families.remove(family).unwrap(); - for face in old.faces { - self.font_cx.collection.unregister_font( - face.family, - face.width, - face.style, - face.weight, - ); - } - self.font_buckets.remove(&old.font_id); - self.atlas.clear_bucket(old.bucket); - - let registered = self.add_font(old.private_name, bucket, blob); - self.font_buckets.insert(registered.font_id, bucket); - self.registered_families - .insert(family.to_owned(), registered); - Ok(()) - } - - fn validate_font(family: &str, blob: Blob) -> Result<(), FontRegistrationError> { - let mut collection = Collection::new(CollectionOptions { - shared: false, - system_fonts: false, - }); - let fonts = collection.register_fonts(blob, None); - if fonts.is_empty() { - Err(FontRegistrationError::InvalidFont(family.to_owned())) - } else { - Ok(()) - } - } - - fn add_font(&mut self, private_name: String, bucket: u64, blob: Blob) -> RegisteredFont { - let font_id = blob.id(); - let fonts = self.font_cx.collection.register_fonts( - blob, - Some(FontInfoOverride { - family_name: Some(&private_name), - ..Default::default() - }), - ); - debug_assert!(!fonts.is_empty(), "validated font failed its second scan"); - let faces = fonts - .into_iter() - .flat_map(|(family, fonts)| { - fonts.into_iter().map(move |font| RegisteredFace { - family, - width: font.width(), - style: font.style(), - weight: font.weight(), - }) - }) - .collect(); - RegisteredFont { - private_name, - bucket, - font_id, - faces, - } - } - - fn glyph_bucket(&mut self, name: &str) -> u64 { - if name == DEFAULT_GLYPH_BUCKET { - return crate::render::DEFAULT_GLYPH_BUCKET_ID; - } - if let Some(bucket) = self.glyph_buckets.get(name) { - return *bucket; - } - let bucket = self.next_glyph_bucket; - self.next_glyph_bucket += 1; - self.glyph_buckets.insert(name.to_owned(), bucket); - bucket - } - - fn bucket_for_font(&self, font: u64) -> u64 { - self.font_buckets - .get(&font) - .copied() - .unwrap_or(crate::render::DEFAULT_GLYPH_BUCKET_ID) - } - - pub(crate) fn is_font_registered(&self, family: impl AsRef) -> bool { - self.registered_families.contains_key(family.as_ref()) - } - - /// Cloned rather than borrowed because the caller needs it while the - /// layout builder holds `&mut self` -- a `String` per shaped registered - /// run, paid only when the layout is rebuilt. - pub fn resolve_family(&self, family: &str) -> String { - if let Some(font) = self.registered_families.get(family) { - return font.private_name.clone(); - } - family.to_owned() - } - - 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)); - - 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, - } - } - - pub fn place( - &mut self, - buffer: &TextBuffer, - textures: &mut Textures, - ) -> (Vec, Vec) { - let mut placed = Vec::new(); - let mut paints = 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.clone(); - if !paints.contains(&run_color) { - paints.push(run_color.clone()); - } - let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize) - else { - continue; - }; - let coords_hash = hash_coords(coords); - let font_id = font.data.id(); - let bucket = self.bucket_for_font(font_id); - - for glyph in run.positioned_glyphs() { - let (x, subpixel) = horizontal_phase(glyph.x); - 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(bucket, &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(bucket, key, &image, textures), - None => { - self.atlas.insert_empty(bucket, key); - None - } - } - } - }; - let Some(entry) = entry else { continue }; - placed.push(PlacedGlyph { - entry, - offset: Vec2::new( - x + entry.left as f32, - glyph.y.floor() - entry.top as f32, - ), - paint: run_color.slot(), - }); - } - } - } - (placed, paints) - } - - pub fn render( - &mut self, - buffer: &mut TextBuffer, - attrs: &TextAttrs, - width: Option, - textures: &mut Textures, - density: f32, - ) -> RenderedText { - buffer.shape(self, attrs, width, density); - let (glyphs, paints) = self.place(buffer, textures); - RenderedText { - glyphs: std::sync::Arc::new(glyphs), - paints: std::sync::Arc::new(paints), - size: buffer.size(), - color: attrs.color.clone(), - generation: self.atlas.generation(), - } - } -} - -fn horizontal_phase(x: f32) -> (f32, u8) { - let quarters = (x * 4.0).round() as i32; - (quarters.div_euclid(4) as f32, quarters.rem_euclid(4) as u8) -} - -pub const SANS_SERIF: &str = "sans-serif"; -pub const SERIF: &str = "serif"; -pub const MONOSPACE: &str = "monospace"; - -#[derive(Clone, PartialEq)] -pub struct SpanStyle { - pub range: Range, - pub color: Option, - pub family: Option, - 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: PaintId) -> Self { - self.color = Some(color); - self - } - pub fn family(mut self, family: impl AsRef) -> Self { - self.family = Some(family.as_ref().to_owned()); - 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: PaintId, - pub font_size: f32, - pub line_height: f32, - pub family: String, - pub overflow: TextOverflow, - pub overflow_position: Len, - pub align: RegionAlign, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum TextOverflow { - #[default] - Visible, - Wrap, - Hidden, - Ellipsis, -} - -#[derive(Clone, PartialEq)] -struct TextShapeAttrs { - color: PaintId, - font_size: f32, - line_height: f32, - family: String, -} - -impl From<&TextAttrs> for TextShapeAttrs { - fn from(attrs: &TextAttrs) -> Self { - Self { - color: attrs.color.clone(), - font_size: attrs.font_size, - line_height: attrs.line_height, - family: attrs.family.clone(), - } - } -} - -impl TextShapeAttrs { - fn matches(&self, attrs: &TextAttrs) -> bool { - self.color == attrs.color - && self.font_size == attrs.font_size - && self.line_height == attrs.line_height - && self.family == attrs.family - } -} - -pub const LINE_HEIGHT_MULT: f32 = 1.1; - -impl Default for TextAttrs { - fn default() -> Self { - let size = 16.0; - Self { - color: PaintId::WHITE, - font_size: size, - line_height: size * LINE_HEIGHT_MULT, - family: SANS_SERIF.to_owned(), - overflow: TextOverflow::Visible, - overflow_position: Len::ZERO, - align: Align::CENTER_LEFT, - } - } -} - -/// 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, - shaped: Option<(TextShapeAttrs, Option, f32)>, -} - -impl TextBuffer { - pub fn new(text: impl Into) -> Self { - Self { - text: text.into(), - layout: Layout::new(), - spans: Vec::new(), - shaped: None, - } - } - - 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()) - } - - pub fn shape( - &mut self, - data: &mut TextData, - attrs: &TextAttrs, - width: Option, - density: f32, - ) { - if self - .shaped - .as_ref() - .is_some_and(|(old, old_width, old_density)| { - old.matches(attrs) && *old_width == width && *old_density == density - }) - { - return; - } - let shape_attrs = TextShapeAttrs::from(attrs); - 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(FontFamily::from( - base_family.as_str(), - ))); - 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.clone())); - 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.clone()), range.clone()); - } - if let Some(family) = family { - builder.push( - StyleProperty::FontFamily(FontFamily::from(family.as_str())), - 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((shape_attrs, width, density)); - } - - fn invalidate(&mut self) { - // A layout owns the font blobs it was shaped with. Drop it now so - // replacing a registered font does not retain the old bytes until an - // off-screen text resource happens to be shaped again. - self.layout = Layout::new(); - self.shaped = None; - } -} - -fn hash_coords(coords: &[i16]) -> u64 { - 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 -} - -/// 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>, - /// The unique handles whose compact slots the glyphs above carry. - pub paints: std::sync::Arc>, - pub size: Vec2, - pub color: PaintId, - /// 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, -} - -pub struct TextRsc { - buffer: TextBuffer, - ellipsis_buffer: TextBuffer, - attrs: TextAttrs, - rendered: Option, - ellipsis_rendered: Option<(TextShapeAttrs, f32, RenderedText)>, - width: Option, - density: f32, - owner: Option, -} - -impl TextRsc { - fn new(buffer: TextBuffer, attrs: TextAttrs) -> Self { - Self { - buffer, - ellipsis_buffer: TextBuffer::new("…"), - attrs, - rendered: None, - ellipsis_rendered: None, - width: None, - density: 0.0, - owner: None, - } - } - - fn invalidate(&mut self) { - self.buffer.invalidate(); - self.rendered = None; - self.ellipsis_buffer.invalidate(); - self.ellipsis_rendered = None; - } -} - -/// All text storage and shaping state for one [`crate::Ui`]. Widgets retain a -/// compact [`TextHandle`] into this arena rather than owning Parley layouts. -pub struct TextResources { - data: TextData, - entries: Rc>>, -} - -impl TextResources { - pub fn new() -> Self { - Self { - data: TextData::default(), - entries: Rc::new(RefCell::new(Resources::new())), - } - } - - pub fn add(resources: Rc>, buffer: TextBuffer, attrs: TextAttrs) -> TextHandle { - let entries = { - let mut resources = resources.borrow_mut(); - resources.free_released(); - resources.entries.clone() - }; - TextHandle { - rsc: RscHandle::add(entries, TextRsc::new(buffer, attrs)), - resources, - } - } - - pub fn handle(resources: Rc>, id: StrongRscId) -> TextHandle { - let entries = resources.borrow().entries.clone(); - TextHandle { - rsc: RscHandle::new(id, entries), - resources, - } - } - - pub fn upgrade(resources: Rc>, id: WeakRscId) -> Option { - let entries = { - let mut resources = resources.borrow_mut(); - resources.free_released(); - resources.entries.clone() - }; - let id = entries.borrow_mut().upgrade(id)?; - Some(Self::handle(resources, id)) - } - - pub fn free_released(&mut self) { - self.entries.borrow_mut().apply(|_, _| {}); - } - - pub fn invalidate_all(&mut self) -> Vec { - let mut owners = Vec::new(); - for resource in self.entries.borrow_mut().values_mut() { - resource.invalidate(); - if let Some(owner) = resource.owner - && !owners.contains(&owner) - { - owners.push(owner); - } - } - owners - } - - fn shape(&mut self, resource: &mut TextRsc) { - let density = self.data.density; - resource - .buffer - .shape(&mut self.data, &resource.attrs, resource.width, density); - } - - fn render( - &mut self, - resource: &mut TextRsc, - width: Option, - owner: WidgetId, - textures: &mut Textures, - density: f32, - ) -> (RenderedText, bool) { - let atlas_generation = self.data.atlas.generation(); - resource.owner = Some(owner); - if resource.width == width - && resource.density == density - && let Some(rendered) = &resource.rendered - && rendered.generation == atlas_generation - { - return (rendered.clone(), false); - } - resource.width = width; - resource.density = density; - let rendered = self.data.render( - &mut resource.buffer, - &resource.attrs, - width, - textures, - density, - ); - resource.rendered = Some(rendered.clone()); - (rendered, true) - } - - fn render_ellipsis( - &mut self, - resource: &mut TextRsc, - owner: WidgetId, - textures: &mut Textures, - density: f32, - ) -> (RenderedText, bool) { - let shape_attrs = TextShapeAttrs::from(&resource.attrs); - let atlas_generation = self.data.atlas.generation(); - resource.owner = Some(owner); - if let Some((cached_attrs, cached_density, rendered)) = &resource.ellipsis_rendered - && cached_attrs == &shape_attrs - && *cached_density == density - && rendered.generation == atlas_generation - { - return (rendered.clone(), false); - } - let rendered = self.data.render( - &mut resource.ellipsis_buffer, - &resource.attrs, - None, - textures, - density, - ); - resource.ellipsis_rendered = Some((shape_attrs, density, rendered.clone())); - (rendered, true) - } -} - -impl Default for TextResources { - fn default() -> Self { - Self::new() - } -} - -impl Deref for TextResources { - type Target = TextData; - - fn deref(&self) -> &Self::Target { - &self.data - } -} - -impl DerefMut for TextResources { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.data - } -} - -/// A widget-owned reference to one entry in [`TextResources`]. The arena is -/// shared once per UI; constructing a handle does not allocate a resource of -/// its own. -pub struct TextHandle { - rsc: RscHandle, - resources: Rc>, -} - -impl TextHandle { - pub fn strong(&self) -> StrongRscId { - self.rsc.strong() - } - - pub fn weak(&self) -> WeakRscId { - self.rsc.weak() - } - - pub fn text(&self) -> Ref<'_, str> { - Ref::map(self.rsc.get(), |resource| resource.buffer.text()) - } - - pub fn attrs(&self) -> Ref<'_, TextAttrs> { - Ref::map(self.rsc.get(), |resource| &resource.attrs) - } - - pub fn set_text(&mut self, text: impl Into) -> bool { - let text = text.into(); - let mut resource = self.rsc.get_mut(); - if resource.buffer.text() == text { - return false; - } - resource.buffer.set_text(text); - resource.rendered = None; - true - } - - pub fn edit_text(&mut self, edit: impl FnOnce(&mut String) -> R) -> R { - let mut resource = self.rsc.get_mut(); - resource.rendered = None; - edit(resource.buffer.edit()) - } - - pub fn set_spans(&mut self, spans: Vec) { - let mut resource = self.rsc.get_mut(); - resource.buffer.set_spans(spans); - resource.rendered = None; - } - - pub fn attrs_mut(&mut self) -> RefMut<'_, TextAttrs> { - let mut resource = self.rsc.get_mut(); - resource.invalidate(); - RefMut::map(resource, |resource| &mut resource.attrs) - } - - pub fn set_overflow_position(&mut self, position: Len) { - self.rsc.get_mut().attrs.overflow_position = position; - } - - pub fn set_overflow(&mut self, overflow: TextOverflow) { - self.rsc.get_mut().attrs.overflow = overflow; - } - - pub fn rendered(&self) -> Option { - self.rsc.get().rendered.clone() - } - - pub fn width(&self) -> Option { - self.rsc.get().width - } - - pub fn with_layout(&self, f: impl FnOnce(&Layout, &str) -> R) -> R { - let mut resources = self.resources.borrow_mut(); - let mut resource = self.rsc.get_mut_shared(); - resources.shape(&mut resource); - f(resource.buffer.layout(), resource.buffer.text()) - } - - pub fn layout(&self) -> Ref<'_, Layout> { - { - let mut resources = self.resources.borrow_mut(); - let mut resource = self.rsc.get_mut_shared(); - resources.shape(&mut resource); - } - Ref::map(self.rsc.get(), |resource| resource.buffer.layout()) - } - - pub fn render( - &self, - width: Option, - owner: WidgetId, - textures: &mut Textures, - density: f32, - ) -> (RenderedText, bool) { - let mut resources = self.resources.borrow_mut(); - let mut resource = self.rsc.get_mut_shared(); - resources.render(&mut resource, width, owner, textures, density) - } - - pub fn render_ellipsis( - &self, - owner: WidgetId, - textures: &mut Textures, - density: f32, - ) -> (RenderedText, bool) { - let mut resources = self.resources.borrow_mut(); - let mut resource = self.rsc.get_mut_shared(); - resources.render_ellipsis(&mut resource, owner, textures, density) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_horizontal_phase_carries_across_pixel_boundaries() { - assert_eq!(horizontal_phase(10.20), (10.0, 1)); - assert_eq!(horizontal_phase(10.90), (11.0, 0)); - assert_eq!(horizontal_phase(-0.20), (-1.0, 3)); - } - - #[test] - fn invalid_font_data_is_reported() { - let mut data = TextData::default(); - assert_eq!( - data.register_font("icons", b"not a font" as &'static [u8]), - Err(FontRegistrationError::InvalidFont("icons".to_owned())) - ); - } - - #[test] - fn dropped_handles_release_their_arena_entries() { - let resources = Rc::new(RefCell::new(TextResources::new())); - let text = TextResources::add( - resources.clone(), - TextBuffer::new("temporary"), - TextAttrs::default(), - ); - assert_eq!(resources.borrow().entries.borrow().len(), 1); - - drop(text); - resources.borrow_mut().free_released(); - - assert!(resources.borrow().entries.borrow().is_empty()); - } -} diff --git a/iris/core/src/primitive/texture.rs b/iris/core/src/primitive/texture.rs deleted file mode 100644 index 461c33f..0000000 --- a/iris/core/src/primitive/texture.rs +++ /dev/null @@ -1,377 +0,0 @@ -use crate::util::{Resources, RscHandle, StrongRscId, Vec2, WeakRscId}; -use image::{DynamicImage, GenericImageView}; -use std::{cell::RefCell, collections::HashMap, ops::Index, rc::Rc}; - -/// 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, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SharedTextureKey { - pub owner: &'static str, - pub id: u64, -} - -pub struct TextureRsc { - kind: TextureKind, - size: Vec2, -} - -#[derive(Debug, Clone)] -pub struct TextureHandle { - rsc: RscHandle, -} - -impl PartialEq for TextureHandle { - fn eq(&self, other: &Self) -> bool { - self.rsc.id() == other.rsc.id() - } -} - -impl Eq for TextureHandle {} - -/// a texture manager for a ui -/// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped -pub struct Textures { - resources: Rc>>, - images: Vec>, - 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. Page layers and resource slots - /// are separate identities: released page layers are reused without moving - /// any still-live page. - next_page_layer: u32, - free_page_layers: Vec, - updates: Vec, -} - -pub enum TextureUpdate<'a> { - 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(TextureKind), - SetFree, -} - -#[derive(Debug, Clone, Copy)] -pub struct PatchRect { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -enum Update { - Push(TextureKind, u32), - Set(TextureKind, u32), - Patch(u32, PatchRect), - Free(u32), -} - -impl Textures { - pub fn new() -> Self { - Self { - resources: Rc::new(RefCell::new(Resources::new())), - images: Vec::new(), - kinds: Vec::new(), - shared: HashMap::new(), - next_page_layer: 0, - free_page_layers: Vec::new(), - updates: Vec::new(), - } - } - - pub fn add(&mut self, image: impl Into) -> TextureHandle { - let image = image.into(); - let size = image.dimensions().into(); - let kind = TextureKind::Image; - self.push(kind, size, image) - } - - pub fn add_page(&mut self, image: impl Into) -> TextureHandle { - self.free(); - let image = image.into(); - let size = image.dimensions().into(); - let layer = self.free_page_layers.pop().unwrap_or_else(|| { - let layer = self.next_page_layer; - self.next_page_layer += 1; - layer - }); - let kind = TextureKind::Page { layer }; - self.push(kind, size, image) - } - - pub fn handle(&self, id: StrongRscId) -> TextureHandle { - TextureHandle { - rsc: RscHandle::new(id, self.resources.clone()), - } - } - - pub fn upgrade(&mut self, id: WeakRscId) -> Option { - self.free(); - let id = self.resources.borrow_mut().upgrade(id)?; - Some(self.handle(id)) - } - - fn push(&mut self, kind: TextureKind, size: Vec2, image: DynamicImage) -> TextureHandle { - self.free(); - let old_capacity = self.resources.borrow().capacity(); - let id = self.resources.borrow_mut().add(TextureRsc { kind, size }); - let i = id.slot(); - if (i as usize) < old_capacity { - self.images[i as usize] = Some(image); - self.kinds[i as usize] = kind; - self.updates.push(Update::Set(kind, i)); - } else { - self.images.push(Some(image)); - self.kinds.push(kind); - self.updates.push(Update::Push(kind, i)); - } - TextureHandle { - rsc: RscHandle::new(id, self.resources.clone()), - } - } - - /// 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 - } - - pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage { - self.images[handle.rsc.id().slot() as usize] - .as_mut() - .expect("texture was freed while still held") - } - - pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) { - self.updates - .push(Update::Patch(handle.rsc.id().slot(), rect)); - } - - /// 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. - pub fn reupload(&mut self) { - self.updates.clear(); - self.updates.extend( - (0..self.resources.borrow().capacity() as u32) - .map(|i| Update::Push(self.kinds[i as usize], i)), - ); - } - - pub fn free(&mut self) { - let updates = &mut self.updates; - let images = &mut self.images; - let free_page_layers = &mut self.free_page_layers; - self.resources.borrow_mut().apply(|id, resource| { - let idx = id.slot(); - images[idx as usize] = None; - updates.push(Update::Free(idx)); - if let TextureKind::Page { layer } = resource.kind { - free_page_layers.push(layer); - } - }); - } - - pub fn updates(&mut self) -> impl Iterator> { - self.updates.drain(..).map(|u| match u { - Update::Push(kind, i) => self.images[i as usize] - .as_ref() - .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(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), - }) - } -} - -impl TextureHandle { - pub fn size(&self) -> Vec2 { - self.rsc.get().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.rsc.get().kind { - TextureKind::Image => self.rsc.id().slot(), - TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"), - } - } - - pub fn layer(&self) -> u32 { - match self.rsc.get().kind { - TextureKind::Page { layer } => layer, - TextureKind::Image => panic!("layer() called on a standalone image handle"), - } - } - - pub fn strong(&self) -> StrongRscId { - self.rsc.strong() - } - - pub fn weak(&self) -> WeakRscId { - self.rsc.weak() - } -} - -impl Index<&TextureHandle> for Textures { - type Output = DynamicImage; - - fn index(&self, index: &TextureHandle) -> &Self::Output { - self.images[index.rsc.id().slot() as usize] - .as_ref() - .unwrap() - } -} - -impl Default for Textures { - fn default() -> Self { - 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 } - } - - #[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()); - } - - #[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" - ); - } - - #[test] - fn a_released_atlas_page_layer_is_reused_without_moving_live_pages() { - let mut textures = Textures::new(); - let first = textures.add_page(image(4)); - let second = textures.add_page(image(4)); - let first_layer = first.layer(); - let second_layer = second.layer(); - drop(first); - textures.free(); - - let replacement = textures.add_page(image(4)); - - assert_eq!(replacement.layer(), first_layer); - assert_eq!(second.layer(), second_layer); - } - - #[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(); - 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/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs deleted file mode 100644 index 1368425..0000000 --- a/iris/core/src/render/atlas.rs +++ /dev/null @@ -1,227 +0,0 @@ -use crate::{ - PatchRect, TextureHandle, Textures, - util::{HashMap, Vec2}, -}; -use image::RgbaImage; -use swash::scale::image::{Content, Image}; - -pub(crate) const PAGE: u32 = 1024; -pub(crate) const DEFAULT_GLYPH_BUCKET_ID: u64 = 0; - -const PAD: u32 = 1; - -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct GlyphKey { - pub font: u64, - pub glyph: u32, - pub size: u32, - pub subpixel: u8, - pub coords: u64, -} - -#[derive(Clone, Copy)] -pub struct GlyphEntry { - pub uv_min: [f32; 2], - pub uv_max: [f32; 2], - pub left: i32, - pub top: i32, - pub width: u32, - pub height: u32, - pub is_color: bool, - 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)] -struct Bucket { - pages: Vec, - entries: HashMap>, -} - -#[derive(Default)] -pub struct GlyphAtlas { - buckets: HashMap, - generation: u64, -} - -impl GlyphAtlas { - pub(crate) fn get(&self, bucket: u64, key: &GlyphKey) -> Option> { - self.buckets.get(&bucket)?.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, - bucket: u64, - key: GlyphKey, - image: &Image, - textures: &mut Textures, - ) -> Option { - let bucket = self.buckets.entry(bucket).or_default(); - let w = image.placement.width; - let h = image.placement.height; - if w == 0 || h == 0 { - bucket.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. - bucket.entries.insert(key, None); - return None; - } - - let (page_idx, x, y) = bucket.allocate(w, h, textures); - let page = &bucket.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 rect = PatchRect { - x, - y, - width: w, - height: h, - }; - textures.patch(&page.handle, rect); - - let page = &bucket.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(), - }; - bucket.entries.insert(key, Some(entry)); - Some(entry) - } - - pub(crate) fn insert_empty(&mut self, bucket: u64, key: GlyphKey) { - self.buckets - .entry(bucket) - .or_default() - .entries - .insert(key, None); - } - - pub(crate) fn clear_bucket(&mut self, bucket: u64) { - if self.buckets.remove(&bucket).is_some() { - self.generation += 1; - } - } - - pub fn generation(&self) -> u64 { - self.generation - } - - pub fn page_count(&self) -> usize { - self.buckets.values().map(|bucket| bucket.pages.len()).sum() - } - - pub fn glyph_count(&self) -> usize { - self.buckets - .values() - .map(|bucket| bucket.entries.len()) - .sum() - } - - pub fn clear(&mut self) { - self.buckets.clear(); - self.generation += 1; - } -} - -impl Bucket { - 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) - } -} - -fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { - (page.x + need_w <= PAGE && page.y + need_h <= PAGE) - || (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE) -} - -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 => { - 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])); - } - } - } - } -} - -#[derive(Clone, Copy)] -pub struct PlacedGlyph { - pub entry: GlyphEntry, - pub offset: Vec2, - pub paint: u32, -} diff --git a/iris/core/src/render/data.rs b/iris/core/src/render/data.rs deleted file mode 100644 index e4eb759..0000000 --- a/iris/core/src/render/data.rs +++ /dev/null @@ -1,78 +0,0 @@ -use crate::{UiRegion, util::Id}; -use wgpu::*; - -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable, Default)] -pub struct WindowUniform { - pub width: f32, - pub height: f32, -} - -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct PrimitiveInstance { - pub region: UiRegion, - pub binding: u32, - pub idx: u32, - pub mask_idx: MaskIdx, - pub move_idx: MoveIdx, -} - -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, - } -} - -pub type MaskIdx = Id; - -impl MaskIdx { - pub const NONE: Self = Self::preset(u32::MAX); -} - -pub type MoveIdx = Id; - -#[repr(C)] -#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct Mask { - 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. - pub parent: MaskIdx, -} - -/// `_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/iris/core/src/render/frame_report.rs b/iris/core/src/render/frame_report.rs deleted file mode 100644 index 84ed719..0000000 --- a/iris/core/src/render/frame_report.rs +++ /dev/null @@ -1,776 +0,0 @@ -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); - -const RING_CAPACITY: usize = 16384; - -const MIN_CADENCE_SAMPLES: usize = 12; - -struct PhaseMark { - name: String, - start_index: u64, - start_at: Instant, -} - -pub struct PhaseStats { - pub name: String, - pub frames: u64, - pub duration: Duration, - /// On a backend that blocks in `present()` rather than in the - /// acquire -- GLES, and so this repo's emulator -- the wait lands in - /// `submit` instead and this over-counts. Named rather than - /// corrected, since correcting it would mean guessing which part of - /// `submit` was a wait. - pub late: u64, - pub late_percent: f64, - pub p50: Duration, - pub p90: Duration, - pub p99: Duration, - pub worst: Duration, - /// This phase's own medians of the three parts a frame is made of -- - /// see [`FrameParts`]. Per phase as well as per run because the parts - /// do not divide the same way in every phase: a fling frame spends - /// most of itself in `acquire` (waiting its turn at the swapchain, - /// which is the display pacing the app and not work) while a - /// streaming frame spends it in `build`, and a run-wide median cannot - /// say that. - /// Vsyncs that went by with no frame produced for them, counted from - /// the gap between consecutive frames rather than from their cost. - pub missed: u64, - pub build_p50: Duration, - pub acquire_p50: Duration, - pub submit_p50: Duration, - 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}%) missed vsyncs: {}", - self.late, self.late_percent, self.missed, - )?; - 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, - )?; - writeln!( - f, - " build p50 {:.1}ms acquire p50 {:.1}ms submit p50 {:.1}ms", - self.build_p50.as_secs_f64() * 1000.0, - self.acquire_p50.as_secs_f64() * 1000.0, - self.submit_p50.as_secs_f64() * 1000.0, - )?; - write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0) - } -} - -/// The parts one frame's wall time divides into, measured rather than -/// inferred: what a caller hands [`FrameReport::record`]. -#[derive(Clone, Copy, Default, Debug)] -pub struct FrameParts { - pub total: Duration, - pub acquire: Duration, - pub submit: Duration, -} - -impl FrameParts { - /// A frame measured as one span, with no parts -- honest for a caller - /// that never measured them (they read as zero and `build` reads as - /// the whole frame) rather than fabricating a split. - pub fn whole(total: Duration) -> Self { - Self { - total, - acquire: Duration::ZERO, - submit: Duration::ZERO, - } - } - - /// The two waits a renderer's `draw` measures, with `total` left at - /// zero for the frame loop around it to fill in -- it is the only - /// caller that knows when the frame started. - pub fn waits(acquire: Duration, submit: Duration) -> Self { - Self { - total: Duration::ZERO, - acquire, - submit, - } - } - - /// What is left once the two measured waits are taken off: laying - /// out, shaping text, building primitives and recording the render - /// pass. Saturating, since the three come from different `Instant` - /// pairs on a clock a caller owns. - pub fn build(&self) -> Duration { - self.total - .saturating_sub(self.acquire) - .saturating_sub(self.submit) - } - - pub fn work(&self) -> Duration { - self.total.saturating_sub(self.acquire) - } -} - -/// **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. -pub struct FrameReport { - ring: Box<[Duration; RING_CAPACITY]>, - submit_ring: Box<[Duration; RING_CAPACITY]>, - /// The `acquire` half of each sample in `ring`, same index, same - /// lifetime -- see [`FrameParts::acquire`], which is the part that is - /// a wait rather than work. - acquire_ring: Box<[Duration; RING_CAPACITY]>, - /// How long before each sample the *previous* frame was, same index, - /// same lifetime -- the frame's own cadence rather than its cost. See - /// [`PhaseStats::missed`] for why a report needs both. - gap_ring: Box<[Duration; RING_CAPACITY]>, - last_frame: Option<(Instant, bool)>, - index_ring: Box<[u64; RING_CAPACITY]>, - len: usize, - pos: usize, - total_frames: u64, - janky_frames: u64, - phases: Vec, -} - -pub struct FrameStats { - pub total_frames: u64, - pub janky_percent: f64, - pub p50: Duration, - pub p90: Duration, - pub p99: Duration, - pub worst: Duration, - pub cpu_p50: Duration, - pub acquire_p50: Duration, - 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 acquire_p50={:.1}ms gpu_wait_p50={:.1}ms (own work vs. \ - waiting for a swapchain image vs. submit-to-after-present)", - self.cpu_p50.as_secs_f64() * 1000.0, - self.acquire_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]), - acquire_ring: Box::new([Duration::ZERO; RING_CAPACITY]), - gap_ring: Box::new([Duration::ZERO; RING_CAPACITY]), - last_frame: None, - index_ring: Box::new([0; RING_CAPACITY]), - len: 0, - pos: 0, - total_frames: 0, - janky_frames: 0, - phases: Vec::new(), - } - } - - /// One entry point rather than one per shape of measurement: a caller - /// with nothing but a total passes `FrameParts::whole(total)`, which - /// says so in the type instead of leaving the report to guess from a - /// zero. - pub fn record(&mut self, at: Instant, parts: FrameParts, animating: bool) { - self.gap_ring[self.pos] = match self.last_frame { - Some((last, true)) => at.saturating_duration_since(last), - Some((_, false)) | None => Duration::ZERO, - }; - self.last_frame = Some((at, animating)); - self.ring[self.pos] = parts.total; - self.submit_ring[self.pos] = parts.submit; - self.acquire_ring[self.pos] = parts.acquire; - 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 parts.total > JANK_THRESHOLD { - self.janky_frames += 1; - } - } - - pub fn reset(&mut self) { - self.len = 0; - self.pos = 0; - self.total_frames = 0; - self.janky_frames = 0; - self.last_frame = None; - self.phases.clear(); - } - - fn parts(&self, slot: usize) -> FrameParts { - FrameParts { - total: self.ring[slot], - acquire: self.acquire_ring[slot], - submit: self.submit_ring[slot], - } - } - - pub fn mark_phase(&mut self, name: &str) { - 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(), - }); - } - - 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 slots: Vec = (0..self.len) - .filter(|&j| { - let idx = self.index_ring[j]; - idx >= phase.start_index && idx < end_index - }) - .collect(); - let mut samples: Vec = slots.iter().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, - missed: 0, - build_p50: Duration::ZERO, - acquire_p50: Duration::ZERO, - submit_p50: Duration::ZERO, - complete, - }; - } - let part_p50 = |part: &dyn Fn(usize) -> Duration| { - let mut v: Vec = slots.iter().map(|&j| part(j)).collect(); - v.sort_unstable(); - v[v.len() / 2] - }; - // **The phase's own first frame is skipped**: its gap - // reaches back into the previous phase, across whatever - // the run did between the two -- a bench pausing a second - // between phases would otherwise open each one with sixty - // "missed" frames nobody was waiting for. - let missed: u64 = slots - .iter() - .filter(|&&j| self.index_ring[j] > phase.start_index) - .map(|&j| self.gap_ring[j]) - .filter(|gap| !gap.is_zero()) - .filter(|gap| *gap > budget.mul_f64(1.5)) - .map(|gap| (gap.as_secs_f64() / budget.as_secs_f64()).round() as u64 - 1) - .sum(); - let build_p50 = part_p50(&|j| self.parts(j).build()); - let acquire_p50 = part_p50(&|j| self.acquire_ring[j]); - let submit_p50 = part_p50(&|j| self.submit_ring[j]); - samples.sort_unstable(); - let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)]; - let late = slots - .iter() - .filter(|&&j| self.parts(j).work() > 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"), - missed, - build_p50, - acquire_p50, - submit_p50, - complete, - } - }) - .collect() - } - - /// **This is a floor on the display's refresh rate, never a reading - /// of it.** You cannot observe a cadence faster than you draw, so an - /// app that never keeps up says nothing about the panel; a caller - /// resolves it by taking whichever of this and the platform's own - /// answer is *larger*. That matters in both directions and each has - /// been seen: `Display.getRefreshRate()` answered 60 for a run that - /// sustained 120.3fps, because a phone that varies its rate answers - /// with whatever mode it happens to be in when asked -- and this - /// answered 88 on an emulator whose display is 60Hz and whose app - /// managed 51, because an earlier version took the fastest tenth of - /// the gaps rather than the sustained rate. The fastest tenth is a - /// measurement of the best moment; the budget wants the rhythm. - /// - /// `None` under `MIN_CADENCE_SAMPLES` measurable gaps, which is the - /// honest answer for a run too short or too idle to have seen one. - pub fn sustained_frame_hz(&self) -> Option { - let gaps = self.gap_ring[..self.len].iter().filter(|g| !g.is_zero()); - let (count, total) = gaps.fold((0u32, Duration::ZERO), |(n, sum), g| (n + 1, sum + *g)); - if (count as usize) < MIN_CADENCE_SAMPLES || total.is_zero() { - return None; - } - Some(count as f32 / total.as_secs_f32()) - } - - /// `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)]; - - let submit_samples: Vec = self.submit_ring[..self.len].to_vec(); - let acquire_samples: Vec = self.acquire_ring[..self.len].to_vec(); - let cpu_samples: Vec = (0..self.len).map(|j| self.parts(j).build()).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), - acquire_p50: median(acquire_samples), - gpu_wait_p50: median(submit_samples), - }) - } - - 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 = (0..self.len) - .filter(|&j| self.parts(j).work() > 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( - Instant::now(), - FrameParts::whole(Duration::from_millis(10)), - true, - ); - 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(); - for ms in (1..=100).rev() { - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(ms)), - true, - ); - } - 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( - Instant::now(), - FrameParts::whole(Duration::from_nanos(16_666_667)), - true, - ); // exactly on budget: not janky - r.record( - Instant::now(), - FrameParts::whole(Duration::from_nanos(16_666_668)), - true, - ); // 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() { - let mut r = FrameReport::new(); - for _ in 0..10 { - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(50)), - true, - ); - } - assert_eq!(r.report().unwrap().janky_percent, 100.0); - r.reset(); - assert!(r.report().is_none()); - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(1)), - true, - ); - assert_eq!(r.report().unwrap().janky_percent, 0.0); - } - - #[test] - fn record_without_a_split_reports_the_whole_frame_as_cpu() { - let mut r = FrameReport::new(); - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(20)), - true, - ); - let stats = r.report().unwrap(); - assert_eq!(stats.cpu_p50, Duration::from_millis(20)); - assert_eq!(stats.gpu_wait_p50, Duration::ZERO); - } - - #[test] - fn each_part_reports_its_own_median_and_build_excludes_the_wait() { - let mut r = FrameReport::new(); - for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] { - r.record( - Instant::now(), - FrameParts { - total: Duration::from_millis(30), - acquire: Duration::from_millis(acquire), - submit: Duration::from_millis(submit), - }, - true, - ); - } - let stats = r.report().unwrap(); - assert_eq!(stats.p50, Duration::from_millis(30)); - assert_eq!(stats.acquire_p50, Duration::from_millis(10)); - assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3)); - assert_eq!(stats.cpu_p50, Duration::from_millis(17)); - } - - #[test] - fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let budget = Duration::from_nanos(16_666_667); - r.mark_phase("fling"); - for step in [0u32, 1, 2, 4, 5, 8] { - r.record( - base + budget * step, - FrameParts::whole(Duration::from_millis(2)), - true, - ); - } - let phase = r.phase_stats(Instant::now(), 60.0).remove(0); - assert_eq!(phase.late, 0, "no frame here was over its budget"); - assert_eq!(phase.missed, 3); - } - - #[test] - fn an_idle_gap_is_not_a_missed_frame() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let budget = Duration::from_nanos(16_666_667); - r.mark_phase("fling"); - r.record(base, FrameParts::whole(Duration::ZERO), true); - r.record(base + budget, FrameParts::whole(Duration::ZERO), true); - r.record(base + budget * 2, FrameParts::whole(Duration::ZERO), false); - r.record( - base + Duration::from_millis(300), - FrameParts::whole(Duration::ZERO), - true, - ); - let phase = r.phase_stats(Instant::now(), 60.0).remove(0); - assert_eq!( - phase.missed, 0, - "a rest nobody was waiting through is not a stutter" - ); - } - - #[test] - fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let period = Duration::from_nanos(8_333_333); - for step in 0..120u32 { - r.record( - base + period * step, - FrameParts::whole(Duration::ZERO), - true, - ); - } - let hz = r.sustained_frame_hz().expect("120 gaps is plenty"); - assert!((hz - 120.0).abs() < 1.0, "measured {hz}Hz, expected ~120"); - } - - #[test] - fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let mut at = base; - for step in 0..120u32 { - at += if step % 10 == 0 { - Duration::from_millis(8) - } else { - Duration::from_millis(20) - }; - r.record(at, FrameParts::whole(Duration::ZERO), true); - } - let hz = r.sustained_frame_hz().expect("120 gaps is plenty"); - assert!( - hz < 60.0, - "measured {hz}Hz, which claims more than was drawn" - ); - } - - #[test] - fn a_frame_held_back_by_the_display_is_not_late() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let period = Duration::from_nanos(8_333_333); - r.mark_phase("fling"); - for step in 0..30u32 { - r.record( - base + period * step, - FrameParts { - total: Duration::from_micros(8_300), - acquire: Duration::from_micros(7_900), - submit: Duration::from_micros(200), - }, - true, - ); - } - let phase = r.phase_stats(Instant::now(), 120.0).remove(0); - assert_eq!(phase.late, 0); - assert_eq!(r.late_at_hz(120.0).0, 0); - } - - #[test] - fn a_phase_does_not_inherit_the_pause_before_it() { - let mut r = FrameReport::new(); - let base = Instant::now(); - let budget = Duration::from_nanos(16_666_667); - r.mark_phase("fling"); - for step in [0u32, 1, 2] { - r.record( - base + budget * step, - FrameParts::whole(Duration::ZERO), - true, - ); - } - let after = base + Duration::from_secs(1); - r.mark_phase("type"); - for step in [0u32, 1, 2] { - r.record( - after + budget * step, - FrameParts::whole(Duration::ZERO), - true, - ); - } - let phases = r.phase_stats(Instant::now(), 60.0); - assert_eq!(phases[0].missed, 0); - assert_eq!( - phases[1].missed, 0, - "the rest between phases is not a stutter" - ); - } - - #[test] - fn ring_wraps_without_growing_past_capacity() { - let mut r = FrameReport::new(); - for i in 0..(RING_CAPACITY * 2) { - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(1 + (i % 5) as u64)), - true, - ); - } - let stats = r.report().unwrap(); - assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64); - assert!(stats.worst <= Duration::from_millis(5)); - } - - #[test] - fn no_marks_means_no_phases() { - let mut r = FrameReport::new(); - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(5)), - true, - ); - 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( - Instant::now(), - FrameParts::whole(Duration::from_millis(10)), - true, - ); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late - } - r.mark_phase("b"); - for _ in 0..3 { - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(20)), - true, - ); // 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( - Instant::now(), - FrameParts::whole(Duration::from_millis(1)), - true, - ); - 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( - Instant::now(), - FrameParts::whole(Duration::from_millis(1)), - true, - ); - 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(); - r.record( - Instant::now(), - FrameParts::whole(Duration::from_millis(10)), - true, - ); - 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/iris/core/src/render/mod.rs b/iris/core/src/render/mod.rs deleted file mode 100644 index b5ebe84..0000000 --- a/iris/core/src/render/mod.rs +++ /dev/null @@ -1,662 +0,0 @@ -use crate::{ - Ui, UiData, - render::{ - data::{PrimitiveInstance, instance_slot_layout}, - texture::GpuTextures, - util::ArrBuf, - }, - util::{HashMap, Vec2}, -}; -use data::WindowUniform; -use pollster::FutureExt; -use wgpu::{ - util::{BufferInitDescriptor, DeviceExt}, - *, -}; - -mod atlas; -mod data; -mod frame_report; -mod primitive; -mod sdf; -mod texture; -mod util; - -pub use atlas::*; -pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset}; -pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD}; -pub use primitive::*; -pub use sdf::{distance_from_rect, rounded_rect_coverage}; - -pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); - -/// The advertised swapchain format and the sRGB view Iris renders through. -/// A backend may advertise only the non-sRGB member of an RGBA/BGRA pair; -/// wgpu permits its sRGB counterpart as a configured view format. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SurfaceFormat { - pub surface: TextureFormat, - pub view: TextureFormat, -} - -pub fn srgb_surface_format(caps: &SurfaceCapabilities) -> Result { - let supports_srgb_space = |format| caps.color_spaces(format).contains(SurfaceColorSpaces::SRGB); - if let Some(surface) = caps - .formats - .iter() - .copied() - .find(|format| format.is_srgb() && supports_srgb_space(*format)) - { - return Ok(SurfaceFormat { - surface, - view: surface, - }); - } - if let Some(surface) = caps - .formats - .iter() - .copied() - .find(|format| format.add_srgb_suffix().is_srgb() && supports_srgb_space(*format)) - { - return Ok(SurfaceFormat { - surface, - view: surface.add_srgb_suffix(), - }); - } - Err(format!( - "the surface has no RGBA/BGRA format with an sRGB render view and sRGB output colour \ - space; advertised default formats: {:?}", - caps.formats - )) -} - -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() - } -} - -#[derive(Clone)] -pub struct WgpuErrorLog { - errors: std::sync::Arc>>, -} - -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 fn len(&self) -> usize { - self.errors.lock().unwrap().len() - } - - pub fn is_empty(&self) -> bool { - self.errors.lock().unwrap().is_empty() - } -} - -pub struct UiRenderNode { - uniform_group: BindGroup, - primitive_layout: BindGroupLayout, - primitives: PrimitiveBuffers, - primitive_group: BindGroup, - rsc_layout: BindGroupLayout, - rsc_group: BindGroup, - - pipeline: RenderPipeline, - - layers: HashMap, - active: Vec, - window_buffer: Buffer, - textures: GpuTextures, - instances: ArrBuf, - masks: ArrBuf, - move_offsets: ArrBuf, - paints: ArrBuf, - masks_layout: BindGroupLayout, - masks_group: BindGroup, -} - -struct RenderLayer { - 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(1, &self.primitive_group, &[]); - pass.set_bind_group(3, &self.masks_group, &[]); - for i in &self.active { - let layer = &self.layers[i]; - if layer.order.len() == 0 && layer.images.len() == 0 { - continue; - } - 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); - } - } - } - } - - pub fn update(&mut self, device: &Device, queue: &Queue, ui: &mut Ui) -> FrameUpdateStats { - let render_handle = ui.render_state.clone(); - let mut render_guard = render_handle.get_mut(); - let ui_render = &mut *render_guard; - let ui_data: &mut UiData = ui; - self.active.clear(); - for (i, order) in ui_render.layers.iter_mut() { - self.active.push(i); - let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer { - order: ArrBuf::new( - device, - 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 { - let (entries, dirty) = order.order_for_upload(); - rlayer.order.update(device, queue, entries, dirty); - let (entries, dirty) = order.images_for_upload(); - rlayer.images.update(device, queue, entries, dirty); - rlayer.image_tex_indices.clear(); - rlayer.image_tex_indices.extend( - order - .images() - .iter() - .map(|&slot| ui_render.primitives.instance(slot).idx), - ); - order.updated = false; - } - } - let instances_resized = if ui_render.primitives.needs_upload() { - let (entries, dirty) = ui_render.primitives.instances_for_upload(); - let resized = self.instances.update(device, queue, entries, dirty); - if self - .primitives - .update(device, queue, ui_render.primitives.data_mut()) - { - self.primitive_group = Self::primitive_group( - device, - &self.primitive_layout, - self.primitives.buffers(), - ); - } - resized - } else { - false - }; - let (entries, dirty) = ui_data.masks.for_upload(); - let masks_resized = self.masks.update(device, queue, entries, dirty); - let (entries, dirty) = ui_data.move_offsets.for_upload(); - let moves_resized = self.move_offsets.update(device, queue, entries, dirty); - let (entries, dirty) = ui_data.paints.for_upload(); - let paints_resized = self.paints.update(device, queue, entries, dirty); - if masks_resized || moves_resized || instances_resized || paints_resized { - self.masks_group = Self::masks_group( - device, - &self.masks_layout, - &self.masks, - &self.move_offsets, - &self.instances, - &self.paints, - ); - } - let rebuild_main = self - .textures - .update(&mut ui_data.textures, &self.rsc_layout); - if rebuild_main { - self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures); - } - FrameUpdateStats { - masks_resized, - moves_resized, - paints_resized, - } - } - - /// 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.x, - height: size.y, - }]; - queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); - } - - pub fn new( - device: &Device, - queue: &Queue, - target_format: TextureFormat, - window_size: impl Into, - ) -> Result { - 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 = { - 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]), - usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, - }); - - let uniform_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { - entries: &[BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }], - label: Some("window"), - }); - - let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer); - - let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor { - 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 paints = ArrBuf::new( - device, - BufferUsages::STORAGE | BufferUsages::COPY_DST, - "ui paints", - ); - - 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, - &paints, - ); - - let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor { - label: Some("UI Shape Pipeline 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 { - label: Some("UI Shape Pipeline"), - layout: Some(&pipeline_layout), - vertex: VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[Some(instance_slot_layout())], - compilation_options: Default::default(), - }, - fragment: Some(FragmentState { - module: &shader, - entry_point: Some("fs_main"), - targets: &[Some(ColorTargetState { - format: target_format, - blend: Some(BlendState::ALPHA_BLENDING), - write_mask: ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - primitive: PrimitiveState { - topology: PrimitiveTopology::TriangleStrip, - strip_index_format: None, - front_face: FrontFace::Cw, - cull_mode: Some(Face::Back), - polygon_mode: PolygonMode::Fill, - unclipped_depth: false, - conservative: false, - }, - depth_stencil: None, - multisample: MultisampleState { - count: 1, - mask: !0, - alpha_to_coverage_enabled: false, - }, - multiview_mask: None, - cache: None, - }); - - // 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, - window_buffer, - layers: HashMap::default(), - active: Vec::new(), - textures: tex_manager, - instances, - masks, - move_offsets, - paints, - masks_layout, - masks_group, - }) - } - - fn bind_group_0( - device: &Device, - layout: &BindGroupLayout, - window_buffer: &Buffer, - ) -> BindGroup { - device.create_bind_group(&BindGroupDescriptor { - layout, - entries: &[BindGroupEntry { - binding: 0, - resource: window_buffer.as_entire_binding(), - }], - label: Some("ui window"), - }) - } - - fn primitive_group( - device: &Device, - layout: &BindGroupLayout, - buffers: [(u32, &Buffer); PrimitiveBuffers::LEN], - ) -> BindGroup { - device.create_bind_group(&BindGroupDescriptor { - layout, - entries: &buffers.map(|(binding, buf)| BindGroupEntry { - binding, - resource: buf.as_entire_binding(), - }), - label: Some("ui primitives"), - }) - } - - /// 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 { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - sample_type: TextureSampleType::Float { filterable: false }, - view_dimension: TextureViewDimension::D2Array, - multisampled: false, - }, - count: None, - }, - BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - 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"), - }) - } - - 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, - min_binding_size: None, - }, - count: None, - }, - BindGroupLayoutEntry { - binding: 3, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - ], - label: Some("ui masks"), - }) - } - - fn masks_group( - device: &Device, - layout: &BindGroupLayout, - masks: &ArrBuf, - move_offsets: &ArrBuf, - instances: &ArrBuf, - paints: &ArrBuf, - ) -> BindGroup { - device.create_bind_group(&BindGroupDescriptor { - layout, - entries: &[ - BindGroupEntry { - binding: 0, - resource: masks.buffer.as_entire_binding(), - }, - BindGroupEntry { - binding: 1, - resource: move_offsets.buffer.as_entire_binding(), - }, - BindGroupEntry { - binding: 2, - resource: instances.buffer.as_entire_binding(), - }, - BindGroupEntry { - binding: 3, - resource: paints.buffer.as_entire_binding(), - }, - ], - label: Some("ui masks"), - }) - } - - pub fn view_count(&self) -> usize { - self.textures.view_count() - } - - pub fn take_image_bind_group_creates(&mut self) -> u64 { - self.textures.take_bind_group_creates() - } - - pub fn take_atlas_pages_grown(&mut self) -> u64 { - self.textures.take_pages_grown() - } -} - -/// 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, - pub paints_resized: bool, -} diff --git a/iris/core/src/render/primitive.rs b/iris/core/src/render/primitive.rs deleted file mode 100644 index d7950d2..0000000 --- a/iris/core/src/render/primitive.rs +++ /dev/null @@ -1,720 +0,0 @@ -use std::ops::Deref; - -use crate::{ - UiRegion, WidgetId, - render::{ - ArrBuf, - data::{MaskIdx, MoveIdx, PrimitiveInstance}, - }, - util::{Dirty, HashSet}, -}; -use bytemuck::Pod; -use wgpu::*; - -/// 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; - fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec; -} - -macro_rules! primitives { - ($($name:ident: $ty:ty => $binding:expr,)*) => { - #[derive(Default)] - pub struct PrimitiveData { - $(pub(crate) $name: PrimitiveVec<$ty>,)* - } - - pub struct PrimitiveBuffers { - $($name: ArrBuf<$ty>,)* - } - - impl PrimitiveBuffers { - /// Answers whether **any** of the per-primitive buffers was - /// reallocated, which is the only thing that obliges the - /// caller to rebuild the bind group naming them. It used to - /// return nothing and the group was rebuilt on every frame - /// the arena changed -- once `ArrBuf` kept its buffer across - /// a length change, that was a bind group per frame for a - /// buffer identity that had not moved. - pub fn update( - &mut self, - device: &Device, - queue: &Queue, - data: &mut PrimitiveData, - ) -> bool { - let mut reallocated = false; - $( - let (entries, dirty) = data.$name.for_upload(); - reallocated |= self.$name.update(device, queue, entries, dirty); - )* - reallocated - } - } - - impl PrimitiveBuffers { - pub const LEN: usize = primitives!(@count $($name)*); - pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*]; - pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { - [ - $((<$ty>::BINDING, &self.$name.buffer),)* - ] - } - pub fn new(device: &Device) -> Self { - Self { - $($name: ArrBuf::new( - device, - BufferUsages::STORAGE | BufferUsages::COPY_DST, - stringify!($name), - ),)* - } - } - } - - impl PrimitiveData { - pub fn needs_upload(&self) -> bool { - $(!self.$name.dirty.is_clean() ||)* false - } - pub fn clear(&mut self) { - $(self.$name.clear();)* - } - pub fn free(&mut self, binding: u32, idx: usize) { - match binding { - $(<$ty>::BINDING => self.$name.free(idx),)* - _ => unreachable!() - } - } - } - - $( - unsafe impl bytemuck::Pod for $ty {} - unsafe impl bytemuck::Zeroable for $ty {} - impl Primitive for $ty { - const BINDING: u32 = $binding; - fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec { - &mut data.$name - } - fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec { - &data.$name - } - } - )* - }; - // 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 Primitives { - instances: Vec, - original_instances: Vec>, - assoc: Vec, - handle_idx: Vec, - freed: Vec, - reusable: Vec, - data: PrimitiveData, - pub dirty: Dirty, -} - -impl Default for Primitives { - fn default() -> Self { - Self { - instances: Default::default(), - original_instances: Default::default(), - assoc: Default::default(), - handle_idx: Default::default(), - freed: Vec::new(), - reusable: Vec::new(), - data: Default::default(), - dirty: Dirty::new_all(), - } - } -} - -impl Primitives { - 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, - PrimitiveInst { - id, - primitive, - region, - mask_idx, - move_idx, - }: PrimitiveInst

, - ) -> (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) - } - - 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 { - let slot = if let Some(i) = self.reusable.pop() { - self.instances[i] = inst; - self.original_instances[i] = None; - self.assoc[i] = id; - self.handle_idx[i] = Self::NO_HANDLE; - i - } else { - self.instances.push(inst); - self.original_instances.push(None); - self.assoc.push(id); - self.handle_idx.push(Self::NO_HANDLE); - self.instances.len() - 1 - }; - self.dirty.mark(slot); - slot as u32 - } - - /// Rewrites a slot this widget already owns. Freed slots are not reusable - /// until the end of a frame, so nested provisional redraws must recycle. - /// The caller has already checked that `h` is the same kind of - /// primitive in the same layer, which is what makes the slot, its - /// entry in the per-primitive data, and its position in the layer's - /// draw order all still the right ones -- so nothing here touches - /// `freed`, `reusable` or `LayerOrder`, and no renumbering follows. - pub fn recycle( - &mut self, - h: &PrimitiveHandle, - PrimitiveInst { - id, - primitive, - region, - mask_idx, - move_idx, - }: PrimitiveInst

, - ) { - debug_assert_eq!( - h.binding, - P::BINDING, - "recycling slot {} as a different kind of primitive than it holds", - h.slot, - ); - P::vec(&mut self.data).set(h.data_idx, primitive); - self.set_instance( - h.slot, - PrimitiveInstance { - region, - idx: h.data_idx as u32, - mask_idx, - move_idx, - binding: P::BINDING, - }, - id, - ); - } - - pub fn recycle_image( - &mut self, - h: &PrimitiveHandle, - id: WidgetId, - texture_idx: u32, - region: UiRegion, - mask_idx: MaskIdx, - move_idx: MoveIdx, - ) { - debug_assert_eq!( - h.binding, IMAGE_BINDING, - "recycling slot {} as an image when it holds a primitive", - h.slot, - ); - self.set_instance( - h.slot, - PrimitiveInstance { - region, - idx: texture_idx, - mask_idx, - move_idx, - binding: IMAGE_BINDING, - }, - id, - ); - } - - /// Writes an instance into a slot that already holds one, marking it - /// dirty only if it differs -- the same rule as - /// [`PrimitiveVec::set`], for the same reason. `assoc` is not part of - /// the comparison because it is never uploaded. - fn set_instance(&mut self, slot: u32, inst: PrimitiveInstance, id: WidgetId) { - let slot = slot as usize; - self.assoc[slot] = id; - if bytemuck::bytes_of(&self.instances[slot]) == bytemuck::bytes_of(&inst) { - return; - } - if !self.dirty.contains(slot) { - self.original_instances[slot] = Some(self.instances[slot]); - } - self.instances[slot] = inst; - if self.original_instances[slot] - .is_some_and(|original| bytemuck::bytes_of(&original) == bytemuck::bytes_of(&inst)) - { - self.original_instances[slot] = None; - self.dirty.unmark(slot); - } else { - self.dirty.mark(slot); - } - } - - /// 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 { - 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 - } - - pub fn release_freed(&mut self) { - self.reusable.append(&mut self.freed); - } - - pub fn owner(&self, slot: u32) -> WidgetId { - self.assoc[slot as usize] - } - - 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.dirty.mark_all(); - self.instances.clear(); - self.original_instances.clear(); - self.assoc.clear(); - self.handle_idx.clear(); - self.freed.clear(); - self.reusable.clear(); - self.data.clear(); - } - - pub fn live_count(&self) -> usize { - self.instances.len() - self.freed.len() - self.reusable.len() - } - - 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 { - &self.data - } - - pub fn instances(&self) -> &Vec { - &self.instances - } - - pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) { - (&self.instances, &mut self.dirty) - } - - /// The per-primitive data, mutably, for the one caller that uploads it - /// (`UiRenderNode::update`) and so has to clear its dirty sets. - pub fn data_mut(&mut self) -> &mut PrimitiveData { - &mut self.data - } - - pub fn needs_upload(&self) -> bool { - !self.dirty.is_clean() || self.data.needs_upload() - } - - 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 { - let slot = h.slot as usize; - // The caller receives unrestricted mutable access, so this path - // cannot tell whether a later write restored the prior bytes. Leave - // the entry dirty rather than letting a stale `set_instance` baseline - // cancel it. - self.original_instances[slot] = None; - self.dirty.mark(slot); - &mut self.instances[h.slot as usize].region - } -} - -/// 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, - order_dirty: Dirty, - images_dirty: Dirty, - /// 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, dirty) = if is_image { - (&mut self.images, &mut self.images_dirty) - } else { - (&mut self.order, &mut self.order_dirty) - }; - list.push(slot); - dirty.mark(list.len() - 1); - list.len() - 1 - } - - 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); - } - } - - pub fn apply_free(&mut self) -> Vec { - let mut changes = Vec::new(); - self.apply_free_into(&mut changes); - changes - } - - pub(crate) fn apply_free_into(&mut self, changes: &mut Vec) { - Self::apply_free_list( - &mut self.free, - &mut self.order, - &mut self.order_dirty, - false, - changes, - ); - Self::apply_free_list( - &mut self.image_free, - &mut self.images, - &mut self.images_dirty, - true, - changes, - ); - } - - pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) { - (&self.order, &mut self.order_dirty) - } - - pub fn images_for_upload(&mut self) -> (&[u32], &mut Dirty) { - (&self.images, &mut self.images_dirty) - } - - fn apply_free_list( - free: &mut Vec, - list: &mut Vec, - dirty: &mut Dirty, - is_image: bool, - changes: &mut Vec, - ) { - free.sort_by(|a, b| b.cmp(a)); - for pos in free.drain(..) { - list.swap_remove(pos); - if pos != list.len() { - dirty.mark(pos); - changes.push(OrderChange { - slot: list[pos], - is_image, - pos, - }); - } - } - } - - pub fn order(&self) -> &Vec { - &self.order - } - - pub fn images(&self) -> &Vec { - &self.images - } -} - -pub struct OrderChange { - pub slot: u32, - 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, -} - -pub const NOT_DRAWN: usize = usize::MAX; - -#[derive(Clone, Copy, Debug)] -pub struct PrimitiveHandle { - pub layer: usize, - pub pos: usize, - pub slot: u32, - pub data_idx: usize, - pub binding: u32, -} - -impl PrimitiveHandle { - 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, - glyphs: GlyphPrimitive => 2, -); - -#[repr(C)] -#[derive(Copy, Clone)] -pub struct RectPrimitive { - /// Index into the separate paint buffer. Geometry stays untouched when a - /// theme replaces the value at this index. - pub paint: u32, - pub radius: f32, - pub thickness: f32, - pub inner_radius: f32, -} - -impl RectPrimitive { - pub fn color(paint: u32) -> Self { - Self { - paint, - radius: 0.0, - thickness: 0.0, - inner_radius: 0.0, - } - } -} - -#[repr(C)] -#[derive(Debug, Copy, Clone)] -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 paint: u32, - pub flags: u32, - _pad: u32, -} - -impl GlyphPrimitive { - pub const IS_COLOR: u32 = 1; - - pub fn new(uv_min: [f32; 2], uv_max: [f32; 2], layer: u32, paint: u32, flags: u32) -> Self { - Self { - uv_min, - uv_max, - layer, - paint, - flags, - _pad: 0, - } - } -} - -pub struct PrimitiveVec { - vec: Vec, - free: Vec, - /// Which entries have changed since the last upload. Every way to - /// write one goes through [`Self::add`] or [`Self::set`], which is - /// what keeps this in step -- there is deliberately no `DerefMut`, - /// because an entry written through one would never be uploaded. - pub dirty: Dirty, -} - -impl PrimitiveVec { - pub fn new() -> Self { - Self { - vec: Vec::new(), - free: Vec::new(), - dirty: Dirty::new_all(), - } - } - pub fn add(&mut self, t: T) -> usize { - let i = match self.free.pop() { - Some(i) => { - self.vec[i] = t; - i - } - None => { - self.vec.push(t); - self.vec.len() - 1 - } - }; - self.dirty.mark(i); - i - } - pub fn set(&mut self, i: usize, t: T) - where - T: Pod, - { - if bytemuck::bytes_of(&self.vec[i]) == bytemuck::bytes_of(&t) { - return; - } - self.vec[i] = t; - self.dirty.mark(i); - } - pub fn free(&mut self, i: usize) { - self.free.push(i); - } - /// The entries and the dirty set together, so an uploader can read one - /// while clearing the other -- they are different fields, but a - /// caller reaching for both through `Deref` cannot say so. - pub fn for_upload(&mut self) -> (&[T], &mut Dirty) { - (&self.vec, &mut self.dirty) - } - pub fn clear(&mut self) { - self.free.clear(); - self.vec.clear(); - self.dirty.mark_all(); - } -} - -impl Default for PrimitiveVec { - fn default() -> Self { - Self::new() - } -} - -impl Deref for PrimitiveVec { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.vec - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{UiVec2, Widgets, util::Id}; - - #[test] - fn an_instance_restored_before_upload_is_clean() { - let mut primitives = Primitives::default(); - let mut widgets = Widgets::new(); - let widget = widgets.add_strong(()); - let owner = widget.id(); - let original = PrimitiveInstance { - region: UiRegion::FULL, - binding: RectPrimitive::BINDING, - idx: 0, - mask_idx: MaskIdx::NONE, - move_idx: Id::preset(0), - }; - primitives.push(original, owner); - primitives.dirty.clear(); - - let mut moved = original; - moved.region = moved.region.offset(UiVec2::abs((0.0, 20.0))); - primitives.set_instance(0, moved, owner); - assert!(!primitives.dirty.is_clean()); - - primitives.set_instance(0, original, owner); - assert!( - primitives.dirty.is_clean(), - "the GPU never observes the provisional position" - ); - - primitives.set_instance(0, moved, owner); - assert!(!primitives.dirty.is_clean()); - primitives.dirty.clear(); - primitives.set_instance(0, original, owner); - assert!(!primitives.dirty.is_clean()); - } -} diff --git a/iris/core/src/render/sdf.rs b/iris/core/src/render/sdf.rs deleted file mode 100644 index fde8284..0000000 --- a/iris/core/src/render/sdf.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::util::Vec2; - -pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 { - let p = pos - center; - 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 -} - -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/iris/core/src/render/shader.wgsl b/iris/core/src/render/shader.wgsl deleted file mode 100644 index 5c5de35..0000000 --- a/iris/core/src/render/shader.wgsl +++ /dev/null @@ -1,271 +0,0 @@ -const RECT: u32 = 0u; -// Standalone images select their texture through their own bind group. -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(GLYPH) -var glyphs: array; - -struct Rect { - paint: u32, - radius: f32, - thickness: f32, - inner_radius: f32, -} - -struct GlyphInfo { - uv_min: vec2, - uv_max: vec2, - // A layer in the shared atlas array, not a bind-group index. - layer: u32, - paint: u32, - flags: u32, -} - -/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root. -struct Mask { - primitive: u32, - parent: u32, -} - -/// Mirrors `MoveOffset` in data.rs. -struct MoveOffset { - delta: vec2, - parent: u32, -} - -struct UiSpan { - start: UiScalar, - end: UiScalar, -} - -struct UiScalar { - rel: f32, - abs: f32, -} - -// One array texture avoids descriptor indexing, which is not universal on Android. -@group(2) @binding(0) -var atlas: texture_2d_array; -// Image draws bind their texture here; other draws bind a 1x1 placeholder. -@group(2) @binding(1) -var image_texture: texture_2d; -@group(2) @binding(2) -var samp: sampler; -// Kept outside group 2 so standalone image bind groups need not name these buffers. -@group(3) @binding(0) -var masks: array; -@group(3) @binding(1) -var move_offsets: array; -// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape. -@group(3) @binding(2) -var instances: array; -// Solid linear RGBA today. Primitives already refer to paint records rather -// than embedding colours so gradients and texture fills can extend this -// lookup without rewriting geometry. -@group(3) @binding(3) -var paints: array>; - -// Keep synchronized with render_state.rs. The bound prevents a malformed -// parent cycle from hanging the GPU; real widget trees have exceeded 16. -const PARENT_CHAIN_LIMIT: u32 = 64u; - -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. -struct PrimitiveInstance { - x: UiSpan, - y: UiSpan, - binding: u32, - idx: u32, - mask_idx: u32, - move_idx: u32, -} - -struct InstanceInput { - @location(0) slot: u32, -} - -struct VertexOutput { - @location(0) top_left: vec2, - @location(1) bot_right: vec2, - @location(2) uv: vec2, - // Naga requires integer varyings to declare flat interpolation. - @location(3) @interpolate(flat) binding: u32, - @location(4) @interpolate(flat) idx: u32, - @location(5) @interpolate(flat) mask_idx: u32, - @builtin(position) clip_position: vec4, -}; - -struct Region { - pos: vec2, - uv: vec2, - top_left: vec2, - bot_right: vec2, -} - -/// Shared by drawing and mask coverage so their geometry cannot diverge. -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 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( - f32(vi % 2u), - f32(vi / 2u) - ); - 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 = inst.binding; - out.idx = inst.idx; - out.top_left = top_left; - out.bot_right = bot_right; - out.mask_idx = inst.mask_idx; - - return out; -} - -@fragment -fn fs_main( - in: VertexOutput -) -> @location(0) vec4 { - let pos = in.clip_position.xy; - let region = Region(pos, in.uv, in.top_left, in.bot_right); - let i = in.idx; - var color: vec4; - switch in.binding { - case RECT: { - color = draw_rounded_rect(region, rects[i]); - } - case TEXTURE: { - color = draw_texture(region); - } - case GLYPH: { - color = draw_glyph(region, glyphs[i]); - } - default: { - color = vec4(1.0, 0.0, 1.0, 1.0); - } - } - // Nested masks multiply coverage, matching the CPU hit test. - 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; -} - -/// Uses the referenced primitive itself so its drawn and clipped edges agree. -fn mask_coverage(pos: vec2, mask: Mask) -> f32 { - let inst = instances[mask.primitive]; - if inst.binding != RECT { - // Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks. - 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 = paints[g.paint]; - color.a *= texel.a; - return color; -} - -/// Keep synchronized with the CPU hit-test implementation in render::sdf. -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 { - var color = paints[rect.paint]; - - let edge = 0.5; - - 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); - } - - return color; -} - -fn distance_from_rect(pixel_pos: vec2, rect_center: vec2, rect_corner: vec2, radius: f32) -> f32 { - let p = pixel_pos - rect_center; - let q = abs(p) - (rect_corner - radius); - return length(max(q, vec2(0.0))) - radius; -} diff --git a/iris/core/src/render/texture.rs b/iris/core/src/render/texture.rs deleted file mode 100644 index 361c97c..0000000 --- a/iris/core/src/render/texture.rs +++ /dev/null @@ -1,451 +0,0 @@ -use image::{DynamicImage, EncodableLayout, GenericImageView}; -use wgpu::{util::DeviceExt, *}; - -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; - -enum Slot { - Empty, - Image(ImageGpu), - /// The array layer a live page occupies. Dropping the page empties this - /// resource slot; its array layer may then be assigned to a new page. - Page(u32), -} - -struct ImageGpu { - #[allow(dead_code)] - texture: Texture, - view: TextureView, - bind_group: BindGroup, -} - -/// - **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`. -pub struct GpuTextures { - device: Device, - queue: Queue, - - slots: Vec, - - array_texture: Texture, - array_view: TextureView, - array_capacity: u32, - /// One past the highest layer ever populated. Holes below this remain in - /// place when the array grows, while `Textures` can reuse their numbers. - layer_high_water: 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, - - bind_group_creates: u64, - pages_grown: u64, -} - -impl GpuTextures { - /// 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() { - match update { - 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(_kind) => self.slots.push(Slot::Empty), - } - } - rebuild_main - } - - 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.layer_high_water = self.layer_high_water.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; - } - } - - 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; - } - 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, - }, - ); - } - - 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.layer_high_water > 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.layer_high_water, - }, - ); - 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); - } - - 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: Some("image"), - size: Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: TextureDimension::D2, - // `image` and swash colour-glyph bytes are encoded sRGB. - // Sampling this view decodes RGB to the linear-light values - // used by the paint buffer and render pipeline; alpha stays - // linear. - format: TextureFormat::Rgba8UnormSrgb, - usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST, - view_formats: &[], - }, - wgt::TextureDataOrder::MipMajor, - rgba.as_bytes(), - ); - 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, - } - } - - 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"), - }) - } - - 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, - // One array contains both alpha-mask glyphs and colour glyphs. - // sRGB decoding leaves mask pages' white RGB and alpha unchanged - // while correctly decoding colour-glyph RGB. - format: TextureFormat::Rgba8UnormSrgb, - 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(), - slots: Vec::new(), - array_texture, - array_view, - array_capacity, - layer_high_water: 0, - sampler, - null_view, - bind_group_creates: 0, - pages_grown: 0, - } - } - - pub fn take_bind_group_creates(&mut self) -> u64 { - std::mem::take(&mut self.bind_group_creates) - } - - 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.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})"), - } - } -} - -pub fn null_texture_view(device: &Device) -> TextureView { - device - .create_texture(&TextureDescriptor { - label: Some("null"), - size: Extent3d { - width: 1, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: TextureDimension::D2, - format: TextureFormat::Rgba8UnormSrgb, - usage: TextureUsages::TEXTURE_BINDING, - view_formats: &[], - }) - .create_view(&TextureViewDescriptor::default()) -} - -pub fn default_sampler(device: &Device) -> Sampler { - device.create_sampler(&SamplerDescriptor::default()) -} diff --git a/iris/core/src/render/util/mod.rs b/iris/core/src/render/util/mod.rs deleted file mode 100644 index 305fec1..0000000 --- a/iris/core/src/render/util/mod.rs +++ /dev/null @@ -1,110 +0,0 @@ -use std::marker::PhantomData; - -use crate::util::Dirty; -use bytemuck::Pod; -use wgpu::*; - -/// **The buffer has a capacity, and shrinking never reallocates.** That -/// is not only about allocation cost: a fresh `Buffer`'s contents are -/// undefined, so a reallocation is the one event after which a *partial* -/// upload is not correct. Keeping the buffer alive across a length change -/// is therefore the precondition for uploading only what changed, and -/// [`Self::update`] says which of the two happened so a caller can force -/// the whole range dirty. -pub struct ArrBuf { - label: &'static str, - usage: BufferUsages, - pub buffer: Buffer, - len: usize, - capacity: usize, - _pd: PhantomData, -} - -/// The smallest allocation worth making, in entries. A buffer that starts -/// at the exact first length reallocates on the second frame of anything; -/// this is small enough to be free and large enough that a handful of -/// masks or move offsets never grows at all. -const MIN_CAPACITY: usize = 64; - -impl ArrBuf { - pub fn new(device: &Device, usage: BufferUsages, label: &'static str) -> Self { - Self { - label, - usage, - buffer: Self::init_buf(device, MIN_CAPACITY, usage, label), - len: 0, - capacity: MIN_CAPACITY, - _pd: PhantomData, - } - } - - pub fn reserve(&mut self, device: &Device, len: usize) -> bool { - if len <= self.capacity { - return false; - } - let mut capacity = self.capacity.max(MIN_CAPACITY); - while capacity < len { - capacity *= 2; - } - self.capacity = capacity; - self.buffer = Self::init_buf(device, capacity, self.usage, self.label); - true - } - - fn init_buf( - device: &Device, - entries: usize, - usage: BufferUsages, - label: &'static str, - ) -> Buffer { - let size = (entries.max(1) * std::mem::size_of::()) as u64; - device.create_buffer(&BufferDescriptor { - label: Some(label), - size, - mapped_at_creation: false, - usage, - }) - } - - /// Writes the entries `dirty` names and clears it, answering whether - /// the underlying `Buffer` was **recreated** -- which a caller holding - /// a `BindGroup` over it must know, since it has to rebuild that group. - /// - /// Correct only because the buffer outlives the data in it: a - /// reallocation leaves the rest of the buffer undefined, which is why - /// one forces the whole range dirty here rather than leaving the - /// caller to remember. Measured over the bench fixture - /// (`scripts/rigs/ui-profile`'s `arena_churn`): a fling writes 3.3% of - /// what the whole-array path wrote, and the median frame writes - /// nothing at all. - pub fn update( - &mut self, - device: &Device, - queue: &Queue, - data: &[T], - dirty: &mut Dirty, - ) -> bool { - let reallocated = self.reserve(device, data.len()); - if reallocated { - dirty.mark_all(); - } - self.len = data.len(); - let stride = std::mem::size_of::() as BufferAddress; - dirty.for_each_range(data.len(), Self::MERGE_GAP, |range| { - queue.write_buffer( - &self.buffer, - range.start as BufferAddress * stride, - bytemuck::cast_slice(&data[range]), - ); - }); - dirty.clear(); - reallocated - } - - const MERGE_GAP: usize = 1024 / std::mem::size_of::(); - - #[allow(clippy::len_without_is_empty)] - pub fn len(&self) -> usize { - self.len - } -} diff --git a/iris/core/src/ui/access.rs b/iris/core/src/ui/access.rs deleted file mode 100644 index a7bfe12..0000000 --- a/iris/core/src/ui/access.rs +++ /dev/null @@ -1,160 +0,0 @@ -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()) -} - -struct Entry { - name: String, - role: Role, - bounds: PixelRegion, - seen: u64, -} - -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 -- `desktop::DesktopUiState` and `android::AndroidUiState` -/// each keep one. -#[derive(Default)] -pub struct AccessTree { - known: HashMap, - generation: u64, - rebuilds: u64, -} - -impl AccessTree { - pub fn new() -> Self { - Self::default() - } - - /// Refresh the retained accessibility state and report whether it changed. - /// Existing entries are updated in place so an ordinary frame allocates - /// nothing, including one where only bounds changed. - pub fn refresh(&mut self, widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> bool { - self.generation = self.generation.wrapping_add(1); - if self.generation == 0 { - self.known.clear(); - self.generation = 1; - } - let generation = self.generation; - let mut changed = false; - for id in widgets.named() { - let Some(bounds) = render.window_region(&id, rsc) else { - continue; - }; - let Some(widget) = widgets.get_dyn(id) else { - continue; - }; - let name = widgets.label(id); - let role = widget.access_role(); - match self.known.get_mut(&id) { - Some(entry) => { - if entry.name != *name { - entry.name.clone_from(name); - changed = true; - } - if entry.role != role || entry.bounds != bounds { - entry.role = role; - entry.bounds = bounds; - changed = true; - } - entry.seen = generation; - } - None => { - self.known.insert( - id, - Entry { - name: name.clone(), - role, - bounds, - seen: generation, - }, - ); - changed = true; - } - } - } - let old_len = self.known.len(); - self.known.retain(|_, entry| entry.seen == generation); - changed |= self.known.len() != old_len; - if changed { - self.rebuilds += 1; - } - changed - } - - /// 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 { - if !self.refresh(widgets, render, rsc) { - return None; - } - Some(self.tree_update()) - } - - pub fn tree_update(&self) -> TreeUpdate { - build_update(&self.known) - } - - /// 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 { - let mut tree = Self::new(); - tree.refresh(widgets, render, rsc); - tree.tree_update() - } - - 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/iris/core/src/ui/active.rs b/iris/core/src/ui/active.rs deleted file mode 100644 index 05901e6..0000000 --- a/iris/core/src/ui/active.rs +++ /dev/null @@ -1,31 +0,0 @@ -use crate::{ - LayerId, MaskIdx, MoveIdx, PaintId, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, - util::Vec2, -}; - -#[derive(Debug)] -pub struct ActiveData { - pub id: WidgetId, - pub region: UiRegion, - pub parent: Option, - pub textures: Vec, - pub(crate) spare_textures: Vec, - /// Paint slots retained by this draw. The GPU primitive stores only the - /// slot index, so these handles are what prevent a live primitive from - /// observing a recycled paint. - pub paints: Vec, - pub(crate) spare_paints: Vec, - pub primitives: Vec, - pub(crate) spare_primitives: Vec, - pub children: Vec, - pub(crate) spare_children: Vec, - pub size_dependencies: Vec, - pub mask: MaskIdx, - /// The widget's retained mask slot, or `MaskIdx::NONE`. - pub own_mask: MaskIdx, - pub layer: LayerId, - pub size: Size, - pub move_slot: MoveIdx, - pub child_move_slot: Option, - pub move_applied: Vec2, -} diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs deleted file mode 100644 index b66f7d7..0000000 --- a/iris/core/src/ui/mod.rs +++ /dev/null @@ -1,215 +0,0 @@ -use crate::{ - Mask, MoveOffset, Paints, TextResources, Textures, WeakWidget, WidgetId, Widgets, - util::TrackedArena, -}; -use std::{ - cell::{Ref, RefCell, RefMut}, - ops::{Deref, DerefMut}, - rc::Rc, -}; - -mod access; -mod active; -mod painter; -mod render_state; - -pub use access::*; -pub use active::*; -pub use painter::{DrawResult, Painter}; -pub use render_state::*; - -#[derive(Default)] -pub struct UiData { - pub widgets: Widgets, - pub paints: Paints, - pub textures: Textures, - pub text: Rc>, - pub masks: TrackedArena, - pub move_offsets: TrackedArena, -} - -#[derive(Clone)] -pub struct RenderHandle { - pub(crate) render_state: Rc>, -} - -impl RenderHandle { - /// The retained result of the last completed frame. The framework holds - /// the corresponding mutable borrow for the whole of a render update, so - /// a read attempted while that state is incomplete fails at the boundary - /// instead of observing half a frame. - pub fn get(&self) -> Ref<'_, UiRenderState> { - self.render_state - .try_borrow() - .expect("render state cannot be read while a frame is being rendered") - } - - pub(crate) fn get_mut(&self) -> RefMut<'_, UiRenderState> { - self.render_state - .try_borrow_mut() - .expect("render state cannot be mutated while it is being read") - } -} - -impl Default for RenderHandle { - fn default() -> Self { - Self { - render_state: Rc::new(RefCell::new(UiRenderState::new())), - } - } -} - -#[derive(Default)] -pub struct Ui { - data: UiData, - pub(crate) render_state: RenderHandle, -} - -impl Ui { - /// Register application-owned font data for a semantic or named family. - /// Existing text resources are invalidated and their active widgets are - /// scheduled for layout again. - #[track_caller] - pub fn register_font( - &mut self, - family: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), crate::FontRegistrationError> { - self.update_fonts(|text| text.register_font(family, data)) - } - - /// Register application-owned font data in a named glyph-atlas bucket. - /// Fonts registered without this method share the default bucket. - #[track_caller] - pub fn register_font_in( - &mut self, - family: impl AsRef, - bucket: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), crate::FontRegistrationError> { - self.update_fonts(|text| text.register_font_in(family, bucket, data)) - } - - /// Replace an application's registered font while retaining its atlas - /// bucket. Text is reshaped and the bucket's old glyph pages are released. - #[track_caller] - pub fn replace_font( - &mut self, - family: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), crate::FontRegistrationError> { - self.update_fonts(|text| text.replace_font(family, data)) - } - - /// Replace an application's registered font and assign the replacement to - /// a named glyph-atlas bucket. - #[track_caller] - pub fn replace_font_in( - &mut self, - family: impl AsRef, - bucket: impl AsRef, - data: impl AsRef<[u8]> + Send + Sync + 'static, - ) -> Result<(), crate::FontRegistrationError> { - self.update_fonts(|text| text.replace_font_in(family, bucket, data)) - } - - fn update_fonts( - &mut self, - update: impl FnOnce(&mut TextResources) -> Result<(), crate::FontRegistrationError>, - ) -> Result<(), crate::FontRegistrationError> { - let owners = { - let mut text = self.data.text.borrow_mut(); - update(&mut text)?; - text.invalidate_all() - }; - let mut active = owners; - { - let render = self.render_state.get(); - active.retain(|owner| render.active.contains_key(owner)); - } - self.data.widgets.needs_redraw.extend(active); - Ok(()) - } - - pub fn is_font_registered(&self, family: impl AsRef) -> bool { - self.data.text.borrow().is_font_registered(family) - } - - /// A read-only handle to the retained result of the last completed frame. - /// The handle is owned so a caller may keep its read guard while mutating - /// unrelated resources on the `Rsc` that owns this `Ui`. - pub fn render_state(&self) -> RenderHandle { - self.render_state.clone() - } - - pub fn resize(&self, size: impl Into) { - self.render_state.get_mut().resize(size); - } - - pub fn set_density(&mut self, density: f32) { - self.data.text.borrow_mut().density = density; - self.render_state.get_mut().set_density(density); - } -} - -impl Deref for Ui { - type Target = UiData; - - fn deref(&self) -> &Self::Target { - &self.data - } -} - -impl DerefMut for Ui { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.data - } -} - -pub trait UiRsc { - fn ui(&self) -> &Ui; - fn ui_mut(&mut self) -> &mut Ui; - - fn draw<'a>(&mut self, root: impl Into>) - where - Self: Sized, - { - self.draw_at(root, std::time::Instant::now()); - } - - fn draw_at<'a>( - &mut self, - root: impl Into>, - frame_time: std::time::Instant, - ) -> bool - where - Self: Sized, - { - let render_state = self.ui().render_state.clone(); - render_state.get_mut().update_at(root, self, frame_time) - } - - #[allow(unused_variables)] - fn on_add(&mut self, id: WeakWidget) {} - #[allow(unused_variables)] - fn on_remove(&mut self, id: WidgetId) {} - #[allow(unused_variables)] - fn on_draw(&mut self, active: &ActiveData) {} - #[allow(unused_variables)] - fn on_undraw(&mut self, active: &ActiveData) {} - - fn widgets(&self) -> &Widgets { - &self.ui().widgets - } - fn widgets_mut(&mut self) -> &mut Widgets { - &mut self.ui_mut().widgets - } - fn free(&mut self) { - while let Some(id) = self.widgets_mut().free_next() { - self.on_remove(id); - } - self.ui_mut().text.borrow_mut().free_released(); - self.ui_mut().textures.free(); - self.ui_mut().paints.free_released(); - } -} diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs deleted file mode 100644 index 49f532c..0000000 --- a/iris/core/src/ui/painter.rs +++ /dev/null @@ -1,553 +0,0 @@ -use crate::{ - Axis, LayoutLen, MoveOffset, PaintId, RegionAlign, RenderedText, Size, StrongWidget, - TextHandle, TextResources, TextureHandle, UiData, UiRegion, UiRenderState, UiRsc, UiScalar, - UiVec2, WidgetId, - render::{ - Drawn, GlyphPrimitive, IMAGE_BINDING, Mask, MaskIdx, MoveIdx, NOT_DRAWN, Primitive, - PrimitiveHandle, PrimitiveInst, RectPrimitive, - }, - ui::render_state::Retained, - util::Vec2, -}; -use std::{cell::RefCell, rc::Rc, time::Instant}; - -pub struct Painter<'a> { - pub(super) render_state: &'a mut UiRenderState, - pub(super) rsc: &'a mut dyn UiRsc, - - pub(super) region: UiRegion, - pub(super) mask: MaskIdx, - pub(super) move_slot: MoveIdx, - pub(super) child_move_slot: Option, - pub(super) own_mask: MaskIdx, - pub(super) textures: Vec, - pub(super) recycle_textures: Vec, - pub(super) paints: Vec, - pub(super) recycle_paints: Vec, - pub(super) primitives: Vec, - pub(super) recycle: Vec, - pub(super) recycle_at: usize, - pub(super) children: Vec, - pub(super) size_dependencies: Vec, - pub(super) size: Option, - /// Whether a retained child's length on each axis is still valid. A - /// child's length may change when the parent's orthogonal extent changes - /// (most importantly, wrapped text gets taller when it gets narrower), - /// but not merely because a content-sized parent grew along that same - /// axis around one of its siblings. - pub(super) reuse_child_sizes: [bool; 2], - pub layer: usize, - pub(super) id: WidgetId, -} - -pub struct DrawResult<'p, 'a> { - painter: &'p mut Painter<'a>, - child: WidgetId, -} - -impl DrawResult<'_, '_> { - pub fn size(self) -> Size { - if !self.painter.size_dependencies.contains(&self.child) { - self.painter.size_dependencies.push(self.child); - } - self.painter.render_state.active[&self.child].size - } -} - -impl<'a> Painter<'a> { - /// The presentation time of this frame, supplied by the platform. On a - /// backend with a display clock this is the vsync timestamp, not the time - /// at which this widget happened to be drawn. - pub fn frame_time(&self) -> Instant { - self.render_state.frame_time - } - - /// Redraw this widget on the following frame and ask the platform to - /// produce that frame. The invalidation is staged until the current - /// retained redraw has finished, so it cannot be consumed again in this - /// frame. - pub fn request_next_frame(&mut self) { - self.render_state.next_frame.insert(self.id); - } - - pub fn set_size(&mut self, size: Size) { - assert!( - self.size.replace(size).is_none(), - "a widget set its size more than once during one draw" - ); - } - - fn primitive_at(&mut self, primitive: P, region: UiRegion) { - self.write_primitive(primitive, region, Drawn::Yes); - } - - /// **Consumed strictly in order, and one mismatch ends recycling for - /// the rest of the draw.** A widget's `draw` is a function of its own - /// state, so a redraw writes the same sequence of primitives in the - /// same order in the overwhelmingly common case; searching the - /// remainder for a match would turn an O(1) step into an O(primitives) - /// one to rescue a case that means the widget's content changed shape - /// anyway. Stopping is also what keeps the invariant simple: every - /// handle from `recycled` on is untouched and gets freed together. - fn take_recycled(&mut self, binding: u32, drawn: Drawn) -> Option { - let h = *self.recycle.get(self.recycle_at)?; - let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No); - if h.binding != binding || h.layer != self.layer || !drawn_matches { - return None; - } - self.recycle_at += 1; - Some(h) - } - - fn write_primitive( - &mut self, - primitive: P, - region: UiRegion, - drawn: Drawn, - ) -> u32 { - let inst = PrimitiveInst { - id: self.id, - primitive, - region, - mask_idx: self.mask, - move_idx: self.move_slot, - }; - let h = match self.take_recycled(P::BINDING, drawn) { - Some(h) => { - self.render_state.primitives.recycle(&h, inst); - h - } - None => self.render_state.write_primitive(self.layer, drawn, inst), - }; - if self.mask != MaskIdx::NONE { - 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. - fn own(&mut self, h: PrimitiveHandle) { - self.render_state - .primitives - .set_handle_index(h.slot, self.primitives.len() as u32); - self.primitives.push(h); - } - - pub fn primitive(&mut self, primitive: P) { - self.primitive_at(primitive, self.region) - } - - /// Resolves a public paint handle to the compact index stored by a GPU - /// primitive and retains the handle for exactly as long as that draw. - pub fn paint(&mut self, paint: &PaintId) -> u32 { - if !self.paints.contains(paint) { - if let Some(i) = self.recycle_paints.iter().position(|old| old == paint) { - self.paints.push(self.recycle_paints.swap_remove(i)); - } else { - self.paints.push(paint.clone()); - } - } - paint.slot() - } - - pub fn paint_value(&mut self, paint: &mut crate::PaintValue) -> u32 { - let paint = paint.resolve(&mut self.rsc.ui_mut().paints); - self.paint(paint) - } - - pub fn primitive_within(&mut self, primitive: P, region: UiRegion) { - self.primitive_at(primitive, region.within(&self.region)); - } - - /// 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) { - let paint = self.paint(&PaintId::NONE); - let shape = self.write_primitive(RectPrimitive::color(paint), region, Drawn::No); - self.set_mask_to(shape); - } - - /// 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.render_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); - } - - fn set_mask_to(&mut self, shape: u32) { - 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", - ); - let binding = self.render_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); - 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 - }; - 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; - } - - pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget) -> DrawResult<'p, 'a> { - self.widget_at(id, self.region) - } - - pub fn widget_within<'p, W: ?Sized>( - &'p mut self, - id: &StrongWidget, - region: UiRegion, - ) -> DrawResult<'p, 'a> { - self.widget_at(id, region.within(&self.region)) - } - - /// Translate this widget's children as one retained subtree, in output - /// pixels. The first call must happen before drawing a child, because the - /// slot becomes the parent of every direct child's ordinary move slot. - /// Once retained, it may be updated later in a redraw (for example after - /// measuring a changed child). All deeper descendants inherit it and the - /// CPU hit-test walk resolves the same translation as the shader. - pub fn set_child_offset(&mut self, offset: Vec2) { - let slot = match self.child_move_slot { - Some(slot) => slot, - None => { - assert!( - self.children.is_empty(), - "a child offset must be created before drawing a child" - ); - let parent = self.move_slot.idx() as u32; - let slot = self - .rsc - .ui_mut() - .move_offsets - .push(MoveOffset::new([offset.x, offset.y], parent)); - // One ref for this widget's ownership and one on the - // up-link. Direct children take their own refs when their - // move slots are allocated. - self.rsc.ui_mut().move_offsets.push_ref(slot); - self.rsc.ui_mut().move_offsets.push_ref(self.move_slot); - self.child_move_slot = Some(slot); - return; - } - }; - let next = [offset.x, offset.y]; - if self.rsc.ui().move_offsets[slot.idx()].delta != next { - self.rsc.ui_mut().move_offsets.get_mut(slot).delta = next; - self.render_state.note_move(); - } - } - - pub fn known_len(&mut self, id: &StrongWidget, axis: Axis) -> Option { - let len = if let Some(len) = self.rsc.widgets().get_dyn(id.id())?.size_hint(axis) { - Some(len.fold_dp(self.density())) - } else if !self.reuse_child_sizes[match axis { - Axis::X => 0, - Axis::Y => 1, - }] || self.rsc.widgets().needs_redraw.contains(&id.id()) - { - None - } else { - self.render_state - .active - .get(&id.id()) - .map(|a| a.size.axis(axis)) - }; - if len.is_some() && !self.size_dependencies.contains(&id.id()) { - self.size_dependencies.push(id.id()); - } - len - } - - fn widget_at<'p, W: ?Sized>( - &'p mut self, - id: &StrongWidget, - region: UiRegion, - ) -> DrawResult<'p, 'a> { - self.children.push(id.id()); - let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot); - self.render_state.draw_inner( - self.layer, - id.id(), - region, - Some(self.id), - parent_move_slot.idx() as u32, - self.mask, - Retained::default(), - self.rsc, - ); - DrawResult { - painter: self, - child: id.id(), - } - } - - pub fn place<'p, W: ?Sized>( - &'p mut self, - id: &StrongWidget, - region: UiRegion, - ) -> DrawResult<'p, 'a> { - let region = region.within(&self.region); - let retained = self - .render_state - .active - .get(&id.id()) - .map(|active| (active.layer, active.mask)); - if self.render_state.place(id.id(), region, self.rsc).is_some() { - } else if let Some((layer, mask)) = retained { - self.children.push(id.id()); - self.rsc.widgets_mut().needs_redraw.insert(id.id()); - let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot); - self.render_state.draw_inner( - layer, - id.id(), - region, - Some(self.id), - parent_move_slot.idx() as u32, - mask, - Retained::default(), - self.rsc, - ); - } else { - self.children.push(id.id()); - let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot); - self.render_state.draw_inner( - self.layer, - id.id(), - region, - Some(self.id), - parent_move_slot.idx() as u32, - self.mask, - Retained::default(), - self.rsc, - ); - } - DrawResult { - painter: self, - child: id.id(), - } - } - - pub fn place_used( - &mut self, - id: &StrongWidget, - used: Size, - within: UiRegion, - ) -> DrawResult<'_, 'a> { - let region = self.fit_region(used, within); - self.place(id, region) - } - - pub fn fit_region(&mut self, used: Size, mut within: UiRegion) -> UiRegion { - let mut region = used - .to_uivec2(self.density()) - .align(RegionAlign::TOP_LEFT) - .within(&within); - let output = self.output_size(); - for axis in [Axis::X, Axis::Y] { - let mut actual = region.within(&self.region); - let mut available = within.within(&self.region); - if actual.axis(axis).len().to_abs(output.axis(axis)) - > available.axis(axis).len().to_abs(output.axis(axis)) - { - *region.axis_mut(axis) = *within.axis(axis); - } - } - region - } - - pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { - self.retain_texture(handle); - self.write_image(handle.image_index(), region.within(&self.region)); - } - - pub fn texture(&mut self, handle: &TextureHandle) { - self.retain_texture(handle); - self.write_image(handle.image_index(), self.region); - } - - pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) { - self.retain_texture(handle); - self.write_image(handle.image_index(), region); - } - - fn retain_texture(&mut self, handle: &TextureHandle) { - if self.textures.contains(handle) { - return; - } - if let Some(i) = self.recycle_textures.iter().position(|old| old == handle) { - self.textures.push(self.recycle_textures.swap_remove(i)); - } else { - self.textures.push(handle.clone()); - } - } - - fn write_image(&mut self, texture_idx: u32, region: UiRegion) { - let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) { - Some(h) => { - self.render_state.primitives.recycle_image( - &h, - self.id, - texture_idx, - region, - self.mask, - self.move_slot, - ); - h - } - None => self.render_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, text: &TextHandle, width: Option) -> RenderedText { - let density = self.render_state.density; - let ui: &mut UiData = self.rsc.ui_mut(); - let (rendered, prepared) = text.render(width, self.id, &mut ui.textures, density); - self.render_state.shape_count += u64::from(prepared); - rendered - } - - pub fn render_ellipsis(&mut self, text: &TextHandle) -> RenderedText { - let density = self.render_state.density; - let ui: &mut UiData = self.rsc.ui_mut(); - let (rendered, prepared) = text.render_ellipsis(self.id, &mut ui.textures, density); - self.render_state.shape_count += u64::from(prepared); - rendered - } - - fn atlas_generation(&self) -> u64 { - self.rsc.ui().text.borrow().atlas.generation() - } - - 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 paint in text.paints.iter() { - self.paint(paint); - } - 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.paint, - flags_for(glyph.entry.is_color), - ), - region, - ); - } - } - - pub fn region(&self) -> UiRegion { - self.region - } - - pub fn output_size(&self) -> Vec2 { - self.render_state.output_size - } - - /// Physical pixels per `dp` -- see `UiRenderState::density`'s field - /// doc. What `LayoutLen::dp`'s `apply_rest` call resolves against. - pub fn density(&self) -> f32 { - self.render_state.density - } - - pub fn px_size(&mut self) -> Vec2 { - self.region.size().to_abs(self.render_state.output_size) - } - - pub fn text_resources(&mut self) -> Rc> { - self.rsc.ui().text.clone() - } - - pub fn child_layer(&mut self) { - self.layer = self.render_state.layers.child(self.layer); - } - - pub fn next_layer(&mut self) { - self.layer = self.render_state.layers.next(self.layer); - } - - pub fn label(&self) -> &str { - &self.rsc.widgets().data(self.id).unwrap().label - } - - pub fn id(&self) -> &WidgetId { - &self.id - } -} diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs deleted file mode 100644 index d47f7dc..0000000 --- a/iris/core/src/ui/render_state.rs +++ /dev/null @@ -1,1216 +0,0 @@ -use std::sync::Mutex; -use std::time::{Duration, Instant}; - -use crate::{ - ActiveData, Axis, ChildOrder, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, - RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiVec2, Widget, WidgetId, Widgets, - render::{ - Drawn, MoveOffset, NOT_DRAWN, OrderChange, Primitive, PrimitiveHandle, PrimitiveInst, - Primitives, RectPrimitive, rounded_rect_coverage, - }, - util::{HashMap, HashSet, Id, Vec2}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RedrawKind { - None, - All, - Updates, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct RenderCounters { - pub draws: u64, - pub region_rewrites: u64, - pub moves: u64, - pub shapes: u64, -} - -pub struct UiRenderState { - pub active: HashMap, - pub primitives: Primitives, - pub layers: PrimitiveLayers, - order_changes: Vec, - pub(super) output_size: Vec2, - /// Physical pixels per `dp` -- see `LayoutLen::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, - /// Widgets whose `draw` call is currently on the stack. Reentrant draws - /// are invalid because each widget owns exactly one retained primitive set. - draw_started: HashSet, - /// Widgets which asked from inside `draw` to be drawn on the following - /// frame. Kept separate from `Widgets::needs_redraw` until `update_at` - /// finishes because `redraw_updates` drains that set to completion. - pub(super) next_frame: HashSet, - pub(super) frame_time: Instant, - - /// `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, - pub(super) shape_count: u64, - - 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, - 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>, -} - -pub(crate) struct Retained { - pub region: Option, - pub children: Vec, - pub spare_children: Vec, - pub move_slot: Option, - pub child_move_slot: Option, - pub own_mask: MaskIdx, - pub textures: Vec, - pub spare_textures: Vec, - pub primitives: Vec, - pub spare_primitives: Vec, - pub paints: Vec, - pub spare_paints: Vec, - pub size_dependencies: Vec, -} - -impl Default for Retained { - fn default() -> Self { - Self { - region: None, - children: Vec::new(), - spare_children: Vec::new(), - move_slot: None, - child_move_slot: None, - own_mask: MaskIdx::NONE, - textures: Vec::new(), - spare_textures: Vec::new(), - primitives: Vec::new(), - spare_primitives: Vec::new(), - paints: Vec::new(), - spare_paints: Vec::new(), - size_dependencies: Vec::new(), - } - } -} - -pub const PARENT_CHAIN_LIMIT: usize = 64; - -impl UiRenderState { - pub fn new() -> Self { - Self { - active: Default::default(), - primitives: Default::default(), - layers: Default::default(), - order_changes: Vec::new(), - output_size: Vec2::ZERO, - density: 1.0, - old_root: None, - resized: false, - draw_started: Default::default(), - next_frame: Default::default(), - frame_time: Instant::now(), - 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), - } - } - - pub fn take_counters(&mut self) -> RenderCounters { - RenderCounters { - draws: std::mem::take(&mut self.draw_count), - region_rewrites: std::mem::take(&mut self.region_mut_count), - moves: std::mem::take(&mut self.mov_count), - shapes: std::mem::take(&mut self.shape_count), - } - } - - pub(super) fn note_move(&mut self) { - self.mov_count += 1; - } - - 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, - } - } - - 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, - } - } - - fn apply_free(&mut self) { - let mut changes = std::mem::take(&mut self.order_changes); - for (layer, order) in self.layers.iter_mut() { - changes.clear(); - order.apply_free_into(&mut changes); - for change in changes.drain(..) { - 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.order_changes = changes; - 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 `LayoutLen::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). - 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) { - self.update_at(root, rsc, Instant::now()); - } - - /// Draw a frame dated on the platform's presentation clock. Returns - /// whether a widget requested another frame. - pub fn update_at<'a>( - &mut self, - root: impl Into>, - rsc: &mut dyn UiRsc, - frame_time: Instant, - ) -> bool { - // safety mechanism for memory leaks; might wanna return a result instead so user can - // decide whether to panic or not - if !rsc.widgets().waiting.is_empty() { - let widgets = rsc.widgets(); - let len = widgets.waiting.len(); - let all: Vec<_> = widgets - .waiting - .iter() - .map(|&w| format!("'{}' ({w:?})", widgets.label(w))) - .collect(); - panic!( - "{len} widget(s) were never upgraded\n\ - this is likely a memory leak; consider upgrading to strong if you plan on using it later\n\ - weak widgets: {all:#?}" - ); - } - self.frame_time = frame_time; - let root = root.into(); - debug_assert!( - self.draw_started.is_empty(), - "a previous frame left {} widget(s) marked as mid-draw", - self.draw_started.len(), - ); - // Keep timing independent of the trace toggle so the first traced - // frame has a duration to report. The clock reads do not measurably - // affect the `--phone` benchmark. - 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; - self.apply_free(); - #[cfg(debug_assertions)] - debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),); - let active = &self.active; - let mut requested = false; - for id in self.next_frame.drain() { - if active.contains_key(&id) { - rsc.widgets_mut().needs_redraw.insert(id); - requested = true; - } - } - requested - } - - pub fn epoch(&self) -> Instant { - self.epoch - } - - pub fn frame_number(&self) -> u64 { - self.frame_no - } - - pub fn last_layout_duration(&self) -> Duration { - self.last_layout - } - - 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)) - } - - /// Excludes undrawn mask shapes so diagnostics match rasterized primitives. - 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); - if let Some(id) = root { - self.draw_inner( - 0, - id.id(), - UiRegion::FULL, - None, - MoveOffset::NONE_PARENT, - MaskIdx::NONE, - Retained::default(), - rsc, - ); - } - } - - 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) - } - - #[allow(clippy::too_many_arguments)] - pub(super) fn draw_inner( - &mut self, - layer: usize, - id: WidgetId, - region: UiRegion, - parent: Option, - parent_move_slot: u32, - mask: MaskIdx, - retained: Retained, - rsc: &mut dyn UiRsc, - ) -> Size { - let Retained { - region: mut old_region, - children: mut old_children, - spare_children: mut children, - move_slot: mut old_move_slot, - mut child_move_slot, - mut own_mask, - textures: mut recycle_textures, - spare_textures: mut textures, - primitives: mut recycle, - spare_primitives: mut primitives, - paints: mut recycle_paints, - spare_paints: mut paints, - mut size_dependencies, - } = retained; - let dirty = rsc.widgets_mut().needs_redraw.remove(&id); - let requires_exact_region = rsc - .widgets() - .get_dyn(id) - .is_some_and(|widget| widget.requires_exact_region()); - - if let Some(active) = self.active.get_mut(&id) - && !dirty - && active.layer == layer - && active.mask == mask - { - if active.region == region { - return active.size; - } else if Self::same_size(active.region, region, self.output_size) { - let from = active.region; - let size = active.size; - self.mov(id, from, region, rsc); - return size; - } else if !requires_exact_region - && Self::same_size( - active - .size - .to_uivec2(self.density) - .align(RegionAlign::TOP_LEFT) - .within(&active.region), - region, - self.output_size, - ) - { - return self.place(id, region, rsc).unwrap(); - } else if rsc - .widgets() - .get_dyn(id) - .map(|w| w.is_size_independent()) - .unwrap_or(false) - { - 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; - } - active.region = region; - return active.size; - } - let active = self.remove(id, false, true, rsc).unwrap(); - old_region = Some(active.region); - old_children = active.children; - children = active.spare_children; - old_move_slot = Some(active.move_slot); - child_move_slot = active.child_move_slot; - own_mask = active.own_mask; - recycle_textures = active.textures; - textures = active.spare_textures; - recycle = active.primitives; - primitives = active.spare_primitives; - recycle_paints = active.paints; - paints = active.spare_paints; - size_dependencies = active.size_dependencies; - } else if self.active.contains_key(&id) { - let layer_changed = self - .active - .get(&id) - .is_some_and(|active| active.layer != layer); - let active = self.remove(id, false, true, rsc).unwrap(); - if layer_changed { - rsc.on_undraw(&active); - } - old_region = Some(active.region); - old_children = active.children; - children = active.spare_children; - old_move_slot = Some(active.move_slot); - child_move_slot = active.child_move_slot; - own_mask = active.own_mask; - recycle_textures = active.textures; - textures = active.spare_textures; - recycle = active.primitives; - primitives = active.spare_primitives; - recycle_paints = active.paints; - paints = active.spare_paints; - size_dependencies = active.size_dependencies; - } - - textures.clear(); - paints.clear(); - primitives.clear(); - children.clear(); - size_dependencies.clear(); - - 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 = Self::move_slot_for(old_move_slot, parent_move_slot, rsc); - - let inherited_mask = mask; - let inherited_layer = layer; - let reuse_child_sizes = old_region.map_or([false; 2], |old| { - [ - Self::same_axis_size(old, region, Axis::Y, self.output_size), - Self::same_axis_size(old, region, Axis::X, self.output_size), - ] - }); - let mut painter = Painter { - render_state: self, - region, - mask, - move_slot, - child_move_slot, - own_mask, - layer, - id, - textures, - recycle_textures, - paints, - recycle_paints, - primitives, - recycle, - recycle_at: 0, - children, - size_dependencies, - size: None, - reuse_child_sizes, - rsc, - }; - - let mut widget = painter.rsc.widgets().get_dyn_dynamic(id); - let density = painter.density(); - let hints = [ - widget.size_hint(Axis::X).map(|len| len.fold_dp(density)), - widget.size_hint(Axis::Y).map(|len| len.fold_dp(density)), - ]; - painter.render_state.draw_count += 1; - widget.draw(&mut painter); - let size = painter.size.unwrap_or_else(|| { - panic!( - "widget '{}' ({id:?}) did not set its size during draw", - painter.rsc.widgets().label(id) - ) - }); - debug_assert!( - size.x.dp == 0.0 && size.y.dp == 0.0, - "widget {id:?} reported an unresolved `dp` size ({size:?}); \ - report `LayoutLen::fold_dp(painter.density())` instead" - ); - for (axis, hint) in [Axis::X, Axis::Y] - .into_iter() - .zip(hints) - .filter_map(|(axis, hint)| hint.map(|hint| (axis, hint))) - { - debug_assert_eq!( - size.axis(axis), - hint, - "widget {id:?}'s {axis:?} size hint differs from its draw result" - ); - } - drop(widget); - painter.render_state.draw_started.remove(&id); - - let Painter { - render_state: _, - rsc: _, - region, - mask: _, - move_slot, - child_move_slot, - own_mask, - textures, - mut recycle_textures, - paints, - mut recycle_paints, - primitives, - mut recycle, - recycle_at, - children, - size_dependencies, - size: _, - reuse_child_sizes: _, - layer: _, - id, - } = painter; - - for h in &recycle[recycle_at..] { - self.free_primitive(h); - } - for c in &old_children { - if !children.contains(c) { - self.remove_rec(*c, rsc); - } - } - recycle.clear(); - recycle_textures.clear(); - recycle_paints.clear(); - old_children.clear(); - - let active = ActiveData { - id, - region, - parent, - textures, - spare_textures: recycle_textures, - paints, - spare_paints: recycle_paints, - primitives, - spare_primitives: recycle, - children, - spare_children: old_children, - size_dependencies, - mask: inherited_mask, - layer: inherited_layer, - size, - move_slot, - child_move_slot, - own_mask, - move_applied: Vec2::ZERO, - }; - - rsc.on_draw(&active); - self.active.insert(id, active); - size - } - - /// A redraw **reuses the slot in place with its delta reset**, never - /// reallocates: the geometry this draw is about to write is already - /// at its correct absolute position, so a delta accumulated before it - /// would double-offset it -- while the chain link (`parent`) is left - /// alone, because the logical parent has not changed and a descendant - /// that is not itself redrawn still points here. See LAYOUT.md - /// section 2. - fn move_slot_for(old: Option, parent_move_slot: u32, rsc: &mut dyn UiRsc) -> MoveIdx { - match old { - Some(slot) => { - rsc.ui_mut().move_offsets.get_mut(slot).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 - } - } - } - - 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; - } - - fn same_size(a: UiRegion, b: UiRegion, output: Vec2) -> bool { - Self::same_axis_size(a, b, Axis::X, output) && Self::same_axis_size(a, b, Axis::Y, output) - } - - fn same_axis_size(mut a: UiRegion, mut b: UiRegion, axis: Axis, output: Vec2) -> bool { - let a = a.axis(axis).len().to_abs(output.axis(axis)); - let b = b.axis(axis).len().to_abs(output.axis(axis)); - (a - b).abs() < 0.01 - } - - pub(super) fn place( - &mut self, - id: WidgetId, - to: UiRegion, - rsc: &mut dyn UiRsc, - ) -> Option { - let active = self.active.get(&id)?; - if rsc - .widgets() - .get_dyn(id) - .is_some_and(|widget| widget.requires_exact_region()) - && !Self::same_size(active.region, to, self.output_size) - { - return None; - } - let from = active - .size - .to_uivec2(self.density) - .align(RegionAlign::TOP_LEFT) - .within(&active.region); - if !Self::same_size(from, to, self.output_size) { - return None; - } - let size = active.size; - if active.region == to { - return Some(size); - } - if Self::same_size(active.region, to, self.output_size) { - let region = active.region; - self.mov(id, region, to, rsc); - return Some(size); - } - - let move_applied = active.move_applied; - 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; - if delta.x.abs() < 0.01 && delta.y.abs() < 0.01 { - if let Some(active) = self.active.get_mut(&id) { - active.region = to; - } - return Some(size); - } - let entry = rsc.ui_mut().move_offsets.get_mut(slot); - entry.delta = [move_applied.x + delta.x, move_applied.y + delta.y]; - if let Some(active) = self.active.get_mut(&id) { - active.region = to; - active.move_applied += delta; - } - self.mov_count += 1; - Some(size) - } - - /// The handles stay in the returned `ActiveData` either way, freed or - /// not: `remask_shape_users` below reads them, and so does the - /// caller. **A caller that passed `keep_primitives: false` must not - /// free them again** -- they name slots that may already have been - /// handed out. - fn remove( - &mut self, - id: WidgetId, - undraw: bool, - keep_primitives: bool, - rsc: &mut dyn UiRsc, - ) -> Option { - let mut active = self.active.remove(&id); - if let Some(active) = &mut active { - for h in &active.primitives { - let mask = self.primitives.instance(h.slot).mask_idx; - if mask != MaskIdx::NONE { - rsc.ui_mut().masks.remove(mask); - } - } - if !keep_primitives { - for h in &active.primitives { - self.free_primitive(h); - } - } - Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc); - 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); - } - } - // The self-ownership ref held by this widget's optional - // child-coordinate slot, plus that slot's link to this - // widget's ordinary move slot. Direct children may still - // hold the slot alive until `remove_rec` reaches them, the - // same lifetime shape as `move_slot` immediately below. - if let Some(slot) = active.child_move_slot { - let parent_slot = rsc.ui().move_offsets[slot.idx()].parent; - rsc.ui_mut().move_offsets.remove(slot); - if parent_slot != MoveOffset::NONE_PARENT { - rsc.ui_mut().move_offsets.remove(Id::preset(parent_slot)); - } - } - 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 - } - - fn free_primitive(&mut self, h: &PrimitiveHandle) { - self.primitives.free(h); - if h.pos != NOT_DRAWN { - self.layers[h.layer].free(h.pos, h.is_image()); - } - } - - 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 { - let inst = self.remove(id, true, false, rsc); - if let Some(inst) = &inst { - for c in &inst.children { - self.remove_rec(*c, rsc); - } - } - inst - } - - fn clear(&mut self, rsc: &mut dyn UiRsc) { - for (_, active) in self.active.drain() { - rsc.on_undraw(&active); - } - self.layers.clear(); - self.primitives.clear(); - rsc.widgets_mut().needs_redraw.clear(); - rsc.free(); - } - - pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) { - while let Some(id) = rsc.widgets().needs_redraw.iter().next().copied() { - // A parent which read this child's size must lay out again before - // the changed child is drawn. Mark the whole dependent path so - // drawing its highest member reaches every dirty descendant. - let mut child = id; - for _ in 0..PARENT_CHAIN_LIMIT { - if self.size_matches_hints(child, rsc) { - break; - } - let Some(parent) = self.active.get(&child).and_then(|active| active.parent) else { - break; - }; - let depends = self - .active - .get(&parent) - .is_some_and(|active| active.size_dependencies.contains(&child)); - if !depends { - break; - } - rsc.widgets_mut().needs_redraw.insert(parent); - child = parent; - } - - // Prefer an already-dirty ancestor. Its draw either consumes this - // mark on the way down or leaves it for the next loop iteration if - // a retained intermediate subtree means it never reaches here. - let mut root = id; - let mut ancestor = self.active.get(&id).and_then(|active| active.parent); - for _ in 0..PARENT_CHAIN_LIMIT { - let Some(parent) = ancestor else { break }; - if rsc.widgets().needs_redraw.contains(&parent) { - root = parent; - } - ancestor = self.active.get(&parent).and_then(|active| active.parent); - } - self.redraw(root, rsc); - } - rsc.free(); - } - - fn size_matches_hints(&self, id: WidgetId, rsc: &dyn UiRsc) -> bool { - let Some(active) = self.active.get(&id) else { - return false; - }; - let Some(widget) = rsc.widgets().get_dyn(id) else { - return false; - }; - [Axis::X, Axis::Y].into_iter().all(|axis| { - widget - .size_hint(axis) - .map(|hint| hint.fold_dp(self.density)) - == Some(active.size.axis(axis)) - }) - } - - pub fn root_changed<'a>(&self, root: impl Into>) -> bool { - 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.needs_redraw_all(root) || widgets.has_updates() - } - - pub fn active_widgets(&self) -> usize { - self.active.len() - } - - 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 - } - - #[cfg(debug_assertions)] - 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 - } - - #[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); - if l == label { Some(inst) } else { None } - }) - } - - pub fn debug_layers(&self) { - for ((idx, depth), order) in self.layers.iter_depth() { - let indent = " ".repeat(depth * 2); - let len = order.order().len(); - print!("{indent}{idx}: {len} primitives"); - if len >= 1 { - print!(" ({})", self.primitives.instance(order.order()[0]).binding); - } - println!(); - } - } - - pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { - let active = self.active.get(&id.id())?; - let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied; - Some(active.region.offset(UiVec2::abs(delta))) - } - - 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); - 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 - } - - 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(" -> ") - } - - 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() + delta).floor(); - PixelRegion { - top_left: corner(inst.region.top_left()), - bot_right: corner(inst.region.bot_right()), - } - } - - pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion { - self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc) - } - - 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 - } - - pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool { - self.mask_coverage(mask, pos, rsc) > 0.5 - } - - 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)) - } - - /// This widget's immediate rendered children in the order non-visual - /// consumers should traverse them. The ordinary case costs no sort and - /// exactly preserves draw order; axis-aware layout widgets ask for their - /// resolved screen positions only when somebody actually queries them. - pub fn ordered_children(&self, id: WidgetId, rsc: &dyn UiRsc) -> Vec { - let Some(active) = self.active.get(&id) else { - return Vec::new(); - }; - let mut children = active.children.clone(); - // A layout may draw a child to learn its size and then place that - // retained drawing. Both operations are recorded for redraw lifetime, - // but a semantic traversal visits the child once. - let mut seen = HashSet::default(); - children.retain(|child| seen.insert(*child)); - let order = rsc - .widgets() - .get_dyn(id) - .map(Widget::child_order) - .unwrap_or_default(); - if let ChildOrder::Axis(axis) = order { - children.sort_by(|a, b| { - let at = self - .window_region(a, rsc) - .map(|r| r.top_left.axis(axis)) - .unwrap_or(f32::INFINITY); - let bt = self - .window_region(b, rsc) - .map(|r| r.top_left.axis(axis)) - .unwrap_or(f32::INFINITY); - at.total_cmp(&bt) - }); - } - children - } - - pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { - rsc.widgets_mut().needs_redraw.remove(&id); - if self.draw_started.contains(&id) { - return; - } - - let Some(active) = self.remove(id, false, true, rsc) else { - return; - }; - 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, - parent, - parent_move_slot, - active.mask, - Retained { - region: Some(active.region), - children: active.children, - spare_children: active.spare_children, - move_slot: Some(active.move_slot), - child_move_slot: active.child_move_slot, - own_mask: active.own_mask, - textures: active.textures, - spare_textures: active.spare_textures, - primitives: active.primitives, - spare_primitives: active.spare_primitives, - paints: active.paints, - spare_paints: active.spare_paints, - size_dependencies: active.size_dependencies, - }, - rsc, - ); - } -} - -impl Default for UiRenderState { - fn default() -> Self { - Self::new() - } -} diff --git a/iris/core/src/util/arena.rs b/iris/core/src/util/arena.rs deleted file mode 100644 index 76f1751..0000000 --- a/iris/core/src/util/arena.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::ops::Deref; - -use crate::util::{Dirty, Id, IdNum, IdTracker}; - -pub struct Arena { - data: Vec, - tracker: IdTracker, -} - -impl Arena { - pub fn new() -> Self { - Self { - data: Vec::new(), - tracker: IdTracker::default(), - } - } - - pub fn push(&mut self, value: T) -> Id { - let id = self.tracker.next(); - let i = id.idx(); - if i == self.data.len() { - self.data.push(value); - } else { - self.data[i] = value; - } - id - } - - pub fn remove(&mut self, id: Id) -> T - where - T: Copy, - { - let i = id.idx(); - self.tracker.free(id); - self.data[i] - } -} - -impl Default for Arena { - fn default() -> Self { - Self::new() - } -} - -pub struct TrackedArena { - inner: Arena, - refs: Vec, - pub dirty: Dirty, -} - -impl TrackedArena { - pub fn new() -> Self { - Self { - inner: Arena::default(), - refs: Vec::new(), - dirty: Dirty::new_all(), - } - } - - pub fn push(&mut self, value: T) -> Id { - let id = self.inner.push(value); - let i = id.idx(); - self.dirty.mark(i); - if i == self.refs.len() { - self.refs.push(0); - } - id - } - - pub fn push_ref(&mut self, i: Id) { - self.refs[i.idx()] += 1; - } - - pub fn get_mut(&mut self, id: Id) -> &mut T { - self.dirty.mark(id.idx()); - &mut self.inner.data[id.idx()] - } - - pub fn for_upload(&mut self) -> (&[T], &mut Dirty) { - (&self.inner.data, &mut self.dirty) - } - - pub fn remove(&mut self, id: Id) -> T - where - T: Copy, - { - let i = id.idx(); - self.refs[i] -= 1; - if self.refs[i] == 0 { - self.inner.remove(id) - } else { - self[i] - } - } -} - -impl Default for TrackedArena { - fn default() -> Self { - Self::new() - } -} - -impl Deref for TrackedArena { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.inner.data - } -} - -impl Deref for Arena { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.data - } -} diff --git a/iris/core/src/util/borrow.rs b/iris/core/src/util/borrow.rs deleted file mode 100644 index bc8e51a..0000000 --- a/iris/core/src/util/borrow.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::ops::{Deref, DerefMut}; - -pub struct DynBorrower<'a, T: ?Sized> { - data: &'a mut T, - borrowed: &'a mut bool, -} - -impl<'a, T: ?Sized> DynBorrower<'a, T> { - pub fn new(data: &'a mut T, borrowed: &'a mut bool) -> Self { - if *borrowed { - panic!("tried to mutably borrow the same thing twice"); - } - Self { data, borrowed } - } -} - -impl Drop for DynBorrower<'_, T> { - fn drop(&mut self) { - *self.borrowed = false; - } -} - -impl Deref for DynBorrower<'_, T> { - type Target = T; - - fn deref(&self) -> &Self::Target { - self.data - } -} - -impl DerefMut for DynBorrower<'_, T> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.data - } -} diff --git a/iris/core/src/util/change.rs b/iris/core/src/util/change.rs deleted file mode 100644 index f1a01bd..0000000 --- a/iris/core/src/util/change.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::ops::{Deref, DerefMut}; - -pub struct MutDetect { - inner: T, - pub changed: bool, -} - -impl Deref for MutDetect { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for MutDetect { - fn deref_mut(&mut self) -> &mut Self::Target { - self.changed = true; - &mut self.inner - } -} - -impl From for MutDetect { - fn from(inner: T) -> Self { - MutDetect { - inner, - changed: true, - } - } -} diff --git a/iris/core/src/util/dirty.rs b/iris/core/src/util/dirty.rs deleted file mode 100644 index beb074a..0000000 --- a/iris/core/src/util/dirty.rs +++ /dev/null @@ -1,173 +0,0 @@ -use std::ops::Range; - -#[derive(Default)] -pub struct Dirty { - words: Vec, - /// Everything is dirty regardless of the bits -- the state after a - /// buffer reallocation, whose contents are undefined, and the state a - /// freshly built arena starts in. Kept as a flag rather than by - /// setting every bit so that it costs nothing to say and cannot be - /// half-applied as the array grows. - all: bool, -} - -impl Dirty { - pub fn new_all() -> Self { - Self { - words: Vec::new(), - all: true, - } - } - - pub fn mark(&mut self, i: usize) { - if self.all { - return; - } - let word = i / 64; - if word >= self.words.len() { - self.words.resize(word + 1, 0); - } - self.words[word] |= 1 << (i % 64); - } - - pub fn contains(&self, i: usize) -> bool { - self.all - || self - .words - .get(i / 64) - .is_some_and(|word| word & (1 << (i % 64)) != 0) - } - - /// Clear one entry that was restored to the value already on the GPU. - /// `all` has no per-entry representation and is used only when every - /// byte must be uploaded regardless of later writes, so it stays set. - pub fn unmark(&mut self, i: usize) { - if self.all { - return; - } - if let Some(word) = self.words.get_mut(i / 64) { - *word &= !(1 << (i % 64)); - } - } - - /// Everything must be written: the buffer was reallocated (its - /// contents are undefined), or the array was cleared. - pub fn mark_all(&mut self) { - self.all = true; - self.words.clear(); - } - - pub fn is_clean(&self) -> bool { - !self.all && self.words.iter().all(|w| *w == 0) - } - - pub fn ranges(&self, len: usize, gap: usize) -> Vec> { - let mut ranges = Vec::new(); - self.for_each_range(len, gap, |range| ranges.push(range)); - ranges - } - - pub(crate) fn for_each_range( - &self, - len: usize, - gap: usize, - mut visit: impl FnMut(Range), - ) { - if self.all { - if len > 0 { - visit(0..len); - } - return; - } - let mut pending: Option> = None; - for (w, word) in self.words.iter().enumerate() { - let mut bits = *word; - while bits != 0 { - let start = w * 64 + bits.trailing_zeros() as usize; - let run = (bits >> (start - w * 64)).trailing_ones() as usize; - let end = (start + run).min(len); - if start >= len { - break; - } - match pending.as_mut() { - Some(last) if start - last.end <= gap => last.end = end, - _ => { - if let Some(range) = pending.replace(start..end) { - visit(range); - } - } - } - bits &= !(((1u128 << run) - 1) as u64) << (start - w * 64); - } - } - if let Some(range) = pending { - visit(range); - } - } - - pub fn clear(&mut self) { - self.all = false; - self.words.clear(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn marked(indices: &[usize], len: usize, gap: usize) -> Vec> { - let mut d = Dirty::default(); - for &i in indices { - d.mark(i); - } - d.ranges(len, gap) - } - - #[test] - fn adjacent_entries_are_one_range() { - assert_eq!(marked(&[3, 4, 5], 64, 0), vec![3..6]); - } - - #[test] - fn a_restored_entry_can_be_unmarked() { - let mut dirty = Dirty::default(); - dirty.mark(3); - dirty.mark(5); - assert!(dirty.contains(3)); - dirty.unmark(3); - assert!(!dirty.contains(3)); - assert_eq!(dirty.ranges(8, 0), vec![5..6]); - } - - #[test] - fn a_run_that_crosses_a_word_boundary_is_one_range() { - assert_eq!(marked(&[62, 63, 64, 65], 128, 0), vec![62..66]); - } - - #[test] - fn a_gap_wider_than_the_threshold_stays_two_ranges() { - assert_eq!(marked(&[0, 10], 64, 4), vec![0..1, 10..11]); - assert_eq!(marked(&[0, 10], 64, 16), vec![0..11]); - } - - #[test] - fn ranges_stop_at_the_length() { - assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]); - } - - #[test] - fn mark_all_covers_everything_and_survives_later_marks() { - let mut d = Dirty::new_all(); - d.mark(2); - assert_eq!(d.ranges(9, 0), vec![0..9]); - assert!(!d.is_clean()); - d.clear(); - assert!(d.is_clean()); - assert!(d.ranges(9, 0).is_empty()); - } - - #[test] - fn an_empty_array_has_nothing_to_upload_even_when_all_is_set() { - assert!(Dirty::new_all().ranges(0, 0).is_empty()); - } -} diff --git a/iris/core/src/util/id.rs b/iris/core/src/util/id.rs deleted file mode 100644 index 0cec955..0000000 --- a/iris/core/src/util/id.rs +++ /dev/null @@ -1,85 +0,0 @@ -#[repr(C)] -#[derive(Eq, Hash, PartialEq, Debug, Clone, Copy, bytemuck::Zeroable)] -pub struct Id(I); - -unsafe impl bytemuck::Pod for Id {} - -pub struct IdTracker { - free: Vec>, - cur: Id, -} - -impl IdTracker { - #[allow(clippy::should_implement_trait)] - pub fn next(&mut self) -> Id { - if let Some(id) = self.free.pop() { - return id; - } - let next = self.cur.next(); - std::mem::replace(&mut self.cur, next) - } - - #[allow(dead_code)] - pub fn free(&mut self, id: Id) { - self.free.push(id); - } -} - -impl Id { - #[allow(dead_code)] - pub(crate) fn raw(id: I) -> Self { - Self(id) - } - pub fn idx(&self) -> usize { - self.0.idx() - } - pub fn next(&self) -> Id { - Self(self.0.next()) - } - pub const fn preset(value: I) -> Self { - Self(value) - } -} - -impl Default for IdTracker { - fn default() -> Self { - Self { - free: Vec::new(), - cur: Id(I::first()), - } - } -} - -pub trait IdNum { - fn first() -> Self; - fn next(&self) -> Self; - fn idx(&self) -> usize; -} - -impl IdNum for u64 { - fn first() -> Self { - 0 - } - - fn next(&self) -> Self { - self + 1 - } - - fn idx(&self) -> usize { - *self as usize - } -} - -impl IdNum for u32 { - fn first() -> Self { - 0 - } - - fn next(&self) -> Self { - self + 1 - } - - fn idx(&self) -> usize { - *self as usize - } -} diff --git a/iris/core/src/util/math.rs b/iris/core/src/util/math.rs deleted file mode 100644 index 7b3d520..0000000 --- a/iris/core/src/util/math.rs +++ /dev/null @@ -1,85 +0,0 @@ -use std::ops::*; - -pub const trait LerpUtil { - fn lerp(self, from: Self, to: Self) -> Self; - fn lerp_inv(self, from: Self, to: Self) -> Self; -} - -pub const trait DivOr { - fn div_or(self, rhs: Self, other: Self) -> Self; -} - -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 } - } -} - -const impl< - T: const Add + const Sub + const Mul + const DivOr + Copy, -> LerpUtil for T -{ - fn lerp(self, from: Self, to: Self) -> Self { - from + (to - from) * self - } - fn lerp_inv(self, from: Self, to: Self) -> Self { - (self - from).div_or(to - from, from) - } -} - -macro_rules! impl_op { - ($T:ident $op:ident $fn:ident $opa:ident $fna:ident; $($field:ident)*) => { - #[allow(non_snake_case)] - mod ${concat($T, _op_, $fn, _impl)} { - use super::*; - #[allow(unused_imports)] - use std::ops::*; - const impl $op for $T { - type Output = Self; - - fn $fn(self, rhs: Self) -> Self::Output { - Self { - $($field: self.$field.$fn(rhs.$field),)* - } - } - } - const impl $opa for $T { - fn $fna(&mut self, rhs: Self) { - *self = self.$fn(rhs); - } - } - const impl $op for $T { - type Output = Self; - - fn $fn(self, rhs: f32) -> Self::Output { - Self { - $($field: self.$field.$fn(rhs),)* - } - } - } - const impl $op<$T> for f32 { - type Output = $T; - - fn $fn(self, rhs: $T) -> Self::Output { - $T { - $($field: self.$fn(rhs.$field),)* - } - } - } - const impl $opa for $T { - fn $fna(&mut self, rhs: f32) { - *self = self.$fn(rhs); - } - } - } - }; - ($T:ident $op:ident $fn:ident; $($field:ident)*) => { - impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*); - }; - (impl $op:ident for $T:ident: $fn:ident $($field:ident)*) => { - impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*); - }; -} - -pub(crate) use impl_op; diff --git a/iris/core/src/util/mod.rs b/iris/core/src/util/mod.rs deleted file mode 100644 index 9b381c1..0000000 --- a/iris/core/src/util/mod.rs +++ /dev/null @@ -1,28 +0,0 @@ -mod arena; -mod borrow; -mod change; -mod dirty; -mod id; -mod math; -mod refcount; -mod resources; -mod slot; -mod trust; -mod typemap; -mod vec2; - -pub use arena::*; -pub use borrow::*; -pub use change::*; -pub use dirty::*; -pub use id::*; -pub use math::*; -pub use refcount::*; -pub use resources::*; -pub use slot::*; -pub use trust::*; -pub use typemap::*; -pub use vec2::*; - -pub type HashMap = fxhash::FxHashMap; -pub type HashSet = fxhash::FxHashSet; diff --git a/iris/core/src/util/refcount.rs b/iris/core/src/util/refcount.rs deleted file mode 100644 index 77c1660..0000000 --- a/iris/core/src/util/refcount.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::sync::{ - Arc, - atomic::{AtomicU32, Ordering}, -}; - -#[derive(Debug)] -pub struct RefCounter(Arc); - -impl RefCounter { - pub fn new() -> Self { - Self(Arc::new(0.into())) - } - pub fn refs(&self) -> u32 { - self.0.load(Ordering::Acquire) - } - pub fn drop(&mut self) -> bool { - let refs = self.0.fetch_sub(1, Ordering::Release); - refs == 0 - } - pub fn quiet_clone(&self) -> Self { - Self(self.0.clone()) - } -} - -impl Default for RefCounter { - fn default() -> Self { - Self::new() - } -} - -impl Clone for RefCounter { - fn clone(&self) -> Self { - self.0.fetch_add(1, Ordering::Release); - Self(self.0.clone()) - } -} diff --git a/iris/core/src/util/resources.rs b/iris/core/src/util/resources.rs deleted file mode 100644 index f17773a..0000000 --- a/iris/core/src/util/resources.rs +++ /dev/null @@ -1,414 +0,0 @@ -use super::{SlotId, SlotVec}; -use std::{ - cell::{Ref, RefCell, RefMut}, - fmt, - hash::{Hash, Hasher}, - marker::PhantomData, - rc::Rc, - sync::mpsc::{Receiver, Sender, channel}, -}; - -enum Event { - Clone(SlotId), - Drop(SlotId), -} - -struct Entry { - value: T, - strong: Option, - recycle: bool, -} - -/// Generational storage and deferred strong-reference accounting for one kind -/// of UI resource. -pub struct Resources { - entries: SlotVec>, - send: Sender, - recv: Receiver, -} - -impl Resources { - pub fn new() -> Self { - let (send, recv) = channel(); - Self { - entries: SlotVec::new(), - send, - recv, - } - } - - pub fn add(&mut self, value: T) -> StrongRscId { - self.add_with(value, true) - } - - pub fn add_unrecycled(&mut self, value: T) -> StrongRscId { - self.add_with(value, false) - } - - fn add_with(&mut self, value: T, recycle: bool) -> StrongRscId { - let id = self.entries.add(Entry { - value, - strong: Some(1), - recycle, - }); - StrongRscId::new(id, self.send.clone()) - } - - /// Add an entry whose lifetime is the lifetime of the arena itself. - pub fn add_static(&mut self, value: T) -> WeakRscId { - WeakRscId::new(self.entries.add(Entry { - value, - strong: None, - recycle: false, - })) - } - - pub fn apply(&mut self, mut dropped: impl FnMut(SlotId, T)) { - for event in self.recv.try_iter() { - match event { - Event::Clone(id) => { - let entry = self - .entries - .get_mut(id) - .expect("cloned resource id points at a released slot"); - let strong = entry - .strong - .as_mut() - .expect("a static resource cannot have a strong id"); - *strong = strong.checked_add(1).expect("resource reference overflow"); - } - Event::Drop(id) => { - let remove = { - let entry = self - .entries - .get_mut(id) - .expect("dropped resource id points at a released slot"); - let strong = entry - .strong - .as_mut() - .expect("a static resource cannot have a strong id"); - *strong = strong.checked_sub(1).expect("resource reference underflow"); - *strong == 0 - }; - if remove { - let recycle = self.entries.get(id).unwrap().recycle; - let entry = if recycle { - self.entries.remove(id) - } else { - self.entries.remove_unrecycled(id) - } - .unwrap(); - dropped(id, entry.value); - } - } - } - } - } - - /// The owning manager must call [`Self::apply`] first so a queued final - /// drop cannot be mistaken for a still-live entry. - pub fn upgrade(&mut self, id: WeakRscId) -> Option> { - let entry = self.entries.get_mut(id.id)?; - let strong = entry.strong.as_mut()?; - *strong = strong.checked_add(1).expect("resource reference overflow"); - Some(StrongRscId::new(id.id, self.send.clone())) - } - - pub fn get(&self, id: impl RscId) -> Option<&T> { - Some(&self.entries.get(id.rsc_id())?.value) - } - - pub fn get_mut(&mut self, id: impl RscId) -> Option<&mut T> { - Some(&mut self.entries.get_mut(id.rsc_id())?.value) - } - - pub fn values_mut(&mut self) -> impl Iterator { - self.entries.values_mut().map(|entry| &mut entry.value) - } - - pub fn capacity(&self) -> usize { - self.entries.capacity() - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } -} - -impl Default for Resources { - fn default() -> Self { - Self::new() - } -} - -pub trait RscId { - fn rsc_id(&self) -> SlotId; -} - -/// A sendable owning ID for one entry in [`Resources`]. It keeps the entry -/// alive but does not provide access to its value. -pub struct StrongRscId { - id: SlotId, - send: Sender, - ty: PhantomData T>, -} - -impl StrongRscId { - fn new(id: SlotId, send: Sender) -> Self { - Self { - id, - send, - ty: PhantomData, - } - } - - pub fn weak(&self) -> WeakRscId { - WeakRscId::new(self.id) - } - - pub fn id(&self) -> SlotId { - self.id - } - - pub(crate) fn slot(&self) -> u32 { - self.id.slot() - } -} - -impl Clone for StrongRscId { - fn clone(&self) -> Self { - let _ = self.send.send(Event::Clone(self.id)); - Self::new(self.id, self.send.clone()) - } -} - -impl Drop for StrongRscId { - fn drop(&mut self) { - let _ = self.send.send(Event::Drop(self.id)); - } -} - -impl RscId for StrongRscId { - fn rsc_id(&self) -> SlotId { - self.id - } -} - -impl RscId for &StrongRscId { - fn rsc_id(&self) -> SlotId { - self.id - } -} - -impl fmt::Debug for StrongRscId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.id.fmt(f) - } -} - -impl PartialEq for StrongRscId { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - -impl Eq for StrongRscId {} - -impl Hash for StrongRscId { - fn hash(&self, state: &mut H) { - self.id.hash(state); - } -} - -/// A sendable, copyable ID that does not keep its [`Resources`] entry alive. -pub struct WeakRscId { - id: SlotId, - ty: PhantomData T>, -} - -impl WeakRscId { - fn new(id: SlotId) -> Self { - Self { - id, - ty: PhantomData, - } - } - - pub fn id(self) -> SlotId { - self.id - } - - pub(crate) fn slot(self) -> u32 { - self.id.slot() - } -} - -impl Clone for WeakRscId { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for WeakRscId {} - -impl RscId for WeakRscId { - fn rsc_id(&self) -> SlotId { - self.id - } -} - -impl RscId for &WeakRscId { - fn rsc_id(&self) -> SlotId { - self.id - } -} - -impl fmt::Debug for WeakRscId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.id.fmt(f) - } -} - -impl PartialEq for WeakRscId { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - -impl Eq for WeakRscId {} - -impl Hash for WeakRscId { - fn hash(&self, state: &mut H) { - self.id.hash(state); - } -} - -/// Convenient UI-thread access to a resource through an owning ID. The `Rc` -/// deliberately makes this local; tasks carry strong or weak IDs instead. -pub struct RscHandle { - id: StrongRscId, - resources: Rc>>, -} - -impl RscHandle { - pub fn new(id: StrongRscId, resources: Rc>>) -> Self { - Self { id, resources } - } - - pub fn add(resources: Rc>>, value: T) -> Self { - let id = resources.borrow_mut().add(value); - Self::new(id, resources) - } - - pub fn get(&self) -> Ref<'_, T> { - Ref::map(self.resources.borrow(), |resources| { - resources - .get(&self.id) - .expect("resource handle points at a released slot") - }) - } - - pub fn get_mut(&mut self) -> RefMut<'_, T> { - RefMut::map(self.resources.borrow_mut(), |resources| { - resources - .get_mut(&self.id) - .expect("resource handle points at a released slot") - }) - } - - pub(crate) fn get_mut_shared(&self) -> RefMut<'_, T> { - RefMut::map(self.resources.borrow_mut(), |resources| { - resources - .get_mut(&self.id) - .expect("resource handle points at a released slot") - }) - } - - pub fn strong(&self) -> StrongRscId { - self.id.clone() - } - - pub fn weak(&self) -> WeakRscId { - self.id.weak() - } - - pub fn id(&self) -> SlotId { - self.id.id() - } -} - -impl Clone for RscHandle { - fn clone(&self) -> Self { - Self::new(self.id.clone(), self.resources.clone()) - } -} - -impl fmt::Debug for RscHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.id.fmt(f) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_slot_lives_until_every_strong_id_is_dropped() { - let mut resources = Resources::new(); - let first = resources.add("value"); - let weak = first.weak(); - let second = first.clone(); - drop(first); - resources.apply(|_, _| {}); - assert_eq!(resources.get(weak), Some(&"value")); - - drop(second); - resources.apply(|_, _| {}); - assert_eq!(resources.get(weak), None); - } - - #[test] - fn a_weak_id_can_be_upgraded_while_the_resource_is_alive() { - let mut resources = Resources::new(); - let first = resources.add("value"); - let weak = first.weak(); - let second = resources.upgrade(weak).unwrap(); - drop(first); - resources.apply(|_, _| {}); - assert_eq!(resources.get(&second), Some(&"value")); - } - - #[test] - fn strong_and_weak_ids_can_cross_threads() { - fn assert_send() {} - assert_send::>(); - assert_send::>(); - - let mut resources = Resources::new(); - let first = resources.add("value"); - let second = first.clone(); - std::thread::spawn(move || drop(second)).join().unwrap(); - drop(first); - resources.apply(|_, _| {}); - assert!(resources.is_empty()); - } - - #[test] - fn an_unrecycled_slot_stays_a_hole() { - let mut resources = Resources::new(); - let first = resources.add_unrecycled("first"); - let first_slot = first.slot(); - drop(first); - resources.apply(|_, _| {}); - - let second = resources.add("second"); - - assert_ne!(second.slot(), first_slot); - assert_eq!(resources.len(), 1); - } -} diff --git a/iris/core/src/util/slot.rs b/iris/core/src/util/slot.rs deleted file mode 100644 index 3e18b42..0000000 --- a/iris/core/src/util/slot.rs +++ /dev/null @@ -1,115 +0,0 @@ -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct SlotId { - idx: u32, - genr: u32, -} - -impl SlotId { - pub(crate) fn slot(self) -> u32 { - self.idx - } - - /// 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, - len: usize, -} - -impl SlotVec { - pub fn new() -> Self { - Self { - data: Default::default(), - free: Default::default(), - len: 0, - } - } - - pub fn add(&mut self, x: T) -> SlotId { - let id = if let Some(idx) = self.free.pop() { - let (genr, data) = &mut self.data[idx as usize]; - *data = Some(x); - SlotId { idx, genr: *genr } - } else { - let idx = self.data.len() as u32; - let genr = 0; - self.data.push((genr, Some(x))); - SlotId { idx, genr } - }; - self.len += 1; - id - } - - pub fn free(&mut self, id: SlotId) { - let _ = self.remove(id); - } - - pub fn remove(&mut self, id: SlotId) -> Option { - self.remove_inner(id, true) - } - - pub fn remove_unrecycled(&mut self, id: SlotId) -> Option { - self.remove_inner(id, false) - } - - fn remove_inner(&mut self, id: SlotId, recycle: bool) -> Option { - let (genr, data) = &mut self.data[id.idx as usize]; - if *genr != id.genr { - return None; - } - *genr += 1; - let value = data.take()?; - self.len -= 1; - if recycle { - self.free.push(id.idx); - } - Some(value) - } - - pub fn get(&self, id: SlotId) -> Option<&T> { - let slot = &self.data[id.idx as usize]; - if slot.0 != id.genr { - return None; - } - slot.1.as_ref() - } - - pub fn get_mut(&mut self, id: SlotId) -> Option<&mut T> { - let slot = &mut self.data[id.idx as usize]; - if slot.0 != id.genr { - return None; - } - slot.1.as_mut() - } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - pub fn values_mut(&mut self) -> impl Iterator { - self.data.iter_mut().filter_map(|(_, value)| value.as_mut()) - } - - pub fn capacity(&self) -> usize { - self.data.len() - } -} - -impl Default for SlotVec { - fn default() -> Self { - Self::new() - } -} diff --git a/iris/core/src/util/trust.rs b/iris/core/src/util/trust.rs deleted file mode 100644 index b9d1a6b..0000000 --- a/iris/core/src/util/trust.rs +++ /dev/null @@ -1,17 +0,0 @@ -#[allow(clippy::missing_safety_doc)] -pub unsafe fn forget_ref<'a, T>(x: &T) -> &'a T { - unsafe { std::mem::transmute::<&T, &T>(x) } -} - -#[allow(clippy::missing_safety_doc)] -pub unsafe fn forget_mut<'a, T>(x: &mut T) -> &'a mut T { - unsafe { std::mem::transmute::<&mut T, &mut T>(x) } -} - -#[allow(clippy::mut_from_ref, clippy::missing_safety_doc)] -pub unsafe fn to_mut(x: &T) -> &mut T { - #[allow(mutable_transmutes)] - unsafe { - std::mem::transmute::<&T, &mut T>(x) - } -} diff --git a/iris/core/src/util/typemap.rs b/iris/core/src/util/typemap.rs deleted file mode 100644 index 8463472..0000000 --- a/iris/core/src/util/typemap.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::util::HashMap; -use std::{ - any::TypeId, - marker::Unsize, - ops::{Deref, DerefMut}, -}; - -pub struct TypeMap { - map: HashMap>, -} - -impl TypeMap { - pub fn set_type + 'static>(&mut self, val: T) { - self.map - .insert(TypeId::of::(), Box::new(val) as Box); - } - - pub fn type_mut + 'static>(&mut self) -> Option<&mut T> { - Some(Self::convert_mut(self.map.get_mut(&TypeId::of::())?)) - } - - pub fn type_or_default + 'static>(&mut self) -> &mut T { - Self::convert_mut( - self.map - .entry(TypeId::of::()) - .or_insert(Box::new(T::default()) as Box), - ) - } - - fn convert_mut>(entry: &mut Box) -> &mut T { - unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) } - } -} - -impl Deref for TypeMap { - type Target = HashMap>; - - fn deref(&self) -> &Self::Target { - &self.map - } -} - -impl DerefMut for TypeMap { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.map - } -} - -impl Default for TypeMap { - fn default() -> Self { - Self { - map: Default::default(), - } - } -} diff --git a/iris/core/src/util/vec2.rs b/iris/core/src/util/vec2.rs deleted file mode 100644 index 6efdd34..0000000 --- a/iris/core/src/util/vec2.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::util::{DivOr, impl_op}; -use std::{hash::Hash, ops::*}; - -#[repr(C)] -#[derive(Clone, Copy, PartialEq, Default, bytemuck::Pod, bytemuck::Zeroable)] -pub struct Vec2 { - pub x: f32, - pub y: f32, -} - -impl Eq for Vec2 {} - -impl Hash for Vec2 { - fn hash(&self, state: &mut H) { - state.write_u32(self.x.to_bits()); - state.write_u32(self.y.to_bits()); - } -} - -impl Vec2 { - pub const ZERO: Self = Self::new(0.0, 0.0); - pub const ONE: Self = Self::new(1.0, 1.0); - - pub const fn new(x: f32, y: f32) -> Self { - Self { x, y } - } - - pub const fn round(self) -> Self { - Self { - x: self.x.round(), - y: self.y.round(), - } - } - - pub const fn floor(self) -> Self { - Self { - x: self.x.floor(), - y: self.y.floor(), - } - } - - pub const fn ceil(self) -> Self { - Self { - x: self.x.ceil(), - y: self.y.ceil(), - } - } - - pub const fn tuple(&self) -> (f32, f32) { - (self.x, self.y) - } - - pub const fn with_x(mut self, x: f32) -> Self { - self.x = x; - self - } - - pub const fn with_y(mut self, y: f32) -> Self { - self.y = y; - self - } -} - -impl_op!(impl Add for Vec2: add x y); -impl_op!(Vec2 Sub sub; x y); -impl_op!(Vec2 Mul mul; x y); -impl_op!(Vec2 Div div; x y); - -const impl DivOr for Vec2 { - fn div_or(self, rhs: Self, other: Self) -> Self { - Self { - x: self.x.div_or(rhs.x, other.x), - y: self.y.div_or(rhs.y, other.y), - } - } -} - -impl Neg for Vec2 { - type Output = Self; - - fn neg(mut self) -> Self::Output { - self.x = -self.x; - self.y = -self.y; - self - } -} - -impl std::fmt::Debug for Vec2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} - -impl std::fmt::Display for Vec2 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "({}, {})", self.x, self.y) - } -} diff --git a/iris/core/src/widget/data.rs b/iris/core/src/widget/data.rs deleted file mode 100644 index bdc6485..0000000 --- a/iris/core/src/widget/data.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::Widget; - -pub struct WidgetData { - pub widget: Box, - pub label: String, - pub borrowed: bool, -} - -impl WidgetData { - pub fn new(widget: W) -> Self { - let name = std::any::type_name::(); - let label = match (name.find("::"), name.rfind("::")) { - (Some(first), Some(last)) => { - let suffix = &name[last + 2..]; - let mut label = String::with_capacity(first + 2 + suffix.len()); - label.push_str(&name[..first]); - label.push_str("::"); - label.push_str(suffix); - label - } - _ => name.to_owned(), - }; - Self { - widget: Box::new(widget), - label, - borrowed: false, - } - } -} diff --git a/iris/core/src/widget/handle.rs b/iris/core/src/widget/handle.rs deleted file mode 100644 index 3a2c434..0000000 --- a/iris/core/src/widget/handle.rs +++ /dev/null @@ -1,157 +0,0 @@ -use std::{marker::Unsize, ops::CoerceUnsized, sync::mpsc::Sender}; - -use crate::{ - UiRsc, Widget, - util::{RefCounter, SlotId}, -}; - -pub type WidgetId = SlotId; - -pub struct StrongWidget { - pub(super) id: WidgetId, - counter: RefCounter, - send: Sender, - ty: *const W, -} - -pub struct WeakWidget { - pub(super) id: WidgetId, - #[allow(unused)] - ty: *const W, -} - -impl> StrongWidget { - pub fn any(self) -> StrongWidget { - self - } -} - -impl StrongWidget { - pub(crate) fn new(id: WidgetId, send: Sender) -> Self { - Self { - id, - counter: RefCounter::new(), - send, - ty: null_ptr(), - } - } - - pub fn id(&self) -> WidgetId { - self.id - } - - pub fn refs(&self) -> u32 { - self.counter.refs() - } - - pub fn weak(&self) -> WeakWidget { - let Self { ty, id, .. } = *self; - WeakWidget { ty, id } - } -} - -impl WeakWidget { - pub(crate) fn new(id: WidgetId) -> Self { - Self { id, ty: null_ptr() } - } - - pub fn id(&self) -> WidgetId { - self.id - } - - #[track_caller] - pub fn upgrade(self, ui: &mut impl UiRsc) -> StrongWidget { - ui.widgets_mut().upgrade(self) - } -} - -impl Drop for StrongWidget { - fn drop(&mut self) { - if self.counter.drop() { - let _ = self.send.send(self.id); - } - } -} - -pub trait WidgetIdFn: FnOnce(&mut Rsc) -> WeakWidget {} -impl WeakWidget> WidgetIdFn for F {} - -pub trait IdLike { - type Widget: ?Sized; - fn id(&self) -> WidgetId; -} - -impl IdLike for &StrongWidget { - type Widget = W; - fn id(&self) -> WidgetId { - self.id - } -} - -impl IdLike for StrongWidget { - type Widget = W; - fn id(&self) -> WidgetId { - self.id - } -} - -impl IdLike for WeakWidget { - type Widget = W; - fn id(&self) -> WidgetId { - self.id - } -} - -impl IdLike for WidgetId { - type Widget = dyn Widget; - fn id(&self) -> WidgetId { - *self - } -} - -impl, U: ?Sized> CoerceUnsized> for StrongWidget {} -impl, U: ?Sized> CoerceUnsized> for WeakWidget {} - -impl Clone for WeakWidget { - fn clone(&self) -> Self { - *self - } -} -impl Copy for WeakWidget {} -impl PartialEq for WeakWidget { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - -impl PartialEq for StrongWidget { - fn eq(&self, other: &Self) -> bool { - self.id == other.id - } -} - -impl std::fmt::Debug for StrongWidget { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.id.fmt(f) - } -} - -impl<'a, W: Widget + 'a, State: UiRsc> FnOnce<(&'a mut State,)> for WeakWidget { - type Output = &'a mut W; - - extern "rust-call" fn call_once(self, args: (&'a mut State,)) -> Self::Output { - &mut args.0.widgets_mut()[self] - } -} - -fn null_ptr() -> *const W { - if size_of::<&W>() == size_of::<*const dyn Widget>() { - let w: *const dyn Widget = &(); - unsafe { std::mem::transmute_copy(&w) } - } else { - unsafe { std::mem::transmute_copy(&[0usize; 1]) } - } -} - -unsafe impl Send for WeakWidget {} -unsafe impl Sync for WeakWidget {} diff --git a/iris/core/src/widget/like.rs b/iris/core/src/widget/like.rs deleted file mode 100644 index 5a54371..0000000 --- a/iris/core/src/widget/like.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::UiRsc; - -use super::*; -use std::marker::Unsize; - -pub trait WidgetLike: Sized { - type Widget: Widget + ?Sized + Unsize; - - fn add(self, rsc: &mut Rsc) -> WeakWidget; - - fn add_strong(self, rsc: &mut Rsc) -> StrongWidget { - self.add(rsc).upgrade(rsc) - } - - fn with_id( - self, - f: impl FnOnce(&mut Rsc, WeakWidget) -> WeakWidget, - ) -> impl WidgetIdFn { - move |state| { - let id = self.add(state); - f(state, id) - } - } - - fn set_root(self, rsc: &mut Rsc, root: &mut impl HasRoot) { - let id = self.add_strong(rsc); - root.set_root(rsc, id); - } -} - -pub trait HasRoot { - fn set_root(&mut self, rsc: &mut Rsc, root: StrongWidget); -} - -pub trait WidgetArrLike { - #[track_caller] - fn add(self, state: &mut Rsc) -> WidgetArr; -} - -impl WidgetArrLike for WidgetArr { - fn add(self, _: &mut Rsc) -> WidgetArr { - self - } -} - -macro_rules! impl_widget_arr { - ($n:expr;$($W:ident)*) => { - impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*); - }; - ($n:expr;$($W:ident)*;$($Tag:ident)*) => { - impl,$Tag,)*> WidgetArrLike for ($($W,)*) { - fn add(self, rsc: &mut Rsc) -> WidgetArr<$n> { - #[allow(non_snake_case)] - let ($($W,)*) = self; - WidgetArr::new( - [$($W.add(rsc).upgrade(rsc),)*], - ) - } - } - }; -} - -impl_widget_arr!(1;A); -impl_widget_arr!(2;A B); -impl_widget_arr!(3;A B C); -impl_widget_arr!(4;A B C D); -impl_widget_arr!(5;A B C D E); -impl_widget_arr!(6;A B C D E F); -impl_widget_arr!(7;A B C D E F G); -impl_widget_arr!(8;A B C D E F G H); -impl_widget_arr!(9;A B C D E F G H I); -impl_widget_arr!(10;A B C D E F G H I J); -impl_widget_arr!(11;A B C D E F G H I J K); -impl_widget_arr!(12;A B C D E F G H I J K L); diff --git a/iris/core/src/widget/mod.rs b/iris/core/src/widget/mod.rs deleted file mode 100644 index 54781af..0000000 --- a/iris/core/src/widget/mod.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::{Axis, LayoutLen, Painter, Size}; -use std::any::Any; - -mod data; -mod handle; -mod like; -mod tag; -mod view; -mod widgets; - -pub use data::*; -pub use handle::*; -pub use like::*; -pub use tag::*; -pub use view::*; -pub use widgets::*; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum ChildOrder { - /// The order in which the parent drew its children. - #[default] - Draw, - /// Ascending visual position on one screen axis. Equal positions keep - /// draw order; the resolved coordinates are sorted only when queried. - Axis(Axis), -} - -pub trait Widget: Any { - fn draw(&mut self, painter: &mut Painter); - - fn size_hint(&self, _axis: Axis) -> Option { - None - } - - fn is_size_independent(&self) -> bool { - false - } - - fn requires_exact_region(&self) -> bool { - false - } - - fn access_role(&self) -> accesskit::Role { - accesskit::Role::Unknown - } - - fn child_order(&self) -> ChildOrder { - ChildOrder::Draw - } -} - -impl Widget for () { - fn draw(&mut self, painter: &mut Painter) { - painter.set_size(Size::ZERO); - } - - fn is_size_independent(&self) -> bool { - true - } - - fn size_hint(&self, _axis: Axis) -> Option { - Some(LayoutLen::ZERO) - } -} - -impl dyn Widget { - pub fn as_any(&self) -> &dyn Any { - self - } - - pub fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -pub trait WidgetFn: FnOnce(&mut State) -> W {} -impl W> WidgetFn for F {} - -pub struct WidgetArr { - pub arr: [StrongWidget; LEN], -} - -impl WidgetArr { - pub fn new(arr: [StrongWidget; LEN]) -> Self { - Self { arr } - } -} - -pub trait WidgetOption { - fn get(self, state: &mut State) -> Option; -} - -impl WidgetOption for () { - fn get(self, _: &mut State) -> Option { - None - } -} - -impl Option> WidgetOption for F { - fn get(self, state: &mut State) -> Option { - self(state) - } -} diff --git a/iris/core/src/widget/tag.rs b/iris/core/src/widget/tag.rs deleted file mode 100644 index 1356dac..0000000 --- a/iris/core/src/widget/tag.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::*; -use crate::UiRsc; -use std::marker::Unsize; - -pub struct WidgetTag; -impl WidgetLike for W { - type Widget = W; - fn add(self, rsc: &mut Rsc) -> WeakWidget { - let w = rsc.ui_mut().widgets.add_weak(self); - rsc.on_add(w); - w - } -} - -pub struct FnTag; -impl W> WidgetLike for F { - type Widget = W; - fn add(self, rsc: &mut Rsc) -> WeakWidget { - self(rsc).add(rsc) - } -} - -pub trait WidgetFnTrait { - type Widget: Widget; - fn run(self, rsc: &mut Rsc) -> Self::Widget; -} -pub struct FnTraitTag; -impl> WidgetLike for T { - type Widget = T::Widget; - #[track_caller] - fn add(self, rsc: &mut Rsc) -> WeakWidget { - self.run(rsc).add(rsc) - } -} - -pub struct RefTag; -impl> WidgetLike - for WeakWidget -{ - type Widget = W; - fn add(self, _: &mut Rsc) -> WeakWidget { - self - } -} - -pub struct RefFnTag; -impl, F: FnOnce(&mut Rsc) -> WeakWidget> - WidgetLike for F -{ - type Widget = W; - fn add(self, rsc: &mut Rsc) -> WeakWidget { - self(rsc) - } -} - -pub struct ViewTag; -impl WidgetLike for V { - type Widget = V::Widget; - fn add(self, _: &mut Rsc) -> WeakWidget { - self.root() - } -} - -pub struct ArrTag; diff --git a/iris/core/src/widget/view.rs b/iris/core/src/widget/view.rs deleted file mode 100644 index 19c7eea..0000000 --- a/iris/core/src/widget/view.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::marker::Unsize; - -use crate::{IdLike, WeakWidget, Widget}; - -pub trait WidgetView { - type Widget: Widget + ?Sized + Unsize; - fn root(&self) -> WeakWidget; -} - -pub trait HasWidget { - type Widget: Widget + ?Sized + Unsize; -} - -impl + ?Sized> HasWidget for WeakWidget { - type Widget = W; -} - -impl IdLike for WV { - type Widget = WV::Widget; - - fn id(&self) -> super::WidgetId { - self.root().id - } -} diff --git a/iris/core/src/widget/widgets.rs b/iris/core/src/widget/widgets.rs deleted file mode 100644 index 9b09006..0000000 --- a/iris/core/src/widget/widgets.rs +++ /dev/null @@ -1,151 +0,0 @@ -use std::sync::mpsc::{Receiver, Sender, channel}; - -use crate::{ - IdLike, StrongWidget, WeakWidget, Widget, WidgetData, WidgetId, - util::{DynBorrower, HashSet, SlotVec, forget_mut, to_mut}, -}; - -pub struct Widgets { - pub needs_redraw: HashSet, - vec: SlotVec, - send: Sender, - recv: Receiver, - pub(crate) waiting: HashSet, - named: HashSet, -} - -impl Widgets { - pub fn new() -> Self { - let (send, recv) = channel(); - Self { - needs_redraw: Default::default(), - vec: Default::default(), - waiting: Default::default(), - named: Default::default(), - send, - recv, - } - } - - pub fn has_updates(&self) -> bool { - !self.needs_redraw.is_empty() - } - - pub fn get_dyn(&self, id: WidgetId) -> Option<&dyn Widget> { - Some(self.vec.get(id)?.widget.as_ref()) - } - - pub fn get_dyn_mut(&mut self, id: WidgetId) -> Option<&mut dyn Widget> { - self.needs_redraw.insert(id); - Some(self.vec.get_mut(id)?.widget.as_mut()) - } - - pub(crate) fn get_dyn_dynamic<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> { - // SAFETY: must guarantee no other mutable references to this widget exist - // done through the borrow variable - let data = unsafe { forget_mut(to_mut(self.vec.get(id).unwrap())) }; - if data.borrowed { - panic!("tried to mutably borrow the same widget twice"); - } - WidgetWrapper::new(data.widget.as_mut(), &mut data.borrowed) - } - - pub fn get(&self, id: &I) -> Option<&I::Widget> - where - I::Widget: Sized + Widget, - { - self.get_dyn(id.id())?.as_any().downcast_ref() - } - - pub fn get_mut(&mut self, id: &I) -> Option<&mut I::Widget> - where - I::Widget: Sized + Widget, - { - self.get_dyn_mut(id.id())?.as_any_mut().downcast_mut() - } - - pub fn add_strong(&mut self, widget: W) -> StrongWidget { - let id = self.vec.add(WidgetData::new(widget)); - StrongWidget::new(id, self.send.clone()) - } - - pub fn add_weak(&mut self, widget: W) -> WeakWidget { - let id = self.vec.add(WidgetData::new(widget)); - self.waiting.insert(id); - WeakWidget::new(id) - } - - #[track_caller] - pub fn upgrade(&mut self, rf: WeakWidget) -> StrongWidget { - if !self.waiting.remove(&rf.id()) { - let label = self.label(rf); - let id = rf.id(); - panic!( - "widget '{label}' ({id:?}) was already added\ncannot add a widget twice; consider creating two" - ) - } - StrongWidget::new(rf.id(), self.send.clone()) - } - - pub fn data(&self, id: impl IdLike) -> Option<&WidgetData> { - self.vec.get(id.id()) - } - - pub fn label(&self, id: impl IdLike) -> &String { - &self.data(id.id()).unwrap().label - } - - pub fn set_label(&mut self, id: impl IdLike, label: String) { - let id = id.id(); - self.data_mut(id).unwrap().label = label; - self.named.insert(id); - } - - pub fn named(&self) -> impl Iterator + '_ { - self.named.iter().copied() - } - - pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> { - self.vec.get_mut(id.id()) - } - - pub fn free_next(&mut self) -> Option { - let next = self.recv.try_recv().ok()?; - self.vec.free(next); - self.named.remove(&next); - Some(next) - } - - #[allow(clippy::len_without_is_empty)] - pub fn len(&self) -> usize { - self.vec.len() - } -} - -impl Default for Widgets { - fn default() -> Self { - Self::new() - } -} - -pub type WidgetWrapper<'a> = DynBorrower<'a, dyn Widget>; - -impl std::ops::Index for Widgets -where - I::Widget: Sized + Widget, -{ - type Output = I::Widget; - - fn index(&self, id: I) -> &Self::Output { - self.get(&id).unwrap() - } -} - -impl std::ops::IndexMut for Widgets -where - I::Widget: Sized + Widget, -{ - fn index_mut(&mut self, id: I) -> &mut Self::Output { - self.get_mut(&id).unwrap() - } -} diff --git a/iris/examples/bench_images/android.rs b/iris/examples/bench_images/android.rs deleted file mode 100644 index d561294..0000000 --- a/iris/examples/bench_images/android.rs +++ /dev/null @@ -1,12 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - let _ = app::build(rsc, ui_state); -} diff --git a/iris/examples/bench_images/desktop.rs b/iris/examples/bench_images/desktop.rs deleted file mode 100644 index e139028..0000000 --- a/iris/examples/bench_images/desktop.rs +++ /dev/null @@ -1,61 +0,0 @@ -use iris::prelude::*; -use winit::event::WindowEvent; - -#[path = "lib.rs"] -mod app; - -const SETTLE_FRAMES: usize = 4; -const FRAMES: usize = 6; - -#[derive(DesktopUiState)] -struct State { - ui_state: DesktopUiState, - span: WeakWidget, - frame: usize, - appended: bool, -} - -impl DesktopAppState for State { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { - let span = app::build(rsc, &mut ui_state); - Self { - ui_state, - span, - frame: 0, - appended: false, - } - } - - fn window_event(&mut self, event: WindowEvent, rsc: &mut StdRsc) { - if !matches!(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 = iris::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() { - DesktopApp::::run(); -} diff --git a/iris/examples/bench_images/lib.rs b/iris/examples/bench_images/lib.rs deleted file mode 100644 index 15d3e25..0000000 --- a/iris/examples/bench_images/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -use iris::prelude::*; - -const ROWS: usize = 1000; - -pub(crate) fn build( - rsc: &mut Rsc, - ui_state: &mut impl HasRoot, -) -> WeakWidget { - let mut span = Span::empty(Dir::DOWN); - for _ in 0..ROWS { - let img = iris::image::DynamicImage::new_rgba8(32, 32); - let widget = image::(img)(rsc); - let widget = rsc.ui_mut().widgets.add_strong(widget); - span.push(widget.any()); - } - let span = rsc.ui_mut().widgets.add_strong(span); - let span_weak = span.weak(); - let root = rsc - .ui_mut() - .widgets - .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::End)); - ui_state.set_root(rsc, root.any()); - span_weak -} diff --git a/iris/examples/message_list/android.rs b/iris/examples/message_list/android.rs deleted file mode 100644 index d6d5b5f..0000000 --- a/iris/examples/message_list/android.rs +++ /dev/null @@ -1,12 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - app::build(rsc, ui_state); -} diff --git a/iris/examples/message_list/desktop.rs b/iris/examples/message_list/desktop.rs deleted file mode 100644 index 1d6a6a9..0000000 --- a/iris/examples/message_list/desktop.rs +++ /dev/null @@ -1,10 +0,0 @@ -use iris::prelude::*; -use winit::{dpi::LogicalSize, window::WindowAttributes}; - -#[path = "lib.rs"] -mod app; - -fn main() { - let attributes = WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)); - DesktopApp::run_with_attributes(attributes, app::build); -} diff --git a/iris/examples/message_list/lib.rs b/iris/examples/message_list/lib.rs deleted file mode 100644 index bfdf231..0000000 --- a/iris/examples/message_list/lib.rs +++ /dev/null @@ -1,64 +0,0 @@ -use iris::prelude::*; - -const ROWS: usize = 800; -const IMAGE_EVERY: usize = 12; - -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)) -} - -fn row_image(i: usize) -> iris::image::DynamicImage { - let hue = ((i * 47) % 255) as u8; - iris::image::RgbaImage::from_pixel(48, 48, iris::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) { - Srgba8::rgb(120, 130, 170) - } else { - Srgba8::rgb(70, 80, 140) - }; - let text_color = PaintId::BLACK; - if i.is_multiple_of(IMAGE_EVERY) { - let text = wtext(row_text(i)) - .overflow(TextOverflow::Wrap) - .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)) - .overflow(TextOverflow::Wrap) - .color(text_color) - .pad(dp(8.0)) - .background(rect(tint)) - .add_strong(rsc) - .any() - } -} - -pub(crate) fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) { - 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)); - } - - let root = list - .scrollable() - .masked() - .background(rect(PaintId::WHITE)) - .add_strong(rsc); - ui_state.set_root(rsc, root.any()); -} diff --git a/iris/examples/minimal/android.rs b/iris/examples/minimal/android.rs deleted file mode 100644 index d6d5b5f..0000000 --- a/iris/examples/minimal/android.rs +++ /dev/null @@ -1,12 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - app::build(rsc, ui_state); -} diff --git a/iris/examples/minimal/desktop.rs b/iris/examples/minimal/desktop.rs deleted file mode 100644 index 63fade8..0000000 --- a/iris/examples/minimal/desktop.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -fn main() { - DesktopApp::run_with(app::build); -} diff --git a/iris/examples/minimal/lib.rs b/iris/examples/minimal/lib.rs deleted file mode 100644 index 334a542..0000000 --- a/iris/examples/minimal/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -use iris::prelude::*; - -pub(crate) fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) { - rect(PaintId::RED).set_root(rsc, ui_state); -} diff --git a/iris/examples/tabs/android.rs b/iris/examples/tabs/android.rs deleted file mode 100644 index 6430598..0000000 --- a/iris/examples/tabs/android.rs +++ /dev/null @@ -1,33 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[derive(AndroidUiState)] -struct Client { - ui_state: AndroidUiState, - info: WeakWidget, -} - -impl AndroidAppState for Client { - type Resources = StdRsc; - - fn on_insets_changed(&mut self, rsc: &mut Self::Resources, _: WindowInsets) { - let views = self - .ui_state - .renderer - .as_ref() - .map_or(0, |renderer| renderer.ui.view_count()); - app::update_info(rsc, self.info, views); - } -} - -#[iris::android_init] -fn create(mut ui_state: AndroidUiState, rsc: &mut StdRsc) -> Client { - let widgets = app::build(rsc, &mut ui_state); - app::update_info(rsc, widgets.info, 0); - Client { - ui_state, - info: widgets.info, - } -} diff --git a/iris/examples/tabs/assets/sungals.png b/iris/examples/tabs/assets/sungals.png deleted file mode 100644 index 0070de2..0000000 Binary files a/iris/examples/tabs/assets/sungals.png and /dev/null differ diff --git a/iris/examples/tabs/desktop.rs b/iris/examples/tabs/desktop.rs deleted file mode 100644 index be2b18c..0000000 --- a/iris/examples/tabs/desktop.rs +++ /dev/null @@ -1,30 +0,0 @@ -use iris::prelude::*; -use winit::event::WindowEvent; - -#[path = "lib.rs"] -mod app; - -#[derive(DesktopUiState)] -struct Client { - ui_state: DesktopUiState, - info: WeakWidget, -} - -impl DesktopAppState for Client { - fn new(mut ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self { - let widgets = app::build(rsc, &mut ui_state); - app::update_info(rsc, widgets.info, 0); - Self { - ui_state, - info: widgets.info, - } - } - - fn window_event(&mut self, _: WindowEvent, rsc: &mut StdRsc) { - app::update_info(rsc, self.info, self.ui_state.renderer.ui.view_count()); - } -} - -fn main() { - DesktopApp::::run(); -} diff --git a/iris/examples/tabs/lib.rs b/iris/examples/tabs/lib.rs deleted file mode 100644 index 0e24f01..0000000 --- a/iris/examples/tabs/lib.rs +++ /dev/null @@ -1,224 +0,0 @@ -use iris::prelude::*; -use std::{cell::RefCell, rc::Rc}; - -pub(crate) fn update_info(rsc: &mut Rsc, info: WeakWidget, views: usize) { - let render_state = rsc.ui().render_state(); - let new = format!( - "widgets: {}\nactive: {}\nviews: {views}", - rsc.widgets().len(), - render_state.get().active_widgets(), - ); - if new != rsc.widgets()[info].content() { - rsc.widgets_mut()[info].set_text(new); - } -} - -pub(crate) struct ClientWidgets { - pub(crate) info: WeakWidget, -} - -pub(crate) fn build( - rsc: &mut Rsc, - ui_state: &mut impl HasRoot, -) -> ClientWidgets -where - Rsc::State: FocusHost, -{ - let rrect = rect(PaintId::WHITE).radius(20); - let pad_test = ( - rrect.clone().color(PaintId::BLUE), - ( - rrect - .clone() - .color(PaintId::RED) - .sized((100, 100)) - .center() - .width(rest(2)), - ( - rrect.clone().color(PaintId::ORANGE), - rrect.clone().color(PaintId::LIME).pad(10.0), - ) - .span(Dir::RIGHT) - .width(rest(2)), - rrect.clone().color(PaintId::YELLOW), - ) - .span(Dir::RIGHT) - .pad(10) - .width(rest(3)), - ) - .span(Dir::RIGHT) - .add(rsc); - - let span_test = ( - rrect.clone().color(PaintId::GREEN).width(100), - rrect.clone().color(PaintId::ORANGE), - rrect.clone().color(PaintId::CYAN), - rrect.clone().color(PaintId::BLUE).width(rel(0.5)), - rrect.clone().color(PaintId::MAGENTA).width(100), - rrect.color(PaintId::RED).width(100), - ) - .span(Dir::LEFT) - .add(rsc); - - let span_add = Span::empty(Dir::RIGHT).add(rsc); - - let add_button = rect(PaintId::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(PaintId::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(MONOSPACE).align(Align::TOP), - btext("'").family(MONOSPACE), - btext(":gamer mode").family(MONOSPACE), - rect(PaintId::CYAN).sized((10, 10)).center(), - rect(PaintId::RED).sized((100, 100)).center(), - rect(PaintId::PURPLE).sized((50, 50)).align(Align::TOP), - ) - .span(Dir::RIGHT) - .compact() - .center(), - wtext("pretty cool right?").size(50), - ) - .span(Dir::DOWN) - .compact() - .add(rsc); - - let texts = Span::empty(Dir::DOWN).gap(10).add(rsc); - let msg_area = texts - .scrollable(Axis::Y, Pin::Start) - .masked() - .background(rect(PaintId::SKY)); - let add_text = wtext("add") - .editable(EditMode::MultiLine) - .text_align(Align::LEFT) - .size(30) - .attr::(()) - .on(Submit, move |ctx, rsc: &mut Rsc| { - let w = ctx.widget; - let content = w(rsc).take(); - let text = wtext(content) - .editable(EditMode::MultiLine) - .size(30) - .text_align(Align::LEFT) - .overflow(TextOverflow::Wrap) - .attr::(()); - let fill = rsc - .ui_mut() - .paints - .add(Srgba8::WHITE.to_linear().darker(0.5)); - let msg_box = text.background(rect(fill)).add_strong(rsc); - texts(rsc).push(msg_box); - }) - .add(rsc); - - let text_edit_scroll = ( - msg_area.height(rest(1)), - ( - Rect::new( - rsc.ui_mut() - .paints - .add(Srgba8::WHITE.to_linear().darker(0.9)), - ), - ( - add_text.width(rest(1)), - Rect::new(PaintId::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 = |solid: Srgba8, to: WeakWidget, label| { - let value = solid.to_linear(); - let paint = rsc.ui_mut().paints.add(value); - 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 pressed = paint.clone(); - let hovered = paint.clone(); - let normal = paint.clone(); - let rect = rect(paint) - .on(CursorSense::click(), move |_ctx, rsc: &mut Rsc| { - let (prev, vec) = &mut *vals.borrow_mut(); - if let Some(h) = vec[i].take() { - vec[*prev] = main(rsc).replace(h); - *prev = i; - } - rsc.ui_mut().paints.set(&pressed, value.darker(0.3)); - }) - .on( - CursorSense::HoverStart | CursorSense::unclick(), - move |_ctx, rsc: &mut Rsc| { - rsc.ui_mut().paints.set(&hovered, value.brighter(0.2)); - }, - ) - .on(CursorSense::HoverEnd, move |_ctx, rsc: &mut Rsc| { - rsc.ui_mut().paints.set(&normal, value); - }) - .label(label); - (rect, wtext(label).size(30).text_align(Align::CENTER)).stack() - }; - - let tabs = ( - switch_button(Srgba8::RED, pad_test, "pad"), - switch_button(Srgba8::GREEN, span_test, "span"), - switch_button(Srgba8::BLUE, span_add_test, "image span"), - switch_button(Srgba8::MAGENTA, text_test, "text layout"), - switch_button(Srgba8::YELLOW, 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/iris/examples/task/android.rs b/iris/examples/task/android.rs deleted file mode 100644 index d6d5b5f..0000000 --- a/iris/examples/task/android.rs +++ /dev/null @@ -1,12 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - app::build(rsc, ui_state); -} diff --git a/iris/examples/task/desktop.rs b/iris/examples/task/desktop.rs deleted file mode 100644 index 63fade8..0000000 --- a/iris/examples/task/desktop.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -fn main() { - DesktopApp::run_with(app::build); -} diff --git a/iris/examples/task/lib.rs b/iris/examples/task/lib.rs deleted file mode 100644 index 1942f8d..0000000 --- a/iris/examples/task/lib.rs +++ /dev/null @@ -1,22 +0,0 @@ -use iris::prelude::*; -use std::time::Duration; - -pub(crate) fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -where - Rsc::State: FocusHost, -{ - let rect = rect(PaintId::RED).add(rsc); - rect.label("Toggle color") - .task_on(CursorSense::click(), async move |mut ctx| { - iris::task::sleep(Duration::from_secs(1)).await; - ctx.update(move |_, rsc| { - let rect = rect(rsc); - if rect.is_paint(&PaintId::RED) { - rect.set_paint(PaintId::BLUE); - } else { - rect.set_paint(PaintId::RED); - } - }); - }) - .set_root(rsc, ui_state); -} diff --git a/iris/examples/text/android.rs b/iris/examples/text/android.rs deleted file mode 100644 index 8b188f7..0000000 --- a/iris/examples/text/android.rs +++ /dev/null @@ -1,9 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create(ui_state: &mut AndroidUiState, rsc: &mut StdRsc) { - app::build(rsc, ui_state); -} diff --git a/iris/examples/text/desktop.rs b/iris/examples/text/desktop.rs deleted file mode 100644 index 63fade8..0000000 --- a/iris/examples/text/desktop.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -fn main() { - DesktopApp::run_with(app::build); -} diff --git a/iris/examples/text/lib.rs b/iris/examples/text/lib.rs deleted file mode 100644 index 42c1312..0000000 --- a/iris/examples/text/lib.rs +++ /dev/null @@ -1,156 +0,0 @@ -use iris::prelude::*; - -const SAMPLE: &str = "The quick brown fox jumps over the lazy dog"; - -fn overflow_row( - rsc: &mut Rsc, - label: &str, - overflow: TextOverflow, - position: Len, - fill: PaintId, -) -> WeakWidget { - ( - wtext(label).size(14).color(PaintId::GRAY).width(dp(76)), - wtext(SAMPLE) - .size(20) - .overflow(overflow) - .overflow_position(position) - .width(dp(260)) - .background(rect(fill)), - ) - .span(Dir::RIGHT) - .gap(dp(8)) - .width(dp(344)) - .add(rsc) -} - -pub(crate) fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -where - Rsc::State: FocusHost, -{ - let panel = rsc - .ui_mut() - .paints - .add(Srgba8::new(34, 36, 42, 255).to_linear()); - let field = rsc - .ui_mut() - .paints - .add(Srgba8::new(53, 57, 66, 255).to_linear()); - - let styled = "Bold, italic, underlined, and colored spans"; - let styled = wtext(styled).size(20).spans(vec![ - SpanStyle::new(0..4).bold(), - SpanStyle::new(6..12).italic(), - SpanStyle::new(14..24).underline(), - SpanStyle::new(30..37).color(PaintId::SKY), - ]); - - let aligned = ( - wtext("Left aligned").text_align(Align::CENTER_LEFT), - wtext("Centered").text_align(Align::CENTER), - wtext("Right aligned").text_align(Align::CENTER_RIGHT), - ) - .span(Dir::DOWN) - .gap(dp(4)) - .width(rest(1)) - .background(rect(field.clone())); - - let wrapped = wtext( - "Wrapping shapes the same source into as many lines as its container needs. Resize the window to see it reflow.", - ) - .overflow(TextOverflow::Wrap) - .size(18) - .width(dp(340)) - .background(rect(field.clone())); - - let editable = wtext(SAMPLE) - .overflow(TextOverflow::Ellipsis) - .editable(EditMode::SingleLine) - .size(20) - .attr::(()) - .width(dp(344)) - .background(rect(field.clone())); - - let intro = ( - wtext("Iris text") - .size(30) - .spans(vec![SpanStyle::new(0..9).bold()]), - wtext("Drag across display text to select it. The overflow markers select hidden source text, but are never copied.") - .overflow(TextOverflow::Wrap) - .color(PaintId::GRAY), - ) - .span(Dir::DOWN) - .gap(dp(6)) - .add(rsc); - let styles = ( - wtext("Styles and alignment").size(16).color(PaintId::SKY), - styled, - aligned, - ) - .span(Dir::DOWN) - .gap(dp(6)) - .add(rsc); - let wrapping = (wtext("Wrapping").size(16).color(PaintId::SKY), wrapped) - .span(Dir::DOWN) - .gap(dp(6)) - .add(rsc); - let overflow = ( - wtext("Overflow treatment and position") - .size(16) - .color(PaintId::SKY), - overflow_row(rsc, "hidden", TextOverflow::Hidden, rel(0), field.clone()), - overflow_row( - rsc, - "ellipsis 0", - TextOverflow::Ellipsis, - rel(0), - field.clone(), - ), - overflow_row( - rsc, - "ellipsis .5", - TextOverflow::Ellipsis, - rel(0.5), - field.clone(), - ), - overflow_row( - rsc, - "ellipsis 1", - TextOverflow::Ellipsis, - rel(1), - field.clone(), - ), - ) - .span(Dir::DOWN) - .gap(dp(6)) - .add(rsc); - let editing = ( - wtext("Editable ellipsis (move the caret through the text)") - .size(16) - .color(PaintId::SKY), - editable, - ) - .span(Dir::DOWN) - .gap(dp(6)) - .add(rsc); - - let content = (intro, styles, wrapping, overflow, editing) - .span(Dir::DOWN) - .gap(dp(14)) - .controller(SelectionController::new().separator("\n")) - .add(rsc); - - content - .on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| { - let input = &ctx.data; - rsc.with_nearest_controller::(content, |id, selection, rsc| { - selection.drag(id, rsc, input) - }); - }) - .add(rsc); - content - .pad(dp(24)) - .width(rest(1)) - .background(rect(panel)) - .set_root(rsc, ui_state); -} diff --git a/iris/examples/view/android.rs b/iris/examples/view/android.rs deleted file mode 100644 index d6d5b5f..0000000 --- a/iris/examples/view/android.rs +++ /dev/null @@ -1,12 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - app::build(rsc, ui_state); -} diff --git a/iris/examples/view/desktop.rs b/iris/examples/view/desktop.rs deleted file mode 100644 index 63fade8..0000000 --- a/iris/examples/view/desktop.rs +++ /dev/null @@ -1,8 +0,0 @@ -use iris::prelude::*; - -#[path = "lib.rs"] -mod app; - -fn main() { - DesktopApp::run_with(app::build); -} diff --git a/iris/examples/view/lib.rs b/iris/examples/view/lib.rs deleted file mode 100644 index 16f4846..0000000 --- a/iris/examples/view/lib.rs +++ /dev/null @@ -1,36 +0,0 @@ -use iris::prelude::*; - -#[derive(Clone, Copy, WidgetView)] -struct Test { - #[root] - root: WeakWidget, - cur: WeakState, -} - -impl Test { - pub fn new(rsc: &mut StdRsc) -> Self { - let root = rect(PaintId::RED).add(rsc); - let cur = rsc.create_state(root, false); - Self { root, cur } - } - pub fn toggle(&self, rsc: &mut StdRsc) { - let cur = &mut rsc[self.cur]; - *cur = !*cur; - if *cur { - rsc[self.root].set_paint(PaintId::BLUE); - } else { - rsc[self.root].set_paint(PaintId::RED); - } - } -} - -pub(crate) fn build(rsc: &mut StdRsc, ui_state: &mut impl HasRoot>) -where - State: FocusHost, -{ - let test = Test::new(rsc); - test.on(CursorSense::click(), move |_, rsc| { - test.toggle(rsc); - }) - .set_root(rsc, ui_state); -} diff --git a/iris/macro/Cargo.toml b/iris/macro/Cargo.toml deleted file mode 100644 index cdcc486..0000000 --- a/iris/macro/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "iris-macro" -version.workspace = true -edition.workspace = true - -[dependencies] -proc-macro2 = "1.0.107" -quote = "1.0.47" -syn = { version = "3.0.5", features = ["full"] } - -[lib] -proc-macro = true diff --git a/iris/macro/src/lib.rs b/iris/macro/src/lib.rs deleted file mode 100644 index 382b1e2..0000000 --- a/iris/macro/src/lib.rs +++ /dev/null @@ -1,358 +0,0 @@ -extern crate proc_macro; -use proc_macro::TokenStream; -use quote::quote; -use syn::{ - Attribute, Block, Error, FnArg, GenericParam, Generics, Ident, ItemFn, ItemStruct, ItemTrait, - ReturnType, Signature, Token, Type, Visibility, - parse::{Parse, ParseStream, Result}, - parse_macro_input, parse_quote, - spanned::Spanned, -}; - -/// Marks the initializer called when Android creates an Iris view. -/// -/// An attribute is necessary here because the Android loader requires one -/// exported `JNI_OnLoad` symbol and `android-view` requires a plain function -/// pointer monomorphized for the application state. A function returning a -/// custom state remains its factory; a function with no return value receives -/// `&mut AndroidUiState` and uses that state directly. The generated linker and -/// JNI glue is Android-gated; the annotated function therefore does not need -/// its own `cfg` attribute. -#[proc_macro_attribute] -pub fn android_init(args: TokenStream, item: TokenStream) -> TokenStream { - if !args.is_empty() { - return Error::new( - proc_macro2::Span::call_site(), - "android_init takes no arguments", - ) - .into_compile_error() - .into(); - } - - let function = parse_macro_input!(item as ItemFn); - let name = &function.sig.ident; - let (state, direct_initializer): (Type, bool) = match &function.sig.output { - ReturnType::Default => (parse_quote!(::iris::android::AndroidUiState), true), - ReturnType::Type(_, state) => ((**state).clone(), false), - }; - if function.sig.inputs.len() != 2 - || function - .sig - .inputs - .iter() - .any(|argument| !matches!(argument, FnArg::Typed(_))) - { - return Error::new( - function.sig.inputs.span(), - "an android_init function takes UI state and resources", - ) - .into_compile_error() - .into(); - } - if function.sig.asyncness.is_some() - || function.sig.constness.is_some() - || matches!(function.sig.safety, syn::Safety::Unsafe(_)) - || !function.sig.generics.params.is_empty() - { - return Error::new( - function.sig.span(), - "an android_init function must be a plain, non-generic synchronous function", - ) - .into_compile_error() - .into(); - } - - let factory = if direct_initializer { - quote! { - fn init( - mut ui_state: ::iris::android::AndroidUiState, - rsc: &mut <#state as ::iris::android::AndroidAppState>::Resources, - ) -> #state { - super::#name(&mut ui_state, rsc); - ui_state - } - } - } else { - quote! {} - }; - let create = if direct_initializer { - quote! { init } - } else { - quote! { super::#name } - }; - - quote! { - #[cfg(target_os = "android")] - #function - - #[cfg(target_os = "android")] - mod __iris_android_app { - use super::*; - - #factory - - extern "system" fn new_view_peer<'local>( - env: ::iris::android::__private::JNIEnv<'local>, - view: ::iris::android::__private::View<'local>, - context: ::iris::android::__private::Context<'local>, - ) -> ::iris::android::__private::JLong { - ::iris::android::new_peer::<#state>(env, view, context, #create) - } - - #[unsafe(no_mangle)] - pub unsafe extern "system" fn JNI_OnLoad( - vm: *mut ::iris::android::__private::RawJavaVM, - _: *mut ::core::ffi::c_void, - ) -> ::iris::android::__private::JInt { - unsafe { ::iris::android::__private::on_load(vm, new_view_peer) } - } - } - } - .into() -} - -struct Input { - attrs: Vec, - vis: Visibility, - name: Ident, - generics: Generics, - fns: Vec, -} - -struct InputFn { - attrs: Vec, - sig: Signature, - body: Block, -} - -impl Parse for Input { - fn parse(input: ParseStream) -> Result { - let attrs = input.call(Attribute::parse_outer)?; - let vis = input.parse()?; - input.parse::()?; - let name = input.parse()?; - let generics = input.parse::()?; - 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 { attrs, sig, body }) - } - if !input.is_empty() { - input.error("function expected"); - } - Ok(Input { - attrs, - vis, - name, - generics, - fns, - }) - } -} - -#[proc_macro] -pub fn widget_trait(input: TokenStream) -> TokenStream { - let Input { - attrs, - vis, - name, - mut generics, - fns, - } = parse_macro_input!(input as Input); - - 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 }) - .collect(); - - let Some(GenericParam::Type(state)) = generics.params.first() else { - return Error::new(name.span(), "expected state generic parameter") - .into_compile_error() - .into(); - }; - - let state = &state.ident; - - generics - .params - .push(parse_quote!(WL: WidgetLike<#state, Tag>)); - generics.params.push(parse_quote!(Tag)); - - let mut trai: ItemTrait = parse_quote!( - #vis trait #name #generics { - #(#sigs;)* - } - ); - - trai.attrs = attrs; - - quote! { - #trai - - impl #generics #name for WL { - #(#impls)* - } - } - .into() -} - -#[proc_macro_derive(DesktopUiState, attributes(desktop_ui_state))] -pub fn derive_desktop_ui_state(input: TokenStream) -> TokenStream { - let state: ItemStruct = parse_macro_input!(input); - derive_ui_state( - state, - UiStateDerive { - module: "desktop", - state_type: "DesktopUiState", - state_trait: "HasDesktopUiState", - field_attr: "desktop_ui_state", - get: "desktop_state", - get_mut: "desktop_state_mut", - }, - ) -} - -#[proc_macro_derive(AndroidUiState, attributes(android_ui_state))] -pub fn derive_android_ui_state(input: TokenStream) -> TokenStream { - let state: ItemStruct = parse_macro_input!(input); - derive_ui_state( - state, - UiStateDerive { - module: "android", - state_type: "AndroidUiState", - state_trait: "HasAndroidUiState", - field_attr: "android_ui_state", - get: "android_state", - get_mut: "android_state_mut", - }, - ) -} - -struct UiStateDerive { - module: &'static str, - state_type: &'static str, - state_trait: &'static str, - field_attr: &'static str, - get: &'static str, - get_mut: &'static str, -} - -fn derive_ui_state(state: ItemStruct, names: UiStateDerive) -> TokenStream { - let UiStateDerive { - module, - state_type, - state_trait, - field_attr, - get, - get_mut, - } = names; - let mut output = proc_macro2::TokenStream::new(); - let mut found_attr = false; - let mut state_field = None; - for field in &state.fields { - if !found_attr - && let Type::Path(path) = &field.ty - && path.path.is_ident(state_type) - { - state_field = Some(field); - } - let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident(field_attr)) else { - continue; - }; - if found_attr { - output.extend( - Error::new( - attr.span(), - format!("cannot have more than one {field_attr} attribute"), - ) - .into_compile_error(), - ); - continue; - } - found_attr = true; - state_field = Some(field); - } - let Some(field) = state_field else { - output.extend( - Error::new(state.ident.span(), format!("no {state_type} field found")) - .into_compile_error(), - ); - return output.into(); - }; - let sname = &state.ident; - let Some(fname) = field.ident.as_ref() else { - return Error::new( - field.span(), - format!("the {state_type} field must be named"), - ) - .into_compile_error() - .into(); - }; - let module = Ident::new(module, sname.span()); - let state_type = Ident::new(state_type, sname.span()); - let state_trait = Ident::new(state_trait, sname.span()); - let get = Ident::new(get, sname.span()); - let get_mut = Ident::new(get_mut, sname.span()); - let (impl_generics, type_generics, where_clause) = state.generics.split_for_impl(); - output.extend(quote! { - impl #impl_generics iris::#module::#state_trait for #sname #type_generics #where_clause { - fn #get(&self) -> &iris::#module::#state_type { - &self.#fname - } - fn #get_mut(&mut self) -> &mut iris::#module::#state_type { - &mut self.#fname - } - } - }); - output.into() -} - -#[proc_macro_derive(WidgetView, attributes(root))] -pub fn derive_widget_view(input: TokenStream) -> TokenStream { - let mut output = proc_macro2::TokenStream::new(); - - let state: ItemStruct = parse_macro_input!(input); - - let mut found_attr = false; - let mut state_field = None; - for field in &state.fields { - let Some(attr) = field.attrs.iter().find(|a| a.path().is_ident("root")) else { - continue; - }; - if found_attr { - output.extend( - Error::new(attr.span(), "cannot have more than one root widget") - .into_compile_error(), - ); - continue; - } - found_attr = true; - state_field = Some(field); - } - let Some(field) = state_field else { - output.extend( - Error::new(state.ident.span(), "no root widget field found (#[root])") - .into_compile_error(), - ); - return output.into(); - }; - let sname = &state.ident; - let fname = field.ident.as_ref().unwrap(); - let fty = &field.ty; - output.extend(quote! { - impl iris::core::WidgetView for #sname { - type Widget = <#fty as iris::core::HasWidget>::Widget; - fn root(&self) -> #fty { - self.#fname - } - } - }); - output.into() -} diff --git a/iris/readme.md b/iris/readme.md deleted file mode 100644 index 99d48d2..0000000 --- a/iris/readme.md +++ /dev/null @@ -1,130 +0,0 @@ -# iris - -My experimental attempt at a rust ui library (also my first ui library). - -It's currently designed around using retained data structures (widgets), rather than diffing generated trees from data like xilem or iced. This is an experiment and I'm not sure if it's a good idea or not. - -Examples are in `examples`, eg. `cargo run --example tabs`. Each example keeps -its widget tree in `lib.rs` and its small desktop and Android hosts in -`desktop.rs` and `android.rs`. - -## Android applications - -An Android application is a library because Android loads its Rust code as a -native shared library: - -```toml -[lib] -crate-type = ["cdylib", "rlib"] - -[dependencies] -iris = { path = "../iris" } - -[package.metadata.iris.android] -application-id = "com.example.myapp" -label = "My app" -``` - -`#[iris::android_init]` marks the initializer called when Android creates the Iris -view. The attribute supplies its own Android target gate and generates the JNI -loader glue. An application with no state beyond the UI state receives -`AndroidUiState` directly by mutable reference: - -```rust -use iris::prelude::*; - -fn build(rsc: &mut Rsc, ui_state: &mut impl HasRoot) { - rect(PaintId::RED).set_root(rsc, ui_state); -} - -#[iris::android_init] -fn create( - ui_state: &mut AndroidUiState, - rsc: &mut StdRsc, -) { - build(rsc, ui_state); -} -``` - -Desktop has the corresponding initializer form: - -```rust -DesktopApp::run_with(build); -``` - -Background work updates either host through the same task context. An update -wakes the UI thread; Iris schedules a frame automatically if the closure made -the retained widget tree dirty: - -```rust -rsc.spawn_task(async move |mut ctx| { - let text = load_text().await; - ctx.update(move |_, rsc| label(rsc).set(&text)); -}); -``` - -Applications do not need a winit event proxy or an explicit redraw request. - -Applications with additional fields use their own state type. Its -`AndroidAppState::Resources` associated type can also replace `StdRsc` with a -custom resource bundle. - -Install the Cargo subcommand from a checkout, then invoke it from the -application's directory: - -```sh -cargo install --path /path/to/iris/cargo-iris -cargo iris apk --abi arm64-v8a -cargo iris run --abi x86_64 --device emulator-5554 -``` - -Package examples use the same command with `--example`: - -```sh -cargo run --example tabs -cargo iris apk --example tabs --abi arm64-v8a -cargo iris run --example tabs --abi x86_64 --device emulator-5554 -``` - -`cargo iris` packages directly with `cargo-ndk`, `javac`, `d8`, `aapt2`, -`jar`, `zipalign`, and `apksigner`; it does not require Gradle. The caller -provides a JDK, Android SDK and NDK, and any emulator or physical device. Set -`ANDROID_HOME` to the SDK. `run` always requires an explicit device and never -creates or starts one. - -Debug APKs use the standard key at `~/.android/debug.keystore`, creating it -with `keytool` when absent. A release build requires the long-lived signing -identity explicitly: - -```sh -IRIS_KEYSTORE_PASSWORD=... IRIS_KEY_PASSWORD=... \ - cargo iris apk --release --keystore /secure/upload.jks --key-alias upload -``` - -The verified APK lives under -`target/iris-android/[-]///`; packaging -intermediates are removed before the command prints its absolute path. - -Goals, in general order: -1. does what I want it to (text, images, video, animations) -2. very easy to use ignoring ergonomic ref counting -3. reasonably fast / efficient (a lot faster than electron, save battery life, try to beat iced and xilem) - -## dev details - -not targeting web rn cause wanna use actual nice gpu features & entire point of this is to make desktop apps / not need a web browser - -general ideas trynna use rn / experiment with: -- retained mode -- specifically designed around wgpu so there's no translation -- postfix functions for most things to prevent unreadable indentation (going very well) -- events can be done directly where you draw the widgets -- almost no macros in user code & actual LSP typechecking (variadic generics if you can hear me please save us) -- relative anchor + absolute offset coord system (+ "rest" / leftover during widget layout) -- single threaded ui & pass context around to make non async usage straightforward (pretty unsure about this) -- widgets store outside of the actual rendering so they can be moved around and swapped easily (unsure about this but seems to work good for now) - -under heavy initial development so not gonna try to explain status, maybe check TODO for that; -sizable chance it gets a rewrite once I know everything I need and what seems to work best - -it's called iris because it's the structure around what you actually want to display and colorful diff --git a/iris/rig-input/Cargo.toml b/iris/rig-input/Cargo.toml deleted file mode 100644 index 715580d..0000000 --- a/iris/rig-input/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "rig-input" -version.workspace = true -edition.workspace = true - -# Replays harness `.touch` files through Wayland's virtual-pointer protocol; -# headless sway has no input devices for coordinate-driving tools to move. -[[bin]] -name = "replay-touch" -path = "src/main.rs" - -[dependencies] -# Share the harness parser so both layers interpret recordings identically. -iris = { path = ".." } -wayland-client = "0.31.15" -wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } diff --git a/iris/rig-input/src/main.rs b/iris/rig-input/src/main.rs deleted file mode 100644 index cf5918a..0000000 --- a/iris/rig-input/src/main.rs +++ /dev/null @@ -1,141 +0,0 @@ -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, -}; - -const BTN_LEFT: u32 = 0x110; - -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); - 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/iris/rust-toolchain.toml b/iris/rust-toolchain.toml deleted file mode 100644 index 303e39c..0000000 --- a/iris/rust-toolchain.toml +++ /dev/null @@ -1,4 +0,0 @@ -[toolchain] -channel = "nightly" -components = ["clippy", "rustfmt"] -targets = ["aarch64-linux-android", "x86_64-linux-android"] diff --git a/iris/scripts/headless.conf b/iris/scripts/headless.conf deleted file mode 100644 index 71abd5a..0000000 --- a/iris/scripts/headless.conf +++ /dev/null @@ -1,14 +0,0 @@ -# The compositor `scripts/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/iris/scripts/reference/fling_spline_reference.py b/iris/scripts/reference/fling_spline_reference.py deleted file mode 100644 index 4d0e8d2..0000000 --- a/iris/scripts/reference/fling_spline_reference.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/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) - -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) - 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 - 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 `app/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/iris/scripts/reference/velocity_reference.py b/iris/scripts/reference/velocity_reference.py deleted file mode 100644 index ce1ba60..0000000 --- a/iris/scripts/reference/velocity_reference.py +++ /dev/null @@ -1,288 +0,0 @@ -#!/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 - -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 = [[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 - - - -# 1. `app/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)] -STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)] - -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)] - -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/iris/scripts/report_to_touch.py b/iris/scripts/report_to_touch.py deleted file mode 100755 index cce32f2..0000000 --- a/iris/scripts/report_to_touch.py +++ /dev/null @@ -1,94 +0,0 @@ -#!/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.*)$" -) -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/iris/scripts/run-bench.sh b/iris/scripts/run-bench.sh deleted file mode 100755 index dd7fa3f..0000000 --- a/iris/scripts/run-bench.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/sh -# Runs iris's on-demand benchmark suite. -# 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. -# -# ./scripts/run-bench.sh # everything -# ./scripts/run-bench.sh list # just the CPU-only message-list scenarios -# ./scripts/run-bench.sh images # just the GPU bind-group-creation scenario -set -eu -scripts=$(cd "$(dirname "$0")" && pwd) -root=$(cd "$scripts/.." && pwd) -cd "$root" - -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 "$scripts/run-headless.sh" bench_images --seconds 4 2>&1 | grep "^BENCH_IMAGES" -fi diff --git a/iris/scripts/run-headless.sh b/iris/scripts/run-headless.sh deleted file mode 100755 index 9412377..0000000 --- a/iris/scripts/run-headless.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/bin/sh -# Run an iris example on this machine, which has no display. -# -# ./scripts/run-headless.sh tabs [-- cargo args] -# ./scripts/run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 -# ./scripts/run-headless.sh phone --phone --dir ../app --shot /tmp/p.png -# ./scripts/run-headless.sh phone --phone --dir ../app \ -# --replay ../app/touch/flick-120hz.touch --shot /tmp/p.png -# -# `--dir DIR` names the workspace to build in, defaulting to `iris/`. The -# app's examples -- the phone-sized transcript -# screen and everything else that is about *this product* -- live in -# `app/`, 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::desktop::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/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 - -scripts=$(cd "$(dirname "$0")" && pwd) -root=$(cd "$scripts/.." && pwd) -workdir="$root" -cd "$root" -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 "$scripts/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 - -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 "$root" && 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 - -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 - "$root/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/iris/src/access_tests.rs b/iris/src/access_tests.rs deleted file mode 100644 index bbdcad9..0000000 --- a/iris/src/access_tests.rs +++ /dev/null @@ -1,102 +0,0 @@ -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: Ui::default() }; - let leaf: WeakWidget = rect(PaintId::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"); - - 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: Ui::default() }; - let root = rsc.ui.widgets.add_strong(rect(PaintId::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" - ); -} - -#[test] -fn bounds_follow_a_moved_widget_and_updates_stay_incremental() { - let mut rsc = TestRsc { ui: Ui::default() }; - let leaf: WeakWidget = rect(PaintId::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); - - render.update(&root, &mut rsc); - assert!(access.update(rsc.widgets(), &render, &rsc).is_none()); - assert_eq!(access.take_rebuilds(), 0); - - 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"); - 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/iris/src/android/access.rs b/iris/src/android/access.rs deleted file mode 100644 index 1de2787..0000000 --- a/iris/src/android/access.rs +++ /dev/null @@ -1,68 +0,0 @@ -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 -/// `desktop/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)) - } -} - -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/iris/src/android/attr.rs b/iris/src/android/attr.rs deleted file mode 100644 index e980dfa..0000000 --- a/iris/src/android/attr.rs +++ /dev/null @@ -1,29 +0,0 @@ -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/iris/src/android/ime.rs b/iris/src/android/ime.rs deleted file mode 100644 index 4ad6f13..0000000 --- a/iris/src/android/ime.rs +++ /dev/null @@ -1,270 +0,0 @@ -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.rsc.events().controllers.command_target_blocks_input()) - .then_some(self.state.android_state().focus) - .flatten() - } - - pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) { - let Some(focus) = self.focus() else { return }; - let text = focus.get(&self.rsc); - 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 = focus.get(&self.rsc); - let sel = text.selection_range().unwrap_or(0..0); - let content = text.text(); - let start = byte_to_utf16(&content, sel.start) as i32; - let end = byte_to_utf16(&content, 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, &content, 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 = focus.get(&self.rsc); - let sel = text.selection_range()?; - let content = text.text(); - let end_16 = byte_to_utf16(&content, sel.start); - let start_16 = end_16.saturating_sub(n as usize); - let start = utf16_to_byte(&content, start_16); - Some(Cow::Owned(content[start..sel.start].to_owned())) - } - - 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 = focus.get(&self.rsc); - let sel = text.selection_range()?; - let content = text.text(); - let len_16 = byte_to_utf16(&content, content.len()); - let start_16 = byte_to_utf16(&content, sel.end); - let end_16 = (start_16 + n as usize).min(len_16); - let end = utf16_to_byte(&content, end_16); - Some(Cow::Owned(content[sel.end..end].to_owned())) - } - - fn selected_text<'slf>(&'slf mut self, _ctx: &mut CallbackCtx) -> Option> { - let focus = self.focus()?; - Some(Cow::Owned(focus.get(&self.rsc).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 = focus.get(&self.rsc); - let Some(caret) = text.caret() else { - return 0; - }; - let content = text.text(); - let off = byte_to_utf16(&content, caret); - caps_mode(&mut ctx.env, &content, 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 = focus.get(&self.rsc); - 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); - drop(content); - focus(&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 { - 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; - }; - let compose_len = self.state.android_state().compose_len; - focus(&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 = focus.get(&self.rsc); - let content = text.text(); - let byte = utf16_to_byte(&content, end.max(0) as usize); - drop(content); - focus(&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 { - 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 { - false - } -} diff --git a/iris/src/android/input.rs b/iris/src/android/input.rs deleted file mode 100644 index 1d6e815..0000000 --- a/iris/src/android/input.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::prelude::*; -use android_view::{jni::JNIEnv, ndk::event::Keycode}; - -use super::view::AndroidAppState; - -/// 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 State::Resources, - 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 text = focus(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/iris/src/android/insets.rs b/iris/src/android/insets.rs deleted file mode 100644 index f68394e..0000000 --- a/iris/src/android/insets.rs +++ /dev/null @@ -1,108 +0,0 @@ -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, - pub ime_bottom: i32, - pub ime_visible: bool, -} - -#[derive(Default)] -pub struct Shared { - pub insets: Insets, - /// Exposed in diagnostics to distinguish missing callbacks from zero insets. - 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; - } - 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/iris/src/android/mod.rs b/iris/src/android/mod.rs deleted file mode 100644 index e533db3..0000000 --- a/iris/src/android/mod.rs +++ /dev/null @@ -1,62 +0,0 @@ -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, AndroidResources, AndroidUiState, HasAndroidUiState, IrisViewPeer, - WindowInsets, new_peer, -}; - -/// Types used by `#[iris::android_init]`'s generated JNI boundary. -#[doc(hidden)] -pub mod __private { - pub use android_view::{Context, View, jni::JNIEnv}; - pub type JInt = android_view::jni::sys::jint; - pub type JLong = android_view::jni::sys::jlong; - pub type RawJavaVM = android_view::jni::sys::JavaVM; - - /// Registers the fixed view class shipped by `cargo-iris`. - /// - /// # Safety - /// `vm` must be the live VM pointer Android passed to `JNI_OnLoad`. - pub unsafe fn on_load( - vm: *mut RawJavaVM, - new_peer: for<'local> extern "system" fn( - JNIEnv<'local>, - View<'local>, - Context<'local>, - ) -> JLong, - ) -> JInt { - use android_view::{jni::JavaVM, register_view_class}; - - let vm = unsafe { JavaVM::from_raw(vm) }.unwrap(); - let mut env = vm.get_env().unwrap(); - const VIEW_CLASS: &str = "dev/iris/android/IrisView"; - register_view_class(&mut env, VIEW_CLASS, new_peer); - super::register_native_methods(&mut env, VIEW_CLASS); - android_view::jni::sys::JNI_VERSION_1_6 - } -} - -/// 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/iris/src/android/platform.rs b/iris/src/android/platform.rs deleted file mode 100644 index 08b796e..0000000 --- a/iris/src/android/platform.rs +++ /dev/null @@ -1,80 +0,0 @@ -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()); - } -} - -/// `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. -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/iris/src/android/render.rs b/iris/src/android/render.rs deleted file mode 100644 index ccd9f89..0000000 --- a/iris/src/android/render.rs +++ /dev/null @@ -1,453 +0,0 @@ -use crate::task::WakeTaskQueue; -use android_view::{ - View, - jni::{JavaVM, objects::GlobalRef}, - ndk::native_window::NativeWindow, -}; -use iris_core::{FrameParts, LinearRgba, Ui, UiRenderNode}; -use pollster::FutureExt; -use std::time::Instant; -use wgpu::{ - rwh::{DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle}, - *, -}; - -pub const CLEAR_COLOR: LinearRgba = LinearRgba::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, - view_format: TextureFormat, - encoder: CommandEncoder, - pub ui: UiRenderNode, - 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, - 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, - pub paints_resized: bool, - pub atlas_pages_grown_prev: u64, - pub image_bind_group_creates_prev: u64, -} - -impl AndroidRenderer { - pub fn new( - window: NativeWindow, - width: u32, - height: u32, - content_scale: f32, - ) -> Result { - // 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}"))?; - - 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, - ) - })?; - - 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 `desktop::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 mut formats = iris_core::srgb_surface_format(&surface_caps)?; - let downlevel = adapter.get_downlevel_capabilities(); - if formats.view != formats.surface - && !downlevel - .flags - .contains(DownlevelFlags::SURFACE_VIEW_FORMATS) - { - // GLES implementations are allowed to lack texture-view format - // reinterpretation. Configuring the advertised non-sRGB format - // with an sRGB view then fails validation and leaves the surface - // unusable. Rendering through the advertised format is the only - // viable fallback on those devices. - log::warn!( - "iris renderer: {:?} cannot use an sRGB surface view; rendering through {:?}", - formats.surface, - formats.surface, - ); - formats.view = formats.surface; - } - log::info!( - "iris renderer: surface={:?}, view={:?}, color_space=Srgb", - formats.surface, - formats.view, - ); - - let config = SurfaceConfiguration { - usage: TextureUsages::RENDER_ATTACHMENT, - format: formats.surface, - // wgpu 30's new field; `Auto` is what every earlier version did. - color_space: SurfaceColorSpace::Srgb, - width, - height, - present_mode: PresentMode::AutoVsync, - alpha_mode: surface_caps.alpha_modes[0], - desired_maximum_frame_latency: 2, - view_formats: (formats.view != formats.surface) - .then_some(formats.view) - .into_iter() - .collect(), - }; - surface.configure(&device, &config); - - let encoder = Self::create_encoder(&device); - let window_size = iris_core::util::Vec2::new(width as f32, height as f32); - let ui = match UiRenderNode::new(&device, &queue, formats.view, window_size) { - Ok(ui) => ui, - Err(wgpu_error) => return Err(Self::diagnostic(&adapter, &wgpu_error)), - }; - - Ok(Self { - surface, - device, - queue, - config, - view_format: formats.view, - 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, - ) - } - - 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\ - surface: {surface:?}, view: {view:?}, color_space: Srgb\n\ - content_scale: {content_scale}\n\ - paint format: linear vec4\n\ - atlas/image format: Rgba8UnormSrgb, 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\ - wgpu errors since surface creation:\n {errors_text}\n\n\ - {frame_report}", - name = self.adapter_name, - backend = self.adapter_backend, - driver = self.adapter_driver, - surface = self.config.format, - view = self.view_format, - 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, - ) - } - - fn create_encoder(device: &Device) -> CommandEncoder { - device.create_command_encoder(&CommandEncoderDescriptor { - label: Some("Render Encoder"), - }) - } - - pub fn update(&mut self, ui: &mut Ui) -> 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); - self.frame_count += 1; - FrameDiagnostics { - masks_resized: stats.masks_resized, - moves_resized: stats.moves_resized, - paints_resized: stats.paints_resized, - atlas_pages_grown_prev, - image_bind_group_creates_prev, - } - } - - pub fn frame_count(&self) -> u64 { - self.frame_count - } - - pub fn draw(&mut self) -> FrameParts { - let acquire_start = Instant::now(); - 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 acquire = acquire_start.elapsed(); - let view = output.texture.create_view(&TextureViewDescriptor { - format: Some(self.view_format), - ..Default::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.to_wgpu()), - 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); - FrameParts::waits(acquire, 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`' UI-thread wake on Android. Updates can be submitted from the -/// tokio runtime or an application-owned thread that is not attached to the -/// JVM, so posting the callback means attaching first. The global ref is what -/// survives past the JNI call that handed the `View` to us. -pub struct AndroidTaskWake { - vm: JavaVM, - view: GlobalRef, -} - -impl AndroidTaskWake { - pub fn new(vm: JavaVM, view: GlobalRef) -> Self { - Self { vm, view } - } -} - -impl WakeTaskQueue for AndroidTaskWake { - fn wake(&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/iris/src/android/view.rs b/iris/src/android/view.rs deleted file mode 100644 index 69208ff..0000000 --- a/iris/src/android/view.rs +++ /dev/null @@ -1,874 +0,0 @@ -use crate::prelude::*; -use crate::task::WakeTaskQueue; -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}, -}; -use std::{cell::RefCell, marker::Sized, rc::Rc, sync::Arc, time::Instant}; - -use super::{ - access::{AndroidAccessSource, NullActionHandler, raise_if_enabled}, - insets::{Insets, Shared}, - render::{AndroidRenderer, AndroidTaskWake}, -}; - -/// Android host state. The renderer follows the `SurfaceView` lifecycle. -/// 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, - /// Previous IME preedit length, in characters. - pub compose_len: usize, - /// Deferred until the input callback regains access to JNI. - pub pending_show_keyboard: bool, - /// Also deferred until a JNI callback is available. - pub pending_open_url: Option, - /// Filled by the native insets callback registered in `android/insets.rs`. - shared: Rc>, - pub access_adapter: AccessAdapter, - pub access: AccessTree, - pub frame_report: FrameReport, - 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 - } - - 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, rsc: &mut Rsc, root: StrongWidget) { - self.root = Some(crate::overlay::default_overlay_root(rsc, root)); - } -} - -pub trait HasAndroidUiState: Sized + 'static { - fn android_state(&self) -> &AndroidUiState; - fn android_state_mut(&mut self) -> &mut AndroidUiState; -} - -impl HasAndroidUiState for AndroidUiState { - fn android_state(&self) -> &AndroidUiState { - self - } - - fn android_state_mut(&mut self) -> &mut AndroidUiState { - self - } -} - -/// Application state retained for the lifetime of one Android `View`. -/// -/// [`StdRsc`] is the usual [`AndroidResources`] implementation, but the host only -/// requires the capabilities in [`AndroidResources`]. An application may add -/// its own resources by supplying another implementation. -pub trait AndroidAppState: HasAndroidUiState { - type Resources: AndroidResources; - - #[allow(unused_variables)] - fn back_pressed(&mut self, rsc: &mut Self::Resources) -> bool { - false - } - #[allow(unused_variables)] - fn platform_ready(&mut self, rsc: &mut Self::Resources, vm: JavaVM, view: GlobalRef) {} - #[allow(unused_variables)] - fn on_insets_changed(&mut self, rsc: &mut Self::Resources, insets: WindowInsets) {} -} - -impl AndroidAppState for AndroidUiState { - type Resources = StdRsc; -} - -/// Resources the Android host needs to draw and dispatch application events. -/// -/// This deliberately names capabilities rather than storage. Custom bundles -/// can embed or replace [`StdRsc`] as long as they implement these traits and -/// create the task receiver paired with their [`Tasks`] value. -pub trait AndroidResources: - HasTasks + HasWidgetState + Sized + 'static -where - State: 'static, -{ - fn new(wake: Arc) -> (Self, TaskMsgReceiver); -} - -impl AndroidResources for StdRsc { - fn new(wake: Arc) -> (Self, TaskMsgReceiver) { - StdRsc::new(wake) - } -} - -/// Widget-facing insets in physical pixels, decoupled from JNI's integer shape. -#[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 `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: State::Resources, - pub(super) state: State, - task_recv: TaskMsgReceiver, - /// Converts input and Choreographer timestamps onto one monotonic clock. - device_clock: Option, -} - -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; - let render_state = self.rsc.ui().render_state(); - render_state - .get() - .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(&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 `desktop::DesktopApp::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); - - self.update_ime_selection(ctx); - - self.state.android_state_mut().cursor.end_frame(); - self.request_frame_if_needed(ctx); - } - - fn request_frame_if_needed(&self, ctx: &mut CallbackCtx) { - let ui_state = self.state.android_state(); - let render_state = self.rsc.ui().render_state(); - if render_state - .get() - .needs_redraw(&ui_state.root, self.rsc.widgets()) - { - ctx.view.post_frame_callback(&mut ctx.env); - } - } - - fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock { - *self - .device_clock - .get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest)) - } - - fn generic_motion<'local>( - &mut self, - ctx: &mut CallbackCtx<'local>, - event: &MotionEvent<'local>, - ) -> bool { - let action = event.action_masked(&mut ctx.env); - let event_time = event.event_time_nanos(&mut ctx.env); - let mut clock = self.device_clock(event_time, event_time); - let at = clock.sample(event_time); - self.device_clock = Some(clock); - - let ui = self.state.android_state_mut(); - ui.cursor.time = at; - ui.cursor.pos = vec2(event.x(&mut ctx.env), event.y(&mut ctx.env)); - ui.cursor.exists = !matches!(action, MotionAction::HoverExit); - - let buttons = event.button_state(&mut ctx.env); - ui.cursor.buttons.left.update(buttons.primary()); - ui.cursor.buttons.right.update(buttons.secondary()); - ui.cursor.buttons.middle.update(buttons.teriary()); - - match action { - MotionAction::HoverEnter - | MotionAction::HoverMove - | MotionAction::HoverExit - | MotionAction::ButtonPress - | MotionAction::ButtonRelease => {} - MotionAction::Scroll => { - ui.cursor.scroll_delta = vec2( - event.axis(&mut ctx.env, Axis::Hscroll, 0), - event.axis(&mut ctx.env, Axis::Vscroll, 0), - ); - } - _ => return false, - } - - self.after_input(ctx); - true - } - - fn window_size(&self) -> Vec2 { - let ui_state = self.state.android_state(); - match &ui_state.renderer { - Some(r) => r.size(), - None => Vec2::ZERO, - } - } - - fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) { - 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); - 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); - } - - 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.rsc.ui().render_state().get().active_widgets(), - ui_state - .root - .as_ref() - .and_then(|r| { - self.rsc - .ui() - .render_state() - .get() - .window_region(r, &self.rsc) - }), - self.window_size(), - ); - } - let frame_start = Instant::now(); - let ui_state = self.state.android_state_mut(); - let animating = self.rsc.draw_at(&ui_state.root, now); - // Ask before renderer update and swapchain acquisition: the latter - // may block until near the next presentation boundary. - if animating { - ctx.view.post_frame_callback(&mut ctx.env); - } - let ui_state = self.state.android_state_mut(); - let Some(renderer) = &mut ui_state.renderer else { - return; - }; - let frame_diagnostics = renderer.update(self.rsc.ui_mut()); - if renderer.frame_count() <= DIAGNOSTIC_FRAMES { - log::info!( - "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ - paints_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.paints_resized, - frame_diagnostics.atlas_pages_grown_prev, - frame_diagnostics.image_bind_group_creates_prev, - renderer.wgpu_errors.len(), - ); - } - let mut parts = renderer.draw(); - parts.total = frame_start.elapsed(); - self.state - .android_state_mut() - .frame_report - .record(now, parts, animating); - let render_state = self.rsc.ui().render_state(); - crate::diagnostics::log_frame(&render_state.get(), now, parts, animating); - if crate::diagnostics::trace_enabled() { - let ui_state = self.state.android_state(); - log::debug!( - target: "iris::frame", - "render(): after update active={} root_px={:?}", - self.rsc.ui().render_state().get().active_widgets(), - ui_state - .root - .as_ref() - .and_then(|r| { - self.rsc - .ui() - .render_state() - .get() - .window_region(r, &self.rsc) - }), - ); - } - - let ui_state = self.state.android_state_mut(); - let access_changed = ui_state.access.refresh( - self.rsc.widgets(), - &self.rsc.ui().render_state().get(), - &self.rsc, - ); - if access_changed { - let ui_state = self.state.android_state_mut(); - let access = &ui_state.access; - if let Some(events) = ui_state - .access_adapter - .update_if_active(|| access.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); -} - -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 { - if self.rsc.run_command(Command::Escape) != CommandResult::Unused { - self.after_input(ctx); - return true; - } - let handled = self.state.back_pressed(&mut self.rsc); - if handled { - self.after_input(ctx); - } - return handled; - } - if self.rsc.events().controllers.command_target_blocks_input() { - return true; - } - 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); - // MotionEvent and layout both use physical pixels. - let x = event.x(&mut ctx.env); - let y = event.y(&mut ctx.env); - // Use the event clock so batched movement keeps its real timing. - let event_time = event.event_time_nanos(&mut ctx.env); - let history = event.history_size(&mut ctx.env); - let mut clock = match self.device_clock { - Some(clock) => clock, - None => { - let oldest = if history > 0 { - event.historical_event_time_nanos(&mut ctx.env, 0) - } else { - event_time - }; - self.device_clock(event_time, oldest) - } - }; - // Avoid allocating trace history when input tracing is disabled. - let trace_input = crate::diagnostics::trace_enabled(); - let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new(); - - if matches!(action, MotionAction::Move) { - // Android orders history oldest-first; `sample` checks monotonicity. - 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.device_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); - } - 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_generic_motion_event<'local>( - &mut self, - ctx: &mut CallbackCtx<'local>, - event: &MotionEvent<'local>, - ) -> bool { - self.drain_tasks(); - self.generic_motion(ctx, event) - } - - fn on_hover_event<'local>( - &mut self, - ctx: &mut CallbackCtx<'local>, - event: &MotionEvent<'local>, - ) -> bool { - let action = event.action(&mut ctx.env); - let x = event.x(&mut ctx.env); - let y = event.y(&mut ctx.env); - let access_events = { - let render_handle = self.rsc.ui().render_state(); - let render_state = render_handle.get(); - let mut source = AndroidAccessSource { - widgets: self.rsc.widgets(), - render: &render_state, - rsc: &self.rsc, - }; - self.state - .android_state_mut() - .access_adapter - .on_hover_event(&mut source, action, x, y) - }; - if let Some(events) = access_events { - ctx.push_dynamic_deferred_callback(move |env, view| { - raise_if_enabled(env, view, events); - }); - true - } else { - self.drain_tasks(); - self.generic_motion(ctx, event) - } - } - - 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(&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 canvas and wgpu surface are separate and both use physical pixels. - self.rsc.ui_mut().resize((width as f32, height as f32)); - - // Resizing preserves GPU resources; recreating a destroyed surface does not. - 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.borrow().atlas.glyph_count(), - self.rsc.ui().text.borrow().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, Instant::now()); - return; - } - - let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env); - // `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.borrow().atlas.glyph_count(), - self.rsc.ui().text.borrow().atlas.page_count(), - ); - self.rsc.ui_mut().textures.reupload(); - self.rsc.ui_mut().paints.reupload(); - self.state.android_state_mut().renderer = Some(renderer); - self.render(ctx, Instant::now()); - } - Err(report) => { - log::error!("iris renderer init failed: {}", report.replace('\n', " | ")); - 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.borrow().atlas.glyph_count(), - self.rsc.ui().text.borrow().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(); - // The vsync this frame is for, dated on the same ruler touch - // samples are (`DeviceClock`), rather than `Instant::now()` here: - // this callback runs some variable distance after that vsync -- - // behind `drain_tasks`, behind whatever else the UI thread was - // doing -- and anything advanced by that variable amount moves - // unevenly between frames the display shows evenly. `at` rather - // than `sample`, since a frame time is not part of the touch - // samples' own ordering. - let now = self - .device_clock(frame_time_nanos, frame_time_nanos) - .at(frame_time_nanos); - self.render(ctx, now); - } - - /// Where `AndroidTaskWake::wake` lands on the UI thread. Applying an - /// update and drawing it are deliberately separate: retained widget - /// invalidation decides whether this wake needs a frame. - fn delayed_callback(&mut self, ctx: &mut CallbackCtx) { - self.drain_tasks(); - self.request_frame_if_needed(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 render_handle = self.rsc.ui().render_state(); - let render_state = render_handle.get(); - let mut source = AndroidAccessSource { - widgets: self.rsc.widgets(), - render: &render_state, - 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 render_handle = self.rsc.ui().render_state(); - let render_state = render_handle.get(); - let mut source = AndroidAccessSource { - widgets: self.rsc.widgets(), - render: &render_state, - 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 -/// `app/src/android/mod.rs` and `iris/examples/*/android.rs`. -pub fn new_peer<'local, State: AndroidAppState>( - mut env: JNIEnv<'local>, - view: View<'local>, - context: Context<'local>, - init: fn(AndroidUiState, &mut State::Resources) -> State, -) -> 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 wake: Arc = Arc::new(AndroidTaskWake::new(vm, global_view)); - let (mut rsc, task_recv) = State::Resources::new(wake); - rsc.ui_mut().set_density(content_scale); - let shared = Rc::new(RefCell::new(Shared::default())); - let ui_state = AndroidUiState::new(shared.clone(), content_scale); - let mut state = init(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 peer = IrisViewPeer { - rsc, - state, - task_recv, - device_clock: None, - }; - let id = android_view::register_view_peer(peer); - super::insets::register(id, shared); - id -} diff --git a/iris/src/desktop/access.rs b/iris/src/desktop/access.rs deleted file mode 100644 index 9909ddf..0000000 --- a/iris/src/desktop/access.rs +++ /dev/null @@ -1,18 +0,0 @@ -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/iris/src/desktop/app.rs b/iris/src/desktop/app.rs deleted file mode 100644 index 94c6e01..0000000 --- a/iris/src/desktop/app.rs +++ /dev/null @@ -1,60 +0,0 @@ -use winit::{ - application::ApplicationHandler, - event::WindowEvent, - event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}, - window::WindowId, -}; - -pub trait AppState: 'static { - fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop); - fn tasks_ready(&mut self); - fn exit(&mut self); -} - -pub struct App { - state: Option, - proxy: EventLoopProxy<()>, - init: Option>>, -} - -type Init = dyn FnOnce(&ActiveEventLoop, EventLoopProxy<()>) -> State; - -impl App { - pub fn run_with(init: impl FnOnce(&ActiveEventLoop, EventLoopProxy<()>) -> State + 'static) { - super::logging::install(log::LevelFilter::Info); - let event_loop = EventLoop::with_user_event().build().unwrap(); - let proxy = event_loop.create_proxy(); - event_loop - .run_app(&mut App:: { - state: None, - proxy, - init: Some(Box::new(init)), - }) - .unwrap(); - } -} - -impl ApplicationHandler<()> for App { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.state.is_none() { - let init = self.init.take().unwrap(); - let state = init(event_loop, self.proxy.clone()); - self.state = Some(state); - } - } - - fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { - let state = self.state.as_mut().unwrap(); - state.window_event(event, event_loop); - } - - fn user_event(&mut self, _: &ActiveEventLoop, (): ()) { - let state = self.state.as_mut().unwrap(); - state.tasks_ready(); - } - - fn exiting(&mut self, _: &ActiveEventLoop) { - let state = self.state.as_mut().unwrap(); - state.exit(); - } -} diff --git a/iris/src/desktop/attr.rs b/iris/src/desktop/attr.rs deleted file mode 100644 index 74b33c0..0000000 --- a/iris/src/desktop/attr.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::prelude::*; -use winit::dpi::{PhysicalPosition, PhysicalSize}; - -impl FocusHost for T { - fn recent_click(&mut self) -> bool { - crate::attr::recent_click(&mut self.desktop_state_mut().last_click) - } - - fn set_focus(&mut self, id: Option>) { - self.desktop_state_mut().focus = id; - } - - fn is_focused(&self, id: WeakWidget) -> bool { - self.desktop_state().focus == Some(id) - } - - fn focus_gained(&mut self, region: Option) { - let state = self.desktop_state_mut(); - let Some(region) = region else { return }; - state.window.set_ime_allowed(true); - state.window.set_ime_cursor_area( - PhysicalPosition::::from(region.top_left.tuple()), - PhysicalSize::::from(region.size().tuple()), - ); - } -} diff --git a/iris/src/desktop/input.rs b/iris/src/desktop/input.rs deleted file mode 100644 index 6dbbc66..0000000 --- a/iris/src/desktop/input.rs +++ /dev/null @@ -1,94 +0,0 @@ -// `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}, -}; - -#[derive(Default)] -pub struct Input { - cursor: CursorState, - pub modifiers: Modifiers, -} - -impl Input { - /// winit's pointer coordinates are physical pixels, which is the - /// space the whole tree is laid out and hit-tested in -- see - /// `desktop::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 { - MouseButton::Left => buttons.left.update(pressed), - MouseButton::Right => buttons.right.update(pressed), - MouseButton::Middle => buttons.middle.update(pressed), - _ => (), - } - } - WindowEvent::MouseWheel { delta, .. } => { - let mut delta = match *delta { - MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y), - MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32), - }; - if delta.x == 0.0 && self.modifiers.shift { - delta.x = delta.y; - delta.y = 0.0; - } - self.cursor.scroll_delta = delta; - self.cursor.time = Instant::now(); - } - WindowEvent::CursorLeft { .. } => { - self.cursor.exists = false; - self.modifiers.clear(); - } - WindowEvent::KeyboardInput { event, .. } => { - if let Key::Named(named) = event.logical_key { - let pressed = event.state.is_pressed(); - match named { - NamedKey::Control => { - self.modifiers.control = pressed; - } - NamedKey::Shift => { - self.modifiers.shift = pressed; - } - _ => (), - } - } - } - _ => return false, - } - true - } - - pub fn end_frame(&mut self) { - self.cursor.end_frame(); - } -} - -impl DesktopUiState { - /// Physical pixels, matching `WindowEvent::Resized` (what - /// `UiRenderState::resize` is given) and the swapchain -- see - /// `desktop::content_scale`. - pub fn window_size(&self) -> Vec2 { - let size = self.renderer.window().inner_size(); - Vec2::new(size.width as f32, size.height as f32) - } - - pub fn cursor_state(&self) -> &CursorState { - &self.input.cursor - } -} diff --git a/iris/src/desktop/logging.rs b/iris/src/desktop/logging.rs deleted file mode 100644 index 75ee394..0000000 --- a/iris/src/desktop/logging.rs +++ /dev/null @@ -1,59 +0,0 @@ -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(); - } -} - -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/iris/src/desktop/mod.rs b/iris/src/desktop/mod.rs deleted file mode 100644 index d7faa06..0000000 --- a/iris/src/desktop/mod.rs +++ /dev/null @@ -1,382 +0,0 @@ -use crate::prelude::*; -use arboard::Clipboard; -use std::{marker::Sized, sync::Arc, time::Instant}; -use winit::{ - event::{Ime, WindowEvent}, - event_loop::{ActiveEventLoop, EventLoopProxy}, - window::{Window, WindowAttributes}, -}; - -mod access; -mod app; -mod attr; -mod input; -mod logging; -mod platform; -mod render; - -pub use access::*; -use app::{App, AppState}; -pub use input::*; -pub use render::*; - -impl WakeTaskQueue for EventLoopProxy<()> { - fn wake(&self) { - let _ = self.send_event(()); - } -} - -/// Physical pixels per dp. Layout and input stay in physical pixels; only -/// `dp(...)` resolves through this scale. -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 DesktopUiState { - pub root: Option, - pub renderer: UiRenderer, - pub input: Input, - pub focus: Option>, - pub clipboard: Clipboard, - pub window: Arc, - pub ime: usize, - pub last_click: Instant, - pub access_adapter: accesskit_winit::Adapter, - pub access: AccessTree, -} - -impl HasRoot> for DesktopUiState { - fn set_root(&mut self, rsc: &mut StdRsc, root: StrongWidget) { - self.root = Some(crate::overlay::default_overlay_root(rsc, root)); - } -} - -impl DesktopUiState { - pub fn new(window: impl Into>, access_adapter: accesskit_winit::Adapter) -> Self { - let window = window.into(); - Self { - root: None, - renderer: UiRenderer::new(window.clone()), - window, - input: Input::default(), - clipboard: Clipboard::new().unwrap(), - ime: 0, - last_click: Instant::now(), - focus: None, - access_adapter, - access: AccessTree::new(), - } - } -} - -pub trait HasDesktopUiState: Sized + 'static { - fn desktop_state(&self) -> &DesktopUiState; - fn desktop_state_mut(&mut self) -> &mut DesktopUiState; -} - -impl HasDesktopUiState for DesktopUiState { - fn desktop_state(&self) -> &DesktopUiState { - self - } - - fn desktop_state_mut(&mut self) -> &mut DesktopUiState { - self - } -} - -pub trait DesktopAppState: HasDesktopUiState { - fn new(ui_state: DesktopUiState, rsc: &mut StdRsc) -> Self; - #[allow(unused_variables)] - fn exit(&mut self, rsc: &mut StdRsc) {} - #[allow(unused_variables)] - fn window_event(&mut self, event: WindowEvent, rsc: &mut StdRsc) {} - fn window_attributes() -> WindowAttributes { - Default::default() - } -} - -impl DesktopAppState for DesktopUiState { - fn new(ui_state: DesktopUiState, _: &mut StdRsc) -> Self { - ui_state - } -} - -pub struct DesktopApp { - rsc: StdRsc, - state: State, - task_recv: TaskMsgReceiver>, -} - -impl DesktopApp { - pub fn run() { - App::::run_with(|event_loop, proxy| { - Self::new_with(event_loop, proxy, State::window_attributes(), State::new) - }); - } - - fn new_with( - event_loop: &ActiveEventLoop, - proxy: EventLoopProxy<()>, - attributes: WindowAttributes, - init: impl FnOnce(DesktopUiState, &mut StdRsc) -> State, - ) -> Self { - let window = event_loop - .create_window(attributes.with_visible(false)) - .unwrap(); - let access_adapter = accesskit_winit::Adapter::with_direct_handlers( - event_loop, - &window, - NullActivationHandler, - NullActionHandler, - NullDeactivationHandler, - ); - window.set_visible(true); - let desktop_state = DesktopUiState::new(window, access_adapter); - let (mut rsc, task_recv) = StdRsc::new(Arc::new(proxy)); - // Set before building widgets so the first text shape uses the right density. - let scale = content_scale(desktop_state.window.as_ref()); - rsc.ui.set_density(scale); - let state = init(desktop_state, &mut rsc); - Self { - rsc, - state, - task_recv, - } - } -} - -impl DesktopApp { - /// Runs an application whose only state is Iris's desktop UI state. - pub fn run_with(init: impl FnOnce(&mut StdRsc, &mut DesktopUiState) + 'static) { - Self::run_with_attributes(DesktopUiState::window_attributes(), init); - } - - pub fn run_with_attributes( - attributes: WindowAttributes, - init: impl FnOnce(&mut StdRsc, &mut DesktopUiState) + 'static, - ) { - App::::run_with(move |event_loop, proxy| { - Self::new_with(event_loop, proxy, attributes, move |mut ui_state, rsc| { - init(rsc, &mut ui_state); - ui_state - }) - }); - } -} - -impl AppState for DesktopApp { - fn tasks_ready(&mut self) { - let Self { - rsc, - state, - task_recv, - } = self; - for update in task_recv.try_iter() { - update(state, rsc); - } - let ui_state = state.desktop_state(); - let render_state = rsc.ui.render_state(); - if render_state - .get() - .needs_redraw(&ui_state.root, rsc.widgets()) - { - ui_state.renderer.window().request_redraw(); - } - } - - fn window_event(&mut self, event: WindowEvent, event_loop: &ActiveEventLoop) { - let Self { - rsc, - state, - task_recv, - } = self; - - for update in task_recv.try_iter() { - update(state, rsc); - } - - let ui_state = state.desktop_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; - if cursor_state.buttons.left.is_start() { - ui_state.focus = None; - } - if input_changed { - // Winit delivers one sample at a time, so there is no history batch. - 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 render_state = rsc.ui.render_state(); - let t_ms = cursor_state - .time - .duration_since(render_state.get().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(); - let render_state = rsc.ui.render_state(); - render_state - .get() - .run_sensors(rsc, state, cursor_state, window_size); - } - let ui_state = state.desktop_state_mut(); - if old != ui_state.focus - && let Some(old) = old - { - old(rsc).deselect(); - } - match &event { - WindowEvent::CloseRequested => event_loop.exit(), - WindowEvent::RedrawRequested => { - let frame_start = std::time::Instant::now(); - let ui_state = state.desktop_state_mut(); - let animating = rsc.draw_at(&ui_state.root, frame_start); - // Ask before renderer update and swapchain acquisition: the latter - // may block until near the next presentation boundary. - if animating { - ui_state.window.request_redraw(); - } - ui_state.renderer.update(&mut rsc.ui); - let mut parts = ui_state.renderer.draw(); - parts.total = frame_start.elapsed(); - let render_state = rsc.ui.render_state(); - let render_state = render_state.get(); - crate::diagnostics::log_frame(&render_state, frame_start, parts, animating); - if ui_state.access.refresh(rsc.widgets(), &render_state, rsc) { - let access = &ui_state.access; - ui_state - .access_adapter - .update_if_active(|| access.tree_update()); - } - } - WindowEvent::Resized(size) => { - rsc.ui.resize((size.width, size.height)); - ui_state.renderer.resize(size) - } - WindowEvent::ScaleFactorChanged { .. } => { - let scale = content_scale(ui_state.window.as_ref()); - rsc.ui.set_density(scale); - ui_state.window.request_redraw(); - } - WindowEvent::KeyboardInput { event, .. } => { - let requested = event.state.is_pressed().then(|| match &event.logical_key { - winit::keyboard::Key::Character(c) if ui_state.input.modifiers.control => { - match c.as_str().to_ascii_lowercase().as_str() { - "c" => Some(Command::Copy), - "a" => Some(Command::SelectAll), - _ => None, - } - } - winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape) => { - Some(Command::Escape) - } - _ => None, - }); - let command = requested - .flatten() - .map_or(CommandResult::Unused, |command| rsc.run_command(command)); - let command_used = match command { - CommandResult::Copy(text) => { - if let Err(err) = ui_state.clipboard.set_text(text) { - eprintln!("failed to copy text to clipboard: {err}") - } - true - } - CommandResult::Used => true, - CommandResult::Unused => false, - }; - if !command_used - && !rsc.events.controllers.command_target_blocks_input() - && let Some(sel) = ui_state.focus - && event.state.is_pressed() - { - let text = sel(rsc); - match text.apply_event(event, &ui_state.input.modifiers) { - TextInputResult::Unfocus => { - ui_state.focus = None; - ui_state.window.set_ime_allowed(false); - } - TextInputResult::Submit => { - rsc.run_event::(sel, (), state); - } - TextInputResult::Paste => { - if let Ok(t) = ui_state.clipboard.get_text() { - text.insert(&t); - } - rsc.run_event::(sel, (), state); - } - TextInputResult::Copy(text) => { - if let Err(err) = ui_state.clipboard.set_text(text) { - eprintln!("failed to copy text to clipboard: {err}") - } - } - TextInputResult::Used => { - rsc.run_event::(sel, (), state); - } - TextInputResult::Unused => {} - } - } - } - WindowEvent::Ime(ime) => { - if !rsc.events.controllers.command_target_blocks_input() - && let Some(sel) = ui_state.focus - { - let text = sel(rsc); - match ime { - Ime::Enabled | Ime::Disabled => (), - Ime::Preedit(content, _pos) => { - // TODO: highlight once that's real - text.replace(ui_state.ime, content); - ui_state.ime = content.chars().count(); - } - Ime::Commit(content) => { - text.insert(content); - } - } - } - } - _ => (), - } - state.window_event(event, rsc); - let ui_state = self.state.desktop_state_mut(); - let render_state = rsc.ui.render_state(); - if render_state - .get() - .needs_redraw(&ui_state.root, rsc.widgets()) - { - ui_state.renderer.window().request_redraw(); - } - ui_state.input.end_frame(); - } - - fn exit(&mut self) { - self.state.exit(&mut self.rsc); - } -} diff --git a/iris/src/desktop/platform.rs b/iris/src/desktop/platform.rs deleted file mode 100644 index 993f349..0000000 --- a/iris/src/desktop/platform.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::platform::OpenUrl; -use crate::prelude::HasDesktopUiState; - -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") { - ("cmd", &["/C", "start", ""]) - } else { - ("xdg-open", &[]) - }; - match std::process::Command::new(program) - .args(first) - .arg(url) - .spawn() - { - Ok(_) => {} - Err(e) => log::warn!("could not open {url} with {program}: {e}"), - } - } -} diff --git a/iris/src/desktop/render.rs b/iris/src/desktop/render.rs deleted file mode 100644 index 4bab57f..0000000 --- a/iris/src/desktop/render.rs +++ /dev/null @@ -1,230 +0,0 @@ -use iris_core::{FrameParts, LinearRgba, Ui, UiRenderNode, util::Vec2}; -use pollster::FutureExt; -use std::sync::Arc; -use std::time::Instant; -use wgpu::*; -use winit::{dpi::PhysicalSize, window::Window}; - -pub const CLEAR_COLOR: LinearRgba = LinearRgba::BLACK; - -pub struct UiRenderer { - window: Arc, - surface: Surface<'static>, - device: Device, - queue: Queue, - config: SurfaceConfiguration, - view_format: TextureFormat, - encoder: CommandEncoder, - pub ui: UiRenderNode, -} - -impl UiRenderer { - pub fn update(&mut self, ui: &mut Ui) { - self.ui.update(&self.device, &self.queue, ui); - } - - /// The two waits, so a desktop frame divides up the same way an - /// Android one does -- see `AndroidRenderer::draw` for why the - /// swapchain acquire is measured apart from the work. - pub fn draw(&mut self) -> FrameParts { - let acquire_start = Instant::now(); - 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 acquire = acquire_start.elapsed(); - let view = output.texture.create_view(&TextureViewDescriptor { - format: Some(self.view_format), - ..Default::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.to_wgpu()), - 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.window.pre_present_notify(); - self.queue.present(output); - FrameParts::waits(acquire, submit_start.elapsed()) - } - - 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( - Vec2::new(size.width as f32, size.height as f32), - &self.queue, - ); - } - - fn create_encoder(device: &Device) -> CommandEncoder { - device.create_command_encoder(&CommandEncoderDescriptor { - label: Some("Render Encoder"), - }) - } - - pub fn new(window: Arc) -> Self { - let size = window.inner_size(); - - // `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())) - }); - 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()) - .expect("Could not create window surface!"); - - let adapter = instance - .request_adapter(&RequestAdapterOptions { - power_preference: PowerPreference::default(), - compatible_surface: Some(&surface), - force_fallback_adapter: false, - ..Default::default() - }) - .block_on() - .unwrap_or_else(|error| { - panic!("No usable GPU adapter for backends {backends:?}: {error}") - }); - - { - 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_limits: iris_core::device_limits(), - ..Default::default() - }) - .block_on() - .expect("Could not get device!"); - - let surface_caps = surface.get_capabilities(&adapter); - let formats = iris_core::srgb_surface_format(&surface_caps) - .expect("Could not select an sRGB iris surface format"); - log::info!( - "iris renderer: surface={:?}, view={:?}, color_space=Srgb", - formats.surface, - formats.view, - ); - - let config = SurfaceConfiguration { - usage: TextureUsages::RENDER_ATTACHMENT, - format: formats.surface, - // wgpu 30's new field; `Auto` is what every earlier version did. - color_space: SurfaceColorSpace::Srgb, - width: size.width, - height: size.height, - // 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: (formats.view != formats.surface) - .then_some(formats.view) - .into_iter() - .collect(), - }; - - surface.configure(&device, &config); - - let encoder = Self::create_encoder(&device); - - // 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 - // `desktop::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, formats.view, physical_size) - .expect("Could not create iris render node!"); - - Self { - surface, - device, - queue, - config, - view_format: formats.view, - encoder, - ui, - window, - } - } - - pub fn window(&self) -> &Window { - self.window.as_ref() - } -} diff --git a/iris/src/diagnostics.rs b/iris/src/diagnostics.rs deleted file mode 100644 index ea57a2c..0000000 --- a/iris/src/diagnostics.rs +++ /dev/null @@ -1,51 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Instant; - -use iris_core::{FrameParts, UiRenderState}; - -static TRACE: AtomicBool = AtomicBool::new(false); - -pub fn set_trace(on: bool) { - TRACE.store(on, Ordering::Relaxed); -} - -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`, -/// `desktop::DesktopApp::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, parts: FrameParts, 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={:?} build={:?} \ - acquire={:?} submit={:?} redraw={:?} primitives={} animating={animating}", - render.frame_number(), - now.duration_since(render.epoch()).as_millis(), - render.last_layout_duration(), - parts.build(), - parts.acquire, - parts.submit, - render.last_redraw_kind(), - render.active_primitive_count(), - ); -} diff --git a/iris/src/harness.rs b/iris/src/harness.rs deleted file mode 100644 index a57d11c..0000000 --- a/iris/src/harness.rs +++ /dev/null @@ -1,277 +0,0 @@ -use crate::prelude::*; -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, - } - } - - 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 { - pub t_ms: u64, - pub action: TouchAction, - pub pos: Vec2, -} - -pub struct TouchScript { - pub samples: Vec, -} - -impl TouchScript { - 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 }) - } - - pub fn end_ms(&self) -> u64 { - self.samples.last().map(|s| s.t_ms).unwrap_or(0) - } -} - -#[derive(Default)] -pub struct RedrawCounter(AtomicUsize); - -impl RedrawCounter { - pub fn count(&self) -> usize { - self.0.load(Ordering::Relaxed) - } -} - -impl WakeTaskQueue for RedrawCounter { - fn wake(&self) { - self.0.fetch_add(1, Ordering::Relaxed); - } -} - -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, - 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, rsc: &mut StdRsc, root: StrongWidget) { - self.root = Some(crate::overlay::default_overlay_root(rsc, 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()); - } -} - -/// 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: StdRsc, - pub state: HarnessState, - task_recv: TaskMsgReceiver>, - redraws: Arc, - cursor: CursorState, - 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 (mut rsc, task_recv) = StdRsc::new(redraws.clone()); - rsc.ui.set_density(density); - rsc.ui.resize(size); - Self { - rsc, - 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 a `DragGesture` by hand needs to date those calls on the - /// same clock the touch samples and frame painter use. - pub fn at(&self, t_ms: u64) -> Instant { - self.base + Duration::from_millis(t_ms) - } - - pub fn size(&self) -> Vec2 { - self.size - } - - pub fn redraws(&self) -> usize { - self.redraws.count() - } - - 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 at = Instant::now(); - let animating = self.rsc.draw_at(&self.state.root, now); - // No GPU here, so there is nothing to acquire and nothing to - // submit: the frame is all `build`, which is honest rather than - // zero-filled (`FrameParts::whole`). `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. - let render_state = self.rsc.ui.render_state(); - crate::diagnostics::log_frame( - &render_state.get(), - now, - FrameParts::whole(at.elapsed()), - animating, - ); - } - - 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 - } - - 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), - TouchAction::Cancel => { - self.cursor.buttons.left.update(false); - self.cursor.cancelled = true; - } - } - crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]); - let cursor = self.cursor.clone(); - let render_state = self.rsc.ui.render_state(); - render_state - .get() - .run_sensors(&mut self.rsc, &mut self.state, cursor, self.size); - self.frame(t_ms); - self.cursor.end_frame(); - } - - pub fn replay(&mut self, script: &TouchScript) { - for sample in &script.samples { - self.touch(sample.action, sample.pos, sample.t_ms); - } - } -} diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs deleted file mode 100644 index 6d9f4e8..0000000 --- a/iris/src/layout_tests.rs +++ /dev/null @@ -1,1304 +0,0 @@ -use crate::prelude::*; -use std::{cell::Cell, cell::RefCell, rc::Rc}; - -pub(crate) struct TestRsc { - pub(crate) ui: Ui, -} - -impl UiRsc for TestRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } -} - -struct FixedRect(f32); - -impl Widget for FixedRect { - fn draw(&mut self, painter: &mut Painter) { - let size = Size::from_axis(Axis::Y, LayoutLen::abs(self.0), LayoutLen::REST); - let paint = painter.paint(&PaintId::WHITE); - painter.primitive_within( - RectPrimitive::color(paint), - size.to_uivec2(painter.density()) - .align(RegionAlign::TOP_LEFT), - ); - painter.set_size(size); - } -} - -struct ChildOffset { - child: StrongWidget, - offset: Vec2, -} - -impl Widget for ChildOffset { - fn draw(&mut self, painter: &mut Painter) { - painter.set_child_offset(self.offset); - let size = painter.widget(&self.child).size(); - painter.set_size(size); - } -} - -struct TracedLeaf { - height: f32, - trace: Rc>>, -} - -impl Widget for TracedLeaf { - fn draw(&mut self, painter: &mut Painter) { - self.trace.borrow_mut().push("child"); - painter.set_size(Size::from_axis( - Axis::Y, - LayoutLen::abs(self.height), - LayoutLen::REST, - )); - } -} - -struct TracedParent { - child: StrongWidget, - reads_child_size: bool, - trace: Rc>>, -} - -struct CountedLeaf { - height: f32, - draws: Rc>, -} - -struct FrameRequester { - draws: Rc>, - seen_time: Rc>>, - request: bool, -} - -impl Widget for FrameRequester { - fn draw(&mut self, painter: &mut Painter) { - self.draws.set(self.draws.get() + 1); - self.seen_time.set(Some(painter.frame_time())); - if self.request { - painter.request_next_frame(); - } - painter.set_size(Size::ZERO); - } -} - -impl Widget for CountedLeaf { - fn draw(&mut self, painter: &mut Painter) { - self.draws.set(self.draws.get() + 1); - painter.set_size(Size::from_axis( - Axis::Y, - LayoutLen::abs(self.height), - LayoutLen::REST, - )); - } -} - -impl Widget for TracedParent { - fn draw(&mut self, painter: &mut Painter) { - self.trace.borrow_mut().push("parent"); - let child = painter.widget(&self.child); - let size = if self.reads_child_size { - child.size() - } else { - Size::REST - }; - painter.set_size(size); - } -} - -#[test] -fn a_next_frame_request_is_staged_after_the_current_redraw() { - let mut rsc = TestRsc { ui: Ui::default() }; - let draws = Rc::new(Cell::new(0)); - let seen_time = Rc::new(Cell::new(None)); - let widget = rsc.ui.widgets.add_strong(FrameRequester { - draws: draws.clone(), - seen_time: seen_time.clone(), - request: true, - }); - let weak = widget.weak(); - let root = widget.any(); - let mut render = UiRenderState::new(); - let frame_time = std::time::Instant::now(); - - assert!(render.update_at(&root, &mut rsc, frame_time)); - assert_eq!(draws.get(), 1, "the request was consumed in the same frame"); - assert_eq!(seen_time.get(), Some(frame_time)); - assert!(rsc.ui.widgets.needs_redraw.contains(&weak.id())); - - rsc.ui.widgets.get_mut(&weak).unwrap().request = false; - assert!(!render.update_at(&root, &mut rsc, frame_time)); - assert_eq!(draws.get(), 2); - assert!(!rsc.ui.widgets.needs_redraw.contains(&weak.id())); -} - -#[test] -fn a_widget_retains_its_entry_layer_not_its_child_cursor() { - let mut rsc = TestRsc { ui: Ui::default() }; - let back = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let front = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED)); - let stack = rsc.ui.widgets.add_strong(Stack { - children: vec![back.any(), front.any()], - size: StackSize::Default, - }); - let stack_id = stack.id(); - let outer = rsc.ui.widgets.add_strong(Sized { - inner: stack.any(), - x: None, - y: None, - }); - let outer_weak = outer.weak(); - let root = outer.any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 100.0)); - render.update(&root, &mut rsc); - - assert_eq!( - render.active[&stack_id].layer, 0, - "Stack entered on layer 0; layers 1 and 2 belong only to its children" - ); - - render.take_counters(); - rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None; - render.update(&root, &mut rsc); - assert_eq!( - render.take_counters().draws, - 1, - "redrawing the parent should retain the unchanged Stack subtree" - ); -} - -#[test] -fn a_size_dependent_parent_is_invalidated_before_layout_runs_downward() { - let mut rsc = TestRsc { ui: Ui::default() }; - let trace = Rc::new(RefCell::new(Vec::new())); - let child = rsc.ui.widgets.add_strong(TracedLeaf { - height: 20.0, - trace: trace.clone(), - }); - let child_weak = child.weak(); - let parent = rsc.ui.widgets.add_strong(TracedParent { - child: child.any(), - reads_child_size: true, - trace: trace.clone(), - }); - let parent_weak = parent.weak(); - let root = parent.any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 100.0)); - render.update(&root, &mut rsc); - trace.borrow_mut().clear(); - - rsc.ui.widgets.get_mut(&child_weak).unwrap().height = 40.0; - render.update(&root, &mut rsc); - - assert_eq!(&*trace.borrow(), &["parent", "child"]); - assert_eq!( - render.active[&parent_weak.id()].size.y, - LayoutLen::abs(40.0) - ); -} - -#[test] -fn a_parent_that_ignores_child_size_is_not_invalidated_with_it() { - let mut rsc = TestRsc { ui: Ui::default() }; - let trace = Rc::new(RefCell::new(Vec::new())); - let child = rsc.ui.widgets.add_strong(TracedLeaf { - height: 20.0, - trace: trace.clone(), - }); - let child_weak = child.weak(); - let parent = rsc.ui.widgets.add_strong(TracedParent { - child: child.any(), - reads_child_size: false, - trace: trace.clone(), - }); - let root = parent.any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 100.0)); - render.update(&root, &mut rsc); - trace.borrow_mut().clear(); - - rsc.ui.widgets.get_mut(&child_weak).unwrap().height = 40.0; - render.update(&root, &mut rsc); - - assert_eq!(&*trace.borrow(), &["child"]); -} - -#[test] -fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() { - let mut rsc = TestRsc { ui: Ui::default() }; - let changed_draws = Rc::new(Cell::new(0)); - let sibling_draws = Rc::new(Cell::new(0)); - let changed = rsc.ui.widgets.add_strong(CountedLeaf { - height: 20.0, - draws: changed_draws.clone(), - }); - let changed_weak = changed.weak(); - let sibling = rsc.ui.widgets.add_strong(CountedLeaf { - height: 20.0, - draws: sibling_draws.clone(), - }); - let span = rsc.ui.widgets.add_strong(Span { - children: vec![changed.any(), sibling.any()], - dir: Dir::DOWN, - gap: LayoutLen::ZERO, - compact_orthogonal: false, - }); - let root = rsc - .ui - .widgets - .add_strong(Sized { - inner: span.any(), - x: None, - y: None, - }) - .any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 100.0)); - render.update(&root, &mut rsc); - changed_draws.set(0); - sibling_draws.set(0); - - rsc.ui.widgets.get_mut(&changed_weak).unwrap().height = 40.0; - render.update(&root, &mut rsc); - - assert!(changed_draws.get() > 0, "the changed child was not redrawn"); - assert_eq!( - sibling_draws.get(), - 0, - "a vertical size change redrew a sibling whose width stayed fixed" - ); -} - -#[test] -fn a_span_leaves_children_in_its_offered_orthogonal_axis_by_default() { - let mut rsc = TestRsc { ui: Ui::default() }; - let short = wtext("short").add(&mut rsc); - let root = (short, wtext("a much wider line")) - .span(Dir::DOWN) - .add_strong(&mut rsc); - let root_id = root.id(); - let root = root.any(); - let mut render = UiRenderState::new(); - render.resize((200.0, 100.0)); - render.update(&root, &mut rsc); - - let width = render.active[&root_id].size.x; - assert_eq!(width.rel, 0.0); - assert_eq!(width.rest, 0.0); - assert!(width.abs > 0.0 && width.abs < 200.0); - assert_eq!( - render.active[&short.id()] - .region - .to_px(vec2(200.0, 100.0)) - .size() - .x, - 200.0, - ); -} - -#[test] -fn a_compact_span_shrinks_its_orthogonal_axis_to_its_widest_child() { - let mut rsc = TestRsc { ui: Ui::default() }; - let short = wtext("short").add(&mut rsc); - let root = (short, wtext("a much wider line")) - .span(Dir::DOWN) - .compact() - .add_strong(&mut rsc); - let root_id = root.id(); - let root = root.any(); - let mut render = UiRenderState::new(); - render.resize((200.0, 100.0)); - render.update(&root, &mut rsc); - - let width = render.active[&root_id].size.x; - assert_eq!(width.rel, 0.0); - assert_eq!(width.rest, 0.0); - assert!(width.abs > 0.0 && width.abs < 200.0); - assert_eq!( - render.active[&short.id()] - .region - .to_px(vec2(200.0, 100.0)) - .size() - .x, - width.abs, - ); -} - -#[test] -fn a_child_coordinate_offset_moves_only_the_child_subtree() { - let mut rsc = TestRsc { ui: Ui::default() }; - let child = rsc.ui.widgets.add_strong(FixedRect(40.0)); - let child_weak = child.weak(); - let parent = rsc.ui.widgets.add_strong(ChildOffset { - child: child.any(), - offset: vec2(0.0, 15.0), - }); - let parent_weak = parent.weak(); - let outer = rsc.ui.widgets.add_strong(Sized { - inner: parent.any(), - x: None, - y: None, - }); - let outer_weak = outer.weak(); - let root = outer.any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 100.0)); - render.update(&root, &mut rsc); - render.take_counters(); - - let parent_before = render.window_region(&parent_weak, &rsc).unwrap(); - let child_before = render.window_region(&child_weak, &rsc).unwrap(); - assert!((parent_before.top_left.y - 0.0).abs() < 0.01); - assert!((child_before.top_left.y - 15.0).abs() < 0.01); - - rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0; - rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None; - render.update(&root, &mut rsc); - let RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = render.take_counters(); - let parent_after = render.window_region(&parent_weak, &rsc).unwrap(); - let child_after = render.window_region(&child_weak, &rsc).unwrap(); - - assert_eq!((draws, rewrites, moves), (2, 0, 1)); - assert_eq!( - parent_after, parent_before, - "the container itself stays put" - ); - assert!((child_after.top_left.y - 35.0).abs() < 0.01); -} - -#[test] -fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() { - let mut rsc = TestRsc { ui: Ui::default() }; - let first = rsc.ui.widgets.add_strong(FixedRect(40.0)); - let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let fill = rsc.ui.widgets.add_strong(Sized { - inner: fill.any(), - x: None, - y: Some(LayoutLen::REST), - }); - let last = rsc.ui.widgets.add_strong(FixedRect(40.0)); - let last_w = last.weak(); - let mut span = Span::empty(Dir::DOWN); - span.push(first.any()); - span.push(fill.any()); - span.push(last.any()); - let root = rsc.ui.widgets.add_strong(span).any(); - - let mut render = UiRenderState::new(); - render.resize((200.0, 300.0)); - render.update(&root, &mut rsc); - let RenderCounters { draws, moves, .. } = render.take_counters(); - - assert_eq!(draws, 5); - assert_eq!(moves, 1); - let last = render.window_region(&last_w, &rsc).unwrap(); - assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}"); -} - -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(PaintId::WHITE)); - rects.push(rect.weak()); - let row = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(10.0)), - }); - span.push(row.any()); - } - let span = rsc.ui.widgets.add_strong(span); - let scroll = rsc - .ui - .widgets - .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: Ui::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); - render.update(&root, &mut rsc); - render.take_counters(); // discard the first, real draws - - render.update(&root, &mut rsc); - let RenderCounters { - draws, - region_rewrites: rewrites, - moves, - .. - } = 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: Ui::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); - render.take_counters(); - - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); - render.update(&root, &mut rsc); - let RenderCounters { draws, moves, .. } = render.take_counters(); - - assert_eq!(draws, 1, "only Scroll itself should redraw"); - assert_eq!(moves, 1, "the scrolled subtree should move in one write"); -} - -#[test] -fn fractional_scrolling_keeps_primitive_edges_on_physical_pixels() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (scroll, root, rects) = scrolled_rects(&mut rsc, 20); - 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); - rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.25); - render.update(&root, &mut rsc); - - let slot = render - .first_primitive(rects[2].id()) - .expect("the scrolled rect has a primitive"); - let corners = render.primitive_corners(slot, &rsc); - for edge in [ - corners.top_left.x, - corners.top_left.y, - corners.bot_right.x, - corners.bot_right.y, - ] { - assert_eq!(edge.fract(), 0.0, "fractional rendered edge: {corners:?}"); - } -} - -#[test] -fn hit_testing_follows_a_scrolled_widget() { - let mut rsc = TestRsc { ui: Ui::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()); - - assert!( - (after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01, - "before={before_px:?} after={after_px:?}" - ); -} - -#[test] -fn redrawing_a_masked_widget_does_not_nest_its_own_mask() { - let mut rsc = TestRsc { ui: Ui::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: Ui::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; - - assert_eq!(mask_delta_before, [0.0, 0.0]); - assert_eq!(mask_delta_after, [0.0, 0.0]); -} - -fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget) { - let field = wtext("") - .editable(EditMode::MultiLine) - .text_align(Align::LEFT) - .overflow(TextOverflow::Wrap) - .size(18) - .color(PaintId::WHITE) - .add(rsc); - let bar = (field.pad(dp(12)).width(rest(1)),) - .span(Dir::RIGHT) - .background(rect(Srgba8::new(40, 40, 46, 255))) - .add(rsc); - let list_stand_in = rect(PaintId::BLACK).height(rest(1)).add(rsc); - let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any(); - (field, tree) -} - -#[test] -fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { - let mut rsc = TestRsc { ui: Ui::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); - field(&mut rsc).select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false); - field(&mut rsc).insert("a"); - render.update(&root, &mut rsc); - - let before_px = render.window_region(&field, &rsc).unwrap(); - 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:?}" - ); - - render.resize((1080.0, 1478.0)); - render.update(&root, &mut rsc); - field(&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:?}" - ); -} - -#[test] -fn a_scroll_measures_the_box_it_was_offered_not_the_window() { - let mut rsc = TestRsc { ui: Ui::default() }; - let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let tall = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(1000.0)), - }); - let scroll = rsc - .ui - .widgets - .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(LayoutLen::abs(100.0)), - }); - let capped_id = capped.id(); - let root = capped.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); - - assert_eq!( - render.active.get(&scroll_id).unwrap().size.y, - LayoutLen::abs(1000.0) - ); - assert_eq!( - render.active.get(&capped_id).unwrap().size.y, - LayoutLen::abs(100.0), - "the cap, not the content and not the window" - ); - - 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() - ); -} - -#[test] -fn a_panned_widgets_own_hit_box_moves_exactly_once() { - let mut rsc = TestRsc { ui: Ui::default() }; - let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let tall = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(1000.0)), - }); - let tall_w = tall.weak(); - let scroll = rsc - .ui - .widgets - .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:?}" - ); -} - -#[test] -fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { - let mut rsc = TestRsc { ui: Ui::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 filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::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(LayoutLen::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" - ); -} - -#[test] -fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() { - let mut rsc = TestRsc { ui: Ui::default() }; - let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let tall = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(1000.0)), - }); - let capped = rsc.ui.widgets.add_strong(MaxSize { - inner: tall.any(), - x: None, - y: Some(LayoutLen::dp(100.0)), - }); - let capped_w = capped.weak(); - let filler = rsc.ui.widgets.add_strong(Rect::new(PaintId::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:?})" - ); -} - -#[test] -fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() { - let mut rsc = TestRsc { ui: Ui::default() }; - let top = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let spacer = rsc.ui.widgets.add_strong(Sized { - inner: top.any(), - x: None, - y: Some(LayoutLen::abs(100.0)), - }); - let spacer_w = spacer.weak(); - let below = rsc.ui.widgets.add_strong(Rect::new(PaintId::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); - 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(LayoutLen::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 - ); -} - -struct MoveThenPlace { - inner: StrongWidget, - offer_top: f32, - place_top: f32, -} - -impl Widget for MoveThenPlace { - fn draw(&mut self, painter: &mut Painter) { - 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.place(&self.inner, place); - painter.set_size(Size::default()); - } -} - -#[test] -fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() { - let mut rsc = TestRsc { ui: Ui::default() }; - let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let child = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::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:?}" - ); - - { - 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:?}" - ); -} - -const RADIUS: f32 = 20.0; - -fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) { - let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let child_id = child.id(); - let shape = rsc - .ui - .widgets - .add_strong(Rect::new(PaintId::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) -} - -#[test] -fn a_masked_child_is_clipped_by_its_container_s_own_corner() { - let mut rsc = TestRsc { ui: Ui::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; - - 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" - ); -} - -#[test] -fn a_mask_s_shape_decides_what_can_be_pressed() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (render, mask, _child, slot) = rounded_container(&mut rsc); - let corners = render.primitive_corners(slot, &rsc); - - 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", - ); - 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", - ); - 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", - ); -} - -#[test] -fn nested_masks_multiply_their_coverage() { - let mut rsc = TestRsc { ui: Ui::default() }; - let child = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let child_id = child.id(); - - let inner_shape = rsc - .ui - .widgets - .add_strong(Rect::new(PaintId::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(PaintId::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) - }; - - 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", - ); -} - -#[test] -fn a_plain_mask_still_clips_to_a_square_box() { - let mut rsc = TestRsc { ui: Ui::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", - ); -} - -#[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: Ui::default() }; - let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any(); - let tall = rsc.ui.widgets.add_strong(Sized { - inner: fill, - x: None, - y: Some(LayoutLen::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)); - 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}" - ); - } -} - -#[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: Ui::default() }; - let header_fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::RED)).any(); - let header_id = header_fill.id(); - let header = rsc.ui.widgets.add_strong(Sized { - inner: header_fill, - x: None, - y: Some(LayoutLen::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(PaintId::WHITE)); - rects.push(rect.weak()); - let sized = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(ROW)), - }); - let padded = rsc.ui.widgets.add_strong(Pad { - padding: Padding::uniform(PAD), - inner: sized.any(), - exact_region: false, - }); - let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::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(LayoutLen::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: LayoutLen::ZERO, - compact_orthogonal: false, - }); - 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:?}" - ); - } -} - -#[test] -fn rest_padding_claims_leftover_space_around_fixed_content() { - let mut rsc = TestRsc { ui: Ui::default() }; - let content = rect(PaintId::WHITE).sized((20, 10)).add_strong(&mut rsc); - let content_id = content.weak(); - let padded = rsc.ui.widgets.add_strong(Pad { - inner: content.any(), - padding: Padding { - left: LayoutLen::rest(0.5), - right: LayoutLen::rest(0.5), - ..Padding::ZERO - }, - exact_region: false, - }); - let root = rsc - .ui - .widgets - .add_strong(Sized { - inner: padded.any(), - x: Some(LayoutLen::abs(100)), - y: None, - }) - .any(); - let mut render = UiRenderState::new(); - render.resize((100.0, 40.0)); - - render.update(&root, &mut rsc); - render.update(&root, &mut rsc); - - let region = render.window_region(&content_id, &rsc).unwrap(); - assert_eq!(region.top_left.x, 40.0); - assert_eq!(region.bot_right.x, 60.0); -} - -#[test] -fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() { - const FIRST: f32 = 30.0; - const SECOND: f32 = 70.0; - const GAP: f32 = 8.0; - - let mut rsc = TestRsc { ui: Ui::default() }; - let first = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let first_id = first.id(); - let first = rsc.ui.widgets.add_strong(Sized { - inner: first.any(), - x: None, - y: Some(LayoutLen::abs(FIRST)), - }); - let mut contents = Span::empty(Dir::DOWN).gap(LayoutLen::abs(GAP)); - contents.push(first.any()); - let contents = rsc.ui.widgets.add_strong(contents); - let contents_w = contents.weak(); - let row = rsc.ui.widgets.add_strong(Sized { - inner: contents.any(), - x: Some(LayoutLen::rest(1.0)), - y: None, - }); - let mut list = LazySpan::new(Dir::DOWN, Pin::End); - list.push_back(LazyItem::new(0, row.any())); - let root = rsc.ui.widgets.add_strong(list).any(); - - let mut render = UiRenderState::new(); - render.resize((200.0, 200.0)); - render.update(&root, &mut rsc); - - let second = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let second_id = second.id(); - let second = rsc.ui.widgets.add_strong(Sized { - inner: second.any(), - x: None, - y: Some(LayoutLen::abs(SECOND)), - }); - rsc.ui - .widgets - .get_mut(&contents_w) - .unwrap() - .push(second.any()); - render.update(&root, &mut rsc); - - let first = render.primitive_corners(render.first_primitive(first_id).unwrap(), &rsc); - let second = render.primitive_corners(render.first_primitive(second_id).unwrap(), &rsc); - assert!( - (second.top_left.y - (first.bot_right.y + GAP)).abs() < 0.01, - "the new child should start after the old child and its gap: first={first:?} second={second:?}" - ); - assert!( - (second.bot_right.y - second.top_left.y - SECOND).abs() < 0.01, - "the new child retained its provisional box: {second:?}" - ); -} diff --git a/iris/src/lib.rs b/iris/src/lib.rs deleted file mode 100644 index 3458323..0000000 --- a/iris/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -#![feature(unboxed_closures)] -#![feature(fn_traits)] -#![feature(unsize)] -#![feature(option_into_flat_iter)] -#![feature(async_fn_traits)] - -#[cfg(target_os = "android")] -pub mod android; -#[cfg(not(target_os = "android"))] -pub mod desktop; - -pub mod diagnostics; -pub mod harness; -pub mod platform; -pub mod rsc; -pub mod widget; - -// Preserve concise public paths while implementation files stay grouped. -pub use rsc::attr; -pub use rsc::{event, overlay, sense, state, task}; - -#[cfg(test)] -mod access_tests; -#[cfg(test)] -mod layout_tests; - -pub use image; -pub use iris_core as core; -pub use iris_macro as macros; -pub use iris_macro::android_init; - -pub mod prelude { - use super::*; - #[cfg(target_os = "android")] - pub use android::*; - #[cfg(not(target_os = "android"))] - pub use desktop::*; - - pub use iris_core::*; - pub use iris_macro::*; - pub use platform::*; - pub use rsc::*; - pub use widget::*; - - pub use iris_core::util::Vec2; - pub use len_fns::*; -} diff --git a/iris/src/platform.rs b/iris/src/platform.rs deleted file mode 100644 index 058df8c..0000000 --- a/iris/src/platform.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub trait OpenUrl { - fn open_url(&mut self, url: &str); -} diff --git a/iris/src/rsc/attr.rs b/iris/src/rsc/attr.rs deleted file mode 100644 index d8789be..0000000 --- a/iris/src/rsc/attr.rs +++ /dev/null @@ -1,184 +0,0 @@ -use crate::prelude::*; -use std::time::{Duration, Instant}; - -pub trait FocusHost { - 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); - fn is_focused(&self, id: WeakWidget) -> bool; -} - -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 -} - -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 HasEvents, - render: &UiRenderState, - state: &mut impl FocusHost, - id: WeakWidget, - pos: Vec2, - size: Vec2, - sense: CursorSense, -) { - if sense == CursorSense::PressStart(CursorButton::Left) { - // An editable field becomes the command destination of the new - // interaction. Dismiss a retained display-text selection first so - // Copy cannot keep going to text the user has visibly left behind. - rsc.run_command(Command::Escape); - } - 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(rsc).press_origin = Some(pos); - id(rsc).select(pos, size, false, recent); - } - CursorSense::Pressing(_) | CursorSense::PressEnd(_) => { - let ctx = id(rsc); - let Some(origin) = ctx.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.press_origin = None; - return; - } - let ended = matches!(sense, CursorSense::PressEnd(_)); - if ended { - ctx.press_origin = None; - } - ctx.select(pos, size, true, false); - if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP { - state.focus_gained(render.window_region(&id, &*rsc)); - } - } - CursorSense::Cancel => id(rsc).press_origin = None, - _ => {} - } - return; - } - - match sense { - CursorSense::PressStart(_) => { - id(rsc).press_origin = Some(pos); - } - CursorSense::Pressing(_) => { - let ctx = id(rsc); - if let Some(origin) = ctx.press_origin - && ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP) - { - ctx.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(rsc).press_origin = None, - CursorSense::PressEnd(_) => { - let was_tap = id(rsc).press_origin.take().is_some(); - if was_tap { - let recent = state.recent_click(); - id(rsc).select(pos, size, false, recent); - state.set_focus(Some(id)); - state.focus_gained(render.window_region(&id, &*rsc)); - } - } - _ => {} - } -} diff --git a/iris/src/rsc/event.rs b/iris/src/rsc/event.rs deleted file mode 100644 index 1b8ba67..0000000 --- a/iris/src/rsc/event.rs +++ /dev/null @@ -1,105 +0,0 @@ -use iris_core::*; -use iris_macro::*; -use std::sync::Arc; - -use super::task::{TaskCtx, TaskUpdate, Tasks}; - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Submit; -impl Event for Submit {} - -#[derive(Eq, PartialEq, Hash, Clone)] -pub struct Edited; -impl Event for Edited {} - -pub trait Eventable: WidgetLike { - fn on( - self, - event: E, - f: impl for<'a> WidgetEventFn::Data<'a>, Self::Widget>, - ) -> impl WidgetIdFn { - move |rsc| { - let id = self.add(rsc); - rsc.register_event(id, event.into_event(), move |ctx, rsc| { - f( - EventIdCtx { - widget: id, - state: ctx.state, - data: ctx.data, - }, - rsc, - ); - }); - id - } - } -} -impl, Rsc: HasEvents, Tag> Eventable for WL {} - -pub trait Controllable: WidgetLike { - fn controller>(self, controller: C) -> impl WidgetIdFn { - move |rsc| { - let id = self.add(rsc); - rsc.register_controller(id, controller); - id - } - } -} -impl, Rsc: HasEvents, Tag> Controllable for WL {} - -widget_trait! { - pub trait TaskEventable; - fn task_on>( - self, - event: E, - f: F, - ) -> impl WidgetIdFn - where for<'b> F::CallRefFuture<'b>: Send, - { - let f = Arc::new(f); - move |rsc| { - let id = self.add(rsc); - rsc.register_event(id, event.into_event(), move |_, rsc| { - let f = f.clone(); - rsc.tasks_mut().spawn(async move |task| { - f(AsyncEventIdCtx { - widget: id, - task, - }).await; - }); - }); - id - } - } -} - -pub trait HasTasks: Sized + HasState + HasEvents { - fn tasks_mut(&mut self) -> &mut Tasks; - - fn spawn_task) + 'static + std::marker::Send>(&mut self, task: F) - where - F::CallOnceFuture: Send, - { - self.tasks_mut().spawn(task); - } -} - -pub trait AsyncWidgetEventFn: - AsyncFn(AsyncEventIdCtx) + Send + Sync + 'static -{ -} -impl) + Send + Sync + 'static, W: ?Sized> - AsyncWidgetEventFn for F -{ -} - -pub struct AsyncEventIdCtx { - pub widget: WeakWidget, - task: TaskCtx, -} - -impl AsyncEventIdCtx { - pub fn update(&mut self, f: impl TaskUpdate + 'static) { - self.task.update(f); - } -} diff --git a/iris/src/rsc/mod.rs b/iris/src/rsc/mod.rs deleted file mode 100644 index 80d5117..0000000 --- a/iris/src/rsc/mod.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Host-independent resources carried by [`StdRsc`]. - -pub mod attr; -pub mod event; -pub mod overlay; -pub mod sense; -pub mod state; -pub mod task; - -#[cfg(test)] -mod sense_tests; - -pub use attr::*; -pub use event::*; -pub use overlay::*; -pub use sense::*; -pub use state::*; -pub use task::*; - -use crate::prelude::*; -use std::sync::Arc; - -/// Iris's standard resource bundle. -/// -/// Built-in hosts use this bundle, but widgets should depend on the narrow -/// resource traits they need so applications can supply a different bundle. -pub struct StdRsc { - pub ui: Ui, - pub events: EventManager, - pub tasks: Tasks, - pub state: WidgetState, - _state: std::marker::PhantomData, -} - -impl StdRsc { - pub(crate) fn new(wake: Arc) -> (Self, TaskMsgReceiver) { - let (tasks, receiver) = Tasks::init(wake); - ( - Self { - ui: Ui::default(), - events: EventManager::default(), - tasks, - state: WidgetState::default(), - _state: std::marker::PhantomData, - }, - receiver, - ) - } - - pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { - self.state.add(id.id(), data) - } -} - -impl UiRsc for StdRsc { - fn ui(&self) -> &Ui { - &self.ui - } - - fn ui_mut(&mut self) -> &mut Ui { - &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 StdRsc { - type State = State; -} - -impl HasEvents for StdRsc { - fn events(&self) -> &EventManager { - &self.events - } - - fn events_mut(&mut self) -> &mut EventManager { - &mut self.events - } -} - -impl HasTasks for StdRsc { - fn tasks_mut(&mut self) -> &mut Tasks { - &mut self.tasks - } -} - -impl HasWidgetState for StdRsc { - fn widget_state(&self) -> &WidgetState { - &self.state - } - - fn widget_state_mut(&mut self) -> &mut WidgetState { - &mut self.state - } -} - -impl>> std::ops::Index for StdRsc { - type Output = I::Output; - - fn index(&self, index: I) -> &Self::Output { - index.get(self) - } -} - -impl>> std::ops::IndexMut for StdRsc { - fn index_mut(&mut self, index: I) -> &mut Self::Output { - index.get_mut(self) - } -} diff --git a/iris/src/rsc/overlay.rs b/iris/src/rsc/overlay.rs deleted file mode 100644 index 13bae42..0000000 --- a/iris/src/rsc/overlay.rs +++ /dev/null @@ -1,815 +0,0 @@ -//! Optional overlays over an ordinary [`Stack`]. -//! -//! An overlay host owns independent single and stackable controllers. Opening -//! any overlay removes the active single on each host the request traverses; -//! stackables remain in opening order, and the host's optional single is -//! always its final child. Controller lookup follows the active parent map -//! maintained by `EventManager`, so callers need only the requesting widget, -//! not access to `UiRenderState`. - -use crate::prelude::*; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum OverlayKind { - Single, - Stackable, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CancelBehavior { - Consume, - PassThrough, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OverlayOptions { - pub kind: OverlayKind, - pub cancel: CancelBehavior, - pub blocks_prior_input: bool, -} - -impl OverlayOptions { - pub const fn single(cancel: CancelBehavior) -> Self { - Self { - kind: OverlayKind::Single, - cancel, - blocks_prior_input: false, - } - } - - pub const fn stackable(cancel: CancelBehavior) -> Self { - Self { - kind: OverlayKind::Stackable, - cancel, - blocks_prior_input: false, - } - } - - pub const fn blocking(mut self) -> Self { - self.blocks_prior_input = true; - self - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OverlayHostOptions { - pub single: bool, - pub stackable: bool, -} - -impl OverlayHostOptions { - pub const BOTH: Self = Self { - single: true, - stackable: true, - }; - pub const SINGLE: Self = Self { - single: true, - stackable: false, - }; - pub const STACKABLE: Self = Self { - single: false, - stackable: true, - }; -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OverlayHandle { - host: WidgetId, - kind: OverlayKind, - serial: u64, -} - -impl OverlayHandle { - pub fn close(self, rsc: &mut Rsc) -> bool { - match self.kind { - OverlayKind::Single => { - let Some(id) = rsc - .events() - .controllers - .id::(self.host) - else { - return false; - }; - rsc.with_controller(id, |controller: &mut SingleOverlayController, rsc| { - controller.close(id, self.serial, rsc) - }) - .unwrap_or(false) - } - OverlayKind::Stackable => { - let Some(id) = rsc - .events() - .controllers - .id::(self.host) - else { - return false; - }; - rsc.with_controller(id, |controller: &mut StackableOverlayController, rsc| { - controller.close(id, self.serial, rsc) - }) - .unwrap_or(false) - } - } - } -} - -struct OverlayEntry { - serial: u64, - widget: WidgetId, - previous_target: Option, - cancel: CancelBehavior, - blocks_prior_input: bool, -} - -pub struct SingleOverlayController { - stack: WeakWidget, - active: Option, - next_serial: u64, -} - -impl SingleOverlayController { - fn new(stack: WeakWidget) -> Self { - Self { - stack, - active: None, - next_serial: 1, - } - } - - fn open( - &mut self, - id: ControllerId, - popup: StrongWidget, - cancel: CancelBehavior, - blocks_prior_input: bool, - rsc: &mut Rsc, - ) -> OverlayHandle { - self.dismiss(id, rsc, true); - let serial = self.take_serial(); - let widget = popup.id(); - let previous_target = rsc.events().controllers.command_target(); - (self.stack)(rsc).children.push(popup); - self.active = Some(OverlayEntry { - serial, - widget, - previous_target, - cancel, - blocks_prior_input, - }); - rsc.set_command_target(Some(id)); - OverlayHandle { - host: id.host(), - kind: OverlayKind::Single, - serial, - } - } - - fn close(&mut self, id: ControllerId, serial: u64, rsc: &mut Rsc) -> bool { - if self.active.as_ref().map(|entry| entry.serial) != Some(serial) { - return false; - } - self.dismiss(id, rsc, false); - true - } - - fn dismiss( - &mut self, - id: ControllerId, - rsc: &mut Rsc, - replacement: bool, - ) -> bool { - let Some(entry) = self.active.take() else { - return false; - }; - let current_target = rsc.events().controllers.command_target(); - let was_target = current_target == Some(id); - remove_child(self.stack, entry.widget, rsc); - if was_target { - rsc.set_command_target(entry.previous_target); - } - if replacement && entry.cancel == CancelBehavior::PassThrough { - rsc.set_command_target(entry.previous_target); - let _ = rsc.run_command_before(Command::Escape, self.stack); - if !was_target { - rsc.set_command_target(current_target); - } - } - true - } - - fn take_serial(&mut self) -> u64 { - let serial = self.next_serial; - self.next_serial = self.next_serial.wrapping_add(1).max(1); - serial - } -} - -impl Controller for SingleOverlayController { - fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult { - if command != Command::Escape { - return CommandResult::Unused; - } - let Some(entry) = self.active.take() else { - return CommandResult::Unused; - }; - remove_child(self.stack, entry.widget, rsc); - rsc.set_command_target(entry.previous_target); - if entry.cancel == CancelBehavior::PassThrough { - let _ = rsc.run_command(command); - } - CommandResult::Used - } - - fn blocks_prior_input(&self) -> bool { - self.active - .as_ref() - .is_some_and(|entry| entry.blocks_prior_input) - } -} - -pub struct StackableOverlayController { - stack: WeakWidget, - entries: Vec, - next_serial: u64, -} - -impl StackableOverlayController { - fn new(stack: WeakWidget) -> Self { - Self { - stack, - entries: Vec::new(), - next_serial: 1, - } - } - - fn open( - &mut self, - id: ControllerId, - overlay: StrongWidget, - cancel: CancelBehavior, - blocks_prior_input: bool, - rsc: &mut Rsc, - ) -> OverlayHandle { - let serial = self.take_serial(); - let widget = overlay.id(); - let previous_target = rsc.events().controllers.command_target(); - (self.stack)(rsc).children.push(overlay); - self.entries.push(OverlayEntry { - serial, - widget, - previous_target, - cancel, - blocks_prior_input, - }); - rsc.set_command_target(Some(id)); - OverlayHandle { - host: id.host(), - kind: OverlayKind::Stackable, - serial, - } - } - - fn close(&mut self, id: ControllerId, serial: u64, rsc: &mut Rsc) -> bool { - let Some(index) = self.entries.iter().position(|entry| entry.serial == serial) else { - return false; - }; - close_single_at(id.host(), rsc, false); - let previous_target = self.entries[index].previous_target; - for entry in self.entries.drain(index..) { - remove_child(self.stack, entry.widget, rsc); - } - if rsc.events().controllers.command_target() == Some(id) { - rsc.set_command_target(previous_target); - } - true - } - - fn take_serial(&mut self) -> u64 { - let serial = self.next_serial; - self.next_serial = self.next_serial.wrapping_add(1).max(1); - serial - } -} - -impl Controller for StackableOverlayController { - fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult { - if command != Command::Escape { - return CommandResult::Unused; - } - let mut used = false; - loop { - let Some(entry) = self.entries.pop() else { - return if used { - CommandResult::Used - } else { - CommandResult::Unused - }; - }; - used = true; - remove_child(self.stack, entry.widget, rsc); - rsc.set_command_target(entry.previous_target); - if entry.cancel == CancelBehavior::Consume { - return CommandResult::Used; - } - let same_controller = entry.previous_target.is_some_and(|previous| { - previous.host() == self.stack.id() && previous.is::() - }); - if !same_controller { - let _ = rsc.run_command(command); - return CommandResult::Used; - } - } - } - - fn blocks_prior_input(&self) -> bool { - self.entries - .last() - .is_some_and(|entry| entry.blocks_prior_input) - } -} - -fn remove_child(stack: WeakWidget, widget: WidgetId, rsc: &mut Rsc) { - if let Some(stack) = rsc.widgets_mut().get_mut(&stack) - && let Some(index) = stack.children.iter().position(|child| child.id() == widget) - { - stack.children.remove(index); - } -} - -fn close_single_at(host: WidgetId, rsc: &mut Rsc, replacement: bool) { - let Some(id) = rsc.events().controllers.id::(host) else { - return; - }; - rsc.with_controller(id, |single: &mut SingleOverlayController, rsc| { - single.dismiss(id, rsc, replacement); - }); -} - -fn add_host( - rsc: &mut Rsc, - base: StrongWidget, - options: OverlayHostOptions, -) -> WeakWidget { - let stack = Stack { - children: vec![base], - size: StackSize::Default, - } - .add(rsc); - if options.single { - rsc.register_controller(stack, SingleOverlayController::new(stack)); - } - if options.stackable { - rsc.register_controller(stack, StackableOverlayController::new(stack)); - } - stack -} - -pub fn overlay_host( - content: W, - options: OverlayHostOptions, -) -> impl WidgetIdFn -where - Rsc: HasEvents, - W: WidgetLike, -{ - move |rsc: &mut Rsc| { - let content = content.add_strong(rsc); - add_host(rsc, content, options) - } -} - -pub(crate) fn default_overlay_root( - rsc: &mut Rsc, - root: StrongWidget, -) -> StrongWidget { - add_host(rsc, root, OverlayHostOptions::BOTH) - .upgrade(rsc) - .any() -} - -pub trait OverlayRscExt: HasEvents { - fn open_popup(&mut self, origin: impl IdLike, popup: W) -> Option - where - W: WidgetLike, - { - self.open_overlay( - origin, - popup, - OverlayOptions::single(CancelBehavior::PassThrough), - ) - } - - fn open_modal(&mut self, origin: impl IdLike, modal: W) -> Option - where - W: WidgetLike, - { - let render_handle = self.ui().render_state(); - let (target, path) = self - .events() - .controllers - .path_to::(origin.id(), &render_handle.get())?; - clear_singles(&path, self); - - let content = modal.add(self); - let content_strong = content.upgrade(self).any(); - let backdrop = rect(Srgba8::new(0, 0, 0, 96)).add(self); - let backdrop_strong = backdrop.upgrade(self).any(); - let centered = Aligned { - inner: content_strong, - align: Align::CENTER.into(), - } - .add_strong(self) - .any(); - let layer = Stack { - children: vec![backdrop_strong, centered], - size: StackSize::Default, - } - .add_strong(self) - .any(); - let handle = open_stackable_at(target, layer, CancelBehavior::Consume, true, self)?; - - let blocking = modal_pointer_senses(); - self.register_event(content, blocking.clone(), |_, _| {}); - self.register_event(backdrop, blocking, move |ctx, rsc| { - if matches!(ctx.data.sense, CursorSense::PressStart(_)) { - handle.close(rsc); - } - }); - Some(handle) - } - - fn open_overlay( - &mut self, - origin: impl IdLike, - overlay: W, - options: OverlayOptions, - ) -> Option - where - W: WidgetLike, - { - match options.kind { - OverlayKind::Single => { - let render_handle = self.ui().render_state(); - let target = self - .events() - .controllers - .nearest_id::(origin.id(), &render_handle.get())?; - let overlay = overlay.add_strong(self).any(); - self.with_controller(target, |single: &mut SingleOverlayController, rsc| { - single.open( - target, - overlay, - options.cancel, - options.blocks_prior_input, - rsc, - ) - }) - } - OverlayKind::Stackable => { - let render_handle = self.ui().render_state(); - let (target, path) = self - .events() - .controllers - .path_to::(origin.id(), &render_handle.get())?; - clear_singles(&path, self); - let overlay = overlay.add_strong(self).any(); - open_stackable_at( - target, - overlay, - options.cancel, - options.blocks_prior_input, - self, - ) - } - } - } -} - -impl OverlayRscExt for Rsc {} - -fn modal_pointer_senses() -> CursorSenses { - CursorSense::drag_senses() - | CursorSense::PressStart(CursorButton::Right) - | CursorSense::Pressing(CursorButton::Right) - | CursorSense::PressEnd(CursorButton::Right) - | CursorSense::PressStart(CursorButton::Middle) - | CursorSense::Pressing(CursorButton::Middle) - | CursorSense::PressEnd(CursorButton::Middle) - | CursorSense::Scroll(Axis::X) - | CursorSense::Scroll(Axis::Y) -} - -fn clear_singles(path: &[WidgetId], rsc: &mut Rsc) { - let controllers: Vec = path - .iter() - .filter_map(|&host| rsc.events().controllers.id::(host)) - .collect(); - for id in controllers { - rsc.with_controller(id, |single: &mut SingleOverlayController, rsc| { - single.dismiss(id, rsc, true); - }); - } -} - -fn open_stackable_at( - target: ControllerId, - overlay: StrongWidget, - cancel: CancelBehavior, - blocks_prior_input: bool, - rsc: &mut Rsc, -) -> Option { - rsc.with_controller(target, |stackable: &mut StackableOverlayController, rsc| { - stackable.open(target, overlay, cancel, blocks_prior_input, rsc) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::harness::{Harness, HarnessState, TouchAction}; - use std::{cell::Cell, rc::Rc}; - - struct EscapeCounter(Rc>); - - impl Controller> for EscapeCounter { - fn command(&mut self, command: Command, _rsc: &mut StdRsc) -> CommandResult { - if command == Command::Escape { - self.0.set(self.0.get() + 1); - CommandResult::Used - } else { - CommandResult::Unused - } - } - } - - fn stack_len(harness: &Harness, handle: OverlayHandle) -> usize { - harness - .rsc - .widgets() - .get_dyn(handle.host) - .unwrap() - .as_any() - .downcast_ref::() - .unwrap() - .children - .len() - } - - fn rooted_harness() -> (Harness, WeakWidget, Rc>) { - let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0); - let base = rect(PaintId::WHITE).add(&mut harness.rsc); - let count = Rc::new(Cell::new(0)); - harness - .rsc - .register_controller(base, EscapeCounter(count.clone())); - let target = harness - .rsc - .events() - .controllers - .id::(base.id()) - .unwrap(); - harness.rsc.set_command_target(Some(target)); - let root = base.upgrade(&mut harness.rsc).any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(0); - (harness, base, count) - } - - #[test] - fn popup_is_single_and_passes_escape_through() { - let (mut harness, base, escapes) = rooted_harness(); - let first = harness.rsc.open_popup(base, rect(PaintId::RED)).unwrap(); - assert_eq!(stack_len(&harness, first), 2); - - let second = harness.rsc.open_popup(base, rect(PaintId::BLUE)).unwrap(); - assert_eq!(escapes.get(), 1, "replacement reaches the old owner"); - assert!(!first.close(&mut harness.rsc), "a replaced handle is stale"); - assert_eq!(stack_len(&harness, second), 2); - - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!( - escapes.get(), - 1, - "the replacement had no prior command target" - ); - assert_eq!(stack_len(&harness, second), 1); - } - - #[test] - fn stackables_survive_singles_and_close_from_the_top() { - let (mut harness, base, escapes) = rooted_harness(); - let options = OverlayOptions::stackable(CancelBehavior::Consume); - let first = harness - .rsc - .open_overlay(base, rect(PaintId::RED), options) - .unwrap(); - let single = harness.rsc.open_popup(base, rect(PaintId::GREEN)).unwrap(); - let second = harness - .rsc - .open_overlay(base, rect(PaintId::BLUE), options) - .unwrap(); - - assert!(!single.close(&mut harness.rsc)); - assert_eq!(stack_len(&harness, second), 3); - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!(stack_len(&harness, first), 2); - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!(stack_len(&harness, first), 1); - assert_eq!(escapes.get(), 0, "stackable cancellation is consumed"); - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!(escapes.get(), 1); - } - - #[test] - fn an_explicit_single_can_consume_cancellation() { - let (mut harness, base, escapes) = rooted_harness(); - let overlay = harness - .rsc - .open_overlay( - base, - rect(PaintId::RED), - OverlayOptions::single(CancelBehavior::Consume), - ) - .unwrap(); - - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!(escapes.get(), 0); - assert_eq!(stack_len(&harness, overlay), 1); - } - - #[test] - fn a_stackable_can_pass_cancellation_to_the_entry_under_it() { - let (mut harness, base, escapes) = rooted_harness(); - let first = harness - .rsc - .open_overlay( - base, - rect(PaintId::RED), - OverlayOptions::stackable(CancelBehavior::Consume), - ) - .unwrap(); - harness - .rsc - .open_overlay( - base, - rect(PaintId::BLUE), - OverlayOptions::stackable(CancelBehavior::PassThrough), - ) - .unwrap(); - - assert_eq!( - harness.rsc.run_command(Command::Escape), - CommandResult::Used - ); - assert_eq!(stack_len(&harness, first), 1); - assert_eq!(escapes.get(), 0); - } - - #[test] - fn closing_a_stackable_handle_closes_everything_above_it() { - let (mut harness, base, _) = rooted_harness(); - let options = OverlayOptions::stackable(CancelBehavior::Consume); - let first = harness - .rsc - .open_overlay(base, rect(PaintId::RED), options) - .unwrap(); - let second = harness - .rsc - .open_overlay(base, rect(PaintId::BLUE), options) - .unwrap(); - let single = harness.rsc.open_popup(base, rect(PaintId::GREEN)).unwrap(); - - assert!(first.close(&mut harness.rsc)); - assert!(!second.close(&mut harness.rsc)); - assert!(!single.close(&mut harness.rsc)); - assert_eq!(stack_len(&harness, first), 1); - } - - #[test] - fn backdrop_dismisses_modal_without_clicking_the_base() { - let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0); - let clicks = Rc::new(Cell::new(0)); - let base = rect(PaintId::WHITE).add(&mut harness.rsc); - let counted = clicks.clone(); - harness - .rsc - .register_event(base, CursorSense::click(), move |_, _| { - counted.set(counted.get() + 1); - }); - let root = base.upgrade(&mut harness.rsc).any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(0); - - let modal = harness - .rsc - .open_modal(base, rect(PaintId::RED).sized((80, 80))) - .unwrap(); - harness.frame(1); - assert_eq!(stack_len(&harness, modal), 2); - assert!( - harness - .rsc - .events() - .controllers - .command_target_blocks_input() - ); - harness.touch(TouchAction::Down, Vec2::new(100.0, 150.0), 2); - harness.touch(TouchAction::Up, Vec2::new(100.0, 150.0), 3); - assert_eq!(stack_len(&harness, modal), 2); - harness.touch(TouchAction::Down, Vec2::new(5.0, 5.0), 4); - - assert_eq!(clicks.get(), 0); - assert_eq!(stack_len(&harness, modal), 1); - assert!( - !harness - .rsc - .events() - .controllers - .command_target_blocks_input() - ); - } - - #[test] - fn local_single_host_passes_a_modal_to_the_root() { - let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0); - let leaf = rect(PaintId::WHITE).add(&mut harness.rsc); - let local = overlay_host(leaf, OverlayHostOptions::SINGLE).add(&mut harness.rsc); - let root = local.upgrade(&mut harness.rsc).any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(0); - - let popup = harness.rsc.open_popup(leaf, rect(PaintId::RED)).unwrap(); - assert_eq!(popup.host, local.id()); - let modal = harness - .rsc - .open_modal(leaf, rect(PaintId::BLUE).sized((80, 80))) - .unwrap(); - - assert!(!popup.close(&mut harness.rsc)); - assert_ne!(modal.host, local.id()); - } - - #[test] - fn replacing_a_local_single_does_not_cancel_an_ancestor_overlay() { - let mut harness = Harness::new(Vec2::new(200.0, 300.0), 1.0); - let leaf = rect(PaintId::WHITE).add(&mut harness.rsc); - let local = overlay_host(leaf, OverlayHostOptions::SINGLE).add(&mut harness.rsc); - let root = local.upgrade(&mut harness.rsc).any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(0); - - let first_modal = harness - .rsc - .open_modal(leaf, rect(PaintId::RED).sized((80, 80))) - .unwrap(); - harness.frame(1); - let popup = harness.rsc.open_popup(leaf, rect(PaintId::GREEN)).unwrap(); - let second_modal = harness - .rsc - .open_modal(leaf, rect(PaintId::BLUE).sized((80, 80))) - .unwrap(); - - assert!(!popup.close(&mut harness.rsc)); - assert_eq!(stack_len(&harness, first_modal), 3); - assert!(second_modal.close(&mut harness.rsc)); - assert_eq!(stack_len(&harness, first_modal), 2); - } - - #[test] - fn inactive_origins_have_no_overlay_route() { - let (mut harness, _, _) = rooted_harness(); - let inactive = rect(PaintId::WHITE).add(&mut harness.rsc); - assert!( - harness - .rsc - .open_popup(inactive, rect(PaintId::RED)) - .is_none() - ); - } - - #[test] - fn replacing_the_root_forgets_the_old_routing_tree() { - let (mut harness, old, _) = rooted_harness(); - let new = rect(PaintId::WHITE).add(&mut harness.rsc); - let root = new.upgrade(&mut harness.rsc).any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(1); - - assert!(harness.rsc.open_popup(old, rect(PaintId::RED)).is_none()); - assert!(harness.rsc.open_popup(new, rect(PaintId::RED)).is_some()); - } -} diff --git a/iris/src/rsc/sense.rs b/iris/src/rsc/sense.rs deleted file mode 100644 index fae2321..0000000 --- a/iris/src/rsc/sense.rs +++ /dev/null @@ -1,2313 +0,0 @@ -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(Axis), - /// Terminal event replacing `PressEnd` for the pointer captor. - Drop, - /// Another widget captured this press; abandon it without acting. - Cancel, -} - -#[derive(Clone)] -pub struct CursorSenses { - senses: Vec, - drag_axis: Option, -} - -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> { - // Capture routing has already selected these exact terminal events; - // deriving again could match PressEnd or Pressing first. - 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, - data.drag_axis, - data.captured, - ) { - let mut data = data.clone(); - data.sense = sense; - Some(data) - } else { - None - } - } -} - -impl CursorSenses { - fn consumes(&self, data: &CursorData<'_>, momentary_active: bool) -> bool { - if !momentary_active { - return true; - } - let Some(sense) = should_run( - self, - &data.cursor, - data.hover, - data.drag_axis, - data.captured, - ) else { - return false; - }; - match (self.drag_axis, sense) { - (Some(axis), CursorSense::Pressing(_)) => data.captured || data.drag_axis == Some(axis), - (Some(_), CursorSense::PressStart(_) | CursorSense::PressEnd(_)) => false, - _ => sense.is_momentary(), - } - } -} - -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) - } - /// Drag frames and both terminal outcomes: `Drop` and `Cancel`. - pub fn drag_senses() -> CursorSenses { - Self::click_or_drag() | Self::unclick() | Self::Drop | Self::Cancel - } - - pub fn drag(axis: Axis) -> CursorSenses { - let mut senses = Self::drag_senses(); - senses.drag_axis = Some(axis); - senses - } - pub fn is_dragging(&self) -> bool { - matches!(self, CursorSense::Pressing(CursorButton::Left)) - } - - /// Whether this event may consume input; ambient hover never does. - 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, - pub time: Instant, - /// Platform cancellation, not a release; it must never produce a fling. - 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, -} - -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> { - pub pos: Vec2, - pub size: Vec2, - pub scroll_delta: Vec2, - pub hover: ActivationState, - pub cursor: CursorState, - /// The direction selected after this press crossed [`DRAG_SLOP`]. - /// `None` while the gesture is still only a press. - pub drag_axis: Option, - pub captured: bool, - pub sense: CursorSense, - pub render: &'a UiRenderState, - /// Pointer-capture state for this dispatch. - pub pointer: &'a PointerRequests, -} - -/// Dispatch-wide capture and in-flight press state. Capture keeps routing a -/// gesture to one widget after the pointer leaves its hit region. -#[derive(Default)] -pub struct PointerInput { - captured: Option, - pressed: Vec, - press_origin: Option, - drag_axis: Option, -} - -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 input. It must outlive the gesture, unlike a - /// virtualized row; replacing a holder sends the old one `Cancel`. - pub fn capture(&self, id: WidgetId) { - self.holder.set(Some(id)); - } - - /// Give up exclusive pointer input. - pub fn release(&self) { - self.holder.set(None); - } - - /// The current holder, used to avoid releasing another widget's capture. - 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, - ) { - self.note_input(cursor.time); - - 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(); - if cursor.buttons.left.is_start() { - pointer.press_origin = Some(cursor.pos); - pointer.drag_axis = None; - } else if button_down - && pointer.drag_axis.is_none() - && let Some(origin) = pointer.press_origin - { - let moved = cursor.pos - origin; - if moved.x.abs().max(moved.y.abs()) > DRAG_SLOP { - pointer.drag_axis = Some(if moved.x.abs() > moved.y.abs() { - Axis::X - } else { - Axis::Y - }); - } - } - - // Platform cancellation reaches every tracker and produces no other sense. - if cursor.cancelled { - let captured = pointer.captured.take(); - requests.release(); - pointer.press_origin = None; - pointer.drag_axis = None; - if let Some(id) = captured { - deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); - } - for id in pointer.pressed.drain(..) { - if Some(id) != captured { - deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); - } - } - rsc.events_mut().get_type::().global = pointer; - return; - } - - // Capture bypasses hit testing until release, when its holder receives one Drop. - if let Some(id) = requests.holder() { - 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(), - drag_axis: pointer.drag_axis, - captured: true, - sense, - render: self, - pointer: &requests, - }; - rsc.run_event::(id, data, state); - if !button_down { - requests.release(); - pointer.pressed.clear(); - pointer.press_origin = None; - pointer.drag_axis = None; - } - pointer.captured = requests.holder(); - rsc.events_mut().get_type::().global = pointer; - return; - } - - 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); - // A point must be in both the widget's box and its rendered mask chain. - 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; - } - - // Idle hover stops at the topmost widget. Momentary input stops only at a - // listener for that input, so click-only children do not block scrolling. - 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, - drag_axis: pointer.drag_axis, - captured: false, - sense: CursorSense::Hovering, - render: self, - pointer: &requests, - }; - let consumes = rsc - .events_mut() - .get_type::() - .registered(*id) - .any(|senses| senses.consumes(&data, momentary_active)); - if consumes { - sensed = true; - } - rsc.run_event::(*id, data, state); - // Any pressed listener may hold gesture state and is owed Cancel if it loses. - if button_down && !pointer.pressed.contains(id) { - pointer.pressed.push(*id); - } - } - if sensed || requests.holder().is_some() { - break; - } - } - rsc.events_mut().get_type::().active = active; - - pointer.captured = requests.holder(); - match pointer.captured { - Some(winner) => { - let mut winner_was_pressed = false; - for id in pointer.pressed.drain(..) { - if id == winner { - winner_was_pressed = true; - } else { - deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); - } - } - if winner_was_pressed { - pointer.pressed.push(winner); - } - } - None if !button_down => pointer.pressed.clear(), - None => {} - } - pointer.captured = requests.holder(); - if !button_down { - pointer.press_origin = None; - pointer.drag_axis = None; - } - rsc.events_mut().get_type::().global = pointer; - } -} - -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(), - drag_axis: None, - captured: false, - sense: CursorSense::Cancel, - render, - pointer, - }; - rsc.run_event::(id, data, state); -} - -pub fn should_run( - senses: &CursorSenses, - cursor: &CursorState, - hover: ActivationState, - drag_axis: Option, - captured: bool, -) -> Option { - 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(axis) => { - on_this - && cursor.scroll_delta.axis(*axis) != 0.0 - && cursor.scroll_delta.axis(*axis).abs() - >= cursor.scroll_delta.axis(!*axis).abs() - } - // Capture routing supplies terminal events; raw button state must not derive them. - CursorSense::Drop | CursorSense::Cancel => false, - } && (captured - || !matches!(sense, CursorSense::Pressing(_)) - || senses.drag_axis.is_none() - || senses.drag_axis == drag_axis) - { - 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.senses - } -} - -impl DerefMut for CursorSenses { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.senses - } -} - -impl From for CursorSenses { - fn from(val: CursorSense) -> Self { - CursorSenses { - senses: vec![val], - drag_axis: None, - } - } -} - -impl BitOr for CursorSense { - type Output = CursorSenses; - - fn bitor(self, rhs: Self) -> Self::Output { - CursorSenses { - senses: vec![self, rhs], - drag_axis: None, - } - } -} - -impl BitOr for CursorSenses { - type Output = Self; - - fn bitor(mut self, rhs: CursorSense) -> Self::Output { - self.senses.push(rhs); - self - } -} - -/// Writes the same action vocabulary and relative-millisecond clock that -/// [`crate::harness::TouchScript::parse`] reads. Diagnostics explicitly gate it. -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 Android touch and Choreographer timestamps from their shared -/// monotonic clock so gesture velocity and animation use the same timeline. -#[derive(Clone, Copy)] -pub struct DeviceClock { - anchor_at: Instant, - anchor_nanos: i64, - last_nanos: i64, -} - -impl DeviceClock { - /// `oldest` anchors a historical batch; it equals `event_time` without one. - 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 { - // Very early process timestamps may precede the representable Instant range. - anchor_at: now.checked_sub(batch_span).unwrap_or(now), - anchor_nanos: oldest, - last_nanos: oldest, - } - } - - 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) - } - - pub fn at(&self, nanos: i64) -> Instant { - self.anchor_at + Duration::from_nanos(nanos.saturating_sub(self.anchor_nanos).max(0) as u64) - } - - pub fn ms_since_anchor(&self, nanos: i64) -> u64 { - (nanos.saturating_sub(self.anchor_nanos).max(0) as u64) / 1_000_000 - } -} - -pub const LONG_PRESS: Duration = Duration::from_millis(500); -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, - Pan(f32), - SelectStart, - SelectExtend, -} - -/// Target state that determines which gestures a new press may become. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct PressState { - pub already_selected: bool, - pub scrolling: bool, -} - -#[derive(Clone, Copy, PartialEq)] -enum ArbiterState { - Idle, - Undecided { already_selected: bool }, - Panning, - Selecting, -} - -/// Chooses between axial panning, long-press selection, and immediate -/// cross-axis extension of an existing selection. -pub struct DragArbiter { - state: ArbiterState, - axis: Axis, - origin: Vec2, - origin_at: Instant, - last: Vec2, -} - -impl Default for DragArbiter { - fn default() -> Self { - Self::on(Axis::Y) - } -} - -impl DragArbiter { - pub fn new() -> Self { - Self::default() - } - - pub fn on(axis: Axis) -> Self { - Self { - state: ArbiterState::Idle, - axis, - origin: Vec2::ZERO, - origin_at: Instant::now(), - last: Vec2::ZERO, - } - } - - /// Begins a press. Touching moving content commits immediately to panning - /// so the press catches the current fling without slop or long-press delay. - 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 no press is in flight, including after a missed `PressStart`. - pub fn is_idle(&self) -> bool { - matches!(self.state, ArbiterState::Idle) - } - - /// Idle cannot infer a missed `PressStart`; callers receiving `Pressing` - /// while idle must open the press first. - 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, - ); - } - // Consume the slop once; replaying the whole withheld distance visibly jumps. - 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 - } - } - } - } - - pub fn release(&mut self) { - self.state = ArbiterState::Idle; - } - - /// Whether release may turn the tracked velocity into a fling. - pub fn is_panning(&self) -> bool { - matches!(self.state, ArbiterState::Panning) - } - - pub fn axis(&self) -> Axis { - self.axis - } - - pub fn is_undecided(&self) -> bool { - matches!(self.state, ArbiterState::Undecided { .. }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum GestureOutcome { - Undecided, - Pan(f32), - SelectStart, - SelectExtend, - /// The press never committed to a pan or selection. - Tapped, - Released(Option), - Cancelled, -} - -pub struct DragGesture { - arbiter: DragArbiter, - velocity: VelocityTracker, - /// A caught fling that has not moved: consumed as a pan, but with no release velocity. - catch_unmoved: bool, -} - -impl Default for DragGesture { - fn default() -> Self { - Self::new() - } -} - -impl DragGesture { - pub fn new() -> Self { - Self::on(Axis::Y) - } - - pub fn on(axis: Axis) -> Self { - Self { - arbiter: DragArbiter::on(axis), - velocity: VelocityTracker::new(), - catch_unmoved: false, - } - } - - pub fn is_idle(&self) -> bool { - self.arbiter.is_idle() - } - - /// Whether this non-terminal frame opens a press. Multiple sensors may - /// deliver one `PressStart`, so an in-flight press must not restart. - 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 { - 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(_) => { - let released = self.velocity.velocity(); - 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) - }; - 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, - ); - 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(); - 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 { - self.catch_unmoved = false; - } - 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 - } - } - } -} - -const HISTORY_SIZE: usize = 20; -const HORIZON_MS: f32 = 100.0; -const ASSUME_POINTER_MOVE_STOPPED_MS: f32 = 40.0; -const MIN_SAMPLE_SIZE: usize = 3; -const DEGENERATE_NORM: f32 = 0.000001; -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; - -/// The numbers its tests assert on come from -/// `iris/scripts/reference/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(); - } - - pub fn add_position(&mut self, position: f32, at: Instant) { - 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, - } - } - - 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`]). - 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; - 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 } - } -} - -/// 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. -/// `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(); - - 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]; - } - } - - 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]; - } - } - 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]) - }; - } - } - - 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() -} - -/// **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. -mod android_fling_spline { - use std::sync::OnceLock; - - const NB_SAMPLES: usize = 100; - 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); - 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 { - 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, - } - } -} - -const FLING_FRICTION: f32 = 0.015; -const FLING_TUNING: f32 = 0.84; -fn deceleration_rate() -> f32 { - (0.78f32.ln()) / (0.9f32.ln()) -} -const GRAVITY_EARTH: f32 = 9.80665; - -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() - } - - pub fn distance(&self, velocity: f32) -> f32 { - 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) - } - - pub fn duration(&self, velocity: f32) -> Duration { - 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 scrolling widget uses to find how far it should - /// have moved by this frame. 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 - } - - 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() - } -} - -/// 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::advance`]'s delta in its own convention and -/// calls [`Self::stop`] when it runs out of content. -pub struct Flinger { - fling: Option, -} - -struct InFlight { - calc: FlingCalculator, - velocity: f32, - /// When the curve begins -- **the first [`Flinger::advance`], 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::advance`]'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. - /// - /// Cancels any fling already in progress. A widget which owns one advances - /// it during `draw` and asks its painter for the following frame. - /// - /// 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 { - 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 advance(&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; - 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; - - static BASE: LazyLock = LazyLock::new(Instant::now); - - fn t(ms: u64) -> Instant { - *BASE + Duration::from_millis(ms) - } - - fn tracker(samples: &[(u64, f32)]) -> VelocityTracker { - let mut v = VelocityTracker::new(); - for &(ms, position) in samples { - v.add_position(position, t(ms)); - } - v - } - - 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}" - ); - } - - 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() { - let samples: Vec<(u64, f32)> = (0..=10).map(|i| (i * 10, (i * 5) as f32)).collect(); - assert_velocity(&samples, 500.0); - } - - #[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() { - 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() { - 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() { - 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() { - 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)) - ); - } - - #[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, - ); - 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() { - 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); - } - - #[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}" - ); - } - } - - #[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); - } - assert_eq!( - calc.position_at(velocity, duration + Duration::from_secs(5)), - total - ); - } - - #[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}" - ); - } - 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); - } - - #[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 { - 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()); - assert_eq!( - a.update(Vec2::new(0.0, 20.0), t(10)), - DragOutcome::Pan(12.0) - ); - assert_eq!( - a.update(Vec2::new(0.0, 35.0), t(20)), - DragOutcome::Pan(15.0) - ); - } - - #[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()); - 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 - ); - 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) - ); - } - - #[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() { - 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(); - 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()); - } - - #[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()); - } - - #[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()); - } - - #[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()); - } - - #[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 - ); - - 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 - ); - } -} - -#[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) - } - - fn pointer() -> PointerRequests { - PointerRequests::default() - } - - fn some_id(ui: &mut UiData) -> WidgetId { - ui.widgets.add_strong(Rect::new(PaintId::WHITE)).id() - } - - #[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(), - ); - - 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:?}"), - } - } - - #[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))); - } - - #[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); - } - - #[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(), - ); - 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)); - } - - #[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, - ), - GestureOutcome::Pan(0.0), - ); - assert_eq!( - g.handle( - &r, - id, - CursorSense::Pressing(CursorButton::Left), - Vec2::new(0.0, 2.0), - t(8), - caught, - ), - GestureOutcome::Pan(2.0), - ); - } - - #[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, - ); - } - - #[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)); - } - - #[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:?}" - ); - } - - #[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/iris/src/rsc/sense_tests.rs b/iris/src/rsc/sense_tests.rs deleted file mode 100644 index 67a92c7..0000000 --- a/iris/src/rsc/sense_tests.rs +++ /dev/null @@ -1,684 +0,0 @@ -use crate::prelude::*; -use std::{cell::Cell, rc::Rc, time::Instant}; - -struct SenseRsc { - ui: Ui, - events: EventManager, -} - -impl UiRsc for SenseRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &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 = (); -} - -#[derive(Clone)] -struct RenderProbe; - -impl Event for RenderProbe {} - -#[test] -fn an_event_can_read_the_completed_frame_while_mutating_ui_data() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - rsc.ui.resize((100.0, 100.0)); - let widget = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let weak = widget.weak(); - let saw_active = Rc::new(Cell::new(false)); - rsc.register_event(weak, RenderProbe, { - let saw_active = saw_active.clone(); - move |_, rsc| { - let render_handle = rsc.ui().render_state(); - let render_state = render_handle.get(); - saw_active.set(render_state.active.contains_key(&weak.id())); - rsc.widgets_mut().set_label(weak, "changed".to_string()); - assert!(render_state.active.contains_key(&weak.id())); - } - }); - rsc.draw(&widget.any()); - - rsc.run_event::(weak, (), &mut ()); - - assert!(saw_active.get()); - assert_eq!(rsc.widgets().label(weak), "changed"); -} - -#[test] -#[should_panic(expected = "render state cannot be mutated while it is being read")] -fn an_event_cannot_start_a_draw() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - let widget = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let weak = widget.weak(); - rsc.register_event(weak, RenderProbe, |_, rsc| rsc.draw(None)); - - rsc.run_event::(weak, (), &mut ()); -} - -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: Ui::default(), - events: EventManager::default(), - }; - - // A button nested over a scrolling surface must participate in both gestures. - let list = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let list_weak = list.weak(); - let button = rsc.ui.widgets.add_strong(Rect::new(PaintId::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(Axis::Y), - move |_ctx, _rsc| { - scrolled.set(true); - }, - ); - } - { - let clicked = clicked.clone(); - rsc.register_event(button_weak, CursorSense::click(), move |_ctx, _rsc| { - clicked.set(true); - }); - } - - 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" - ); -} - -#[test] -fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - - let draggable = rsc.ui.widgets.add_strong(Rect::new(PaintId::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(_) => { - 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" - ); - - 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" - ); -} - -#[test] -fn capturing_one_widget_starves_every_other_widget_of_events() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - - let a = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let a_weak = a.weak(); - let b = rsc.ui.widgets.add_strong(Rect::new(PaintId::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" - ); -} - -#[test] -fn a_finger_drag_over_a_scroll_area_pans_it() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - - let scroll_strong = rect(PaintId::WHITE) - .height(LayoutLen::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); - 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" - ); - - 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" - ); - - 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}" - ); - - assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id())); -} - -#[test] -fn the_first_events_batched_samples_are_dated_apart() { - const MS: i64 = 1_000_000; - let now = Instant::now(); - let clock = DeviceClock::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); -} - -#[test] -fn the_clock_orders_samples_across_events() { - const MS: i64 = 1_000_000; - let mut clock = DeviceClock::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) - ); -} - -#[test] -fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - let scroll_strong = rect(PaintId::WHITE) - .height(LayoutLen::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); - }; - - 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" - ); - - 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, - ); -} - -#[test] -fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - - let capturer = rsc.ui.widgets.add_strong(Rect::new(PaintId::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(Axis::X), - 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(Axis::Y), - 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((80.0, 50.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" - ); - - 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" - ); -} - -#[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: Ui::default(), - events: EventManager::default(), - }; - let seen = Rc::new(Cell::new(None)); - let record = seen.clone(); - let outer_strong = rect(PaintId::WHITE) - .width(LayoutLen::abs(1000.0)) - .height(LayoutLen::abs(1000.0)) - .scrollable(Axis::X, Pin::Start) - .with_id(move |_rsc, id| { - record.set(Some(id)); - id - }) - .layer_offset(1) - .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); - 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" - ); - } -} - -#[test] -fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { - let mut rsc = SenseRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - - let seen: [Rc>>>; 2] = Default::default(); - let half = |slot: &Rc>>>| { - let record = slot.clone(); - rect(PaintId::WHITE) - .height(LayoutLen::abs(1000.0)) - .scrollable(Axis::Y, Pin::Start) - .with_id(move |_rsc, id| { - record.set(Some(id)); - id - }) - .height(LayoutLen::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); - 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 = (); - 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); - let frame_time = c.time; - render.run_sensors(rsc, state, c, win); - render.update_at(&root, rsc, frame_time); - }; - 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", - ); - 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!( - rsc.ui.widgets.get(&top_w).unwrap().is_scrolling(), - "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/iris/src/rsc/state.rs b/iris/src/rsc/state.rs deleted file mode 100644 index 95139a4..0000000 --- a/iris/src/rsc/state.rs +++ /dev/null @@ -1,111 +0,0 @@ -use iris_core::{ - UiRsc, WidgetId, - util::{HashMap, HashSet}, -}; -use iris_core::{WeakWidget, Widget}; -use std::{ - any::{Any, TypeId}, - marker::PhantomData, -}; - -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -struct Key { - id: WidgetId, - ty: TypeId, - i: usize, -} - -#[derive(Default)] -pub struct WidgetState { - widgets: HashMap>, - counts: HashMap<(WidgetId, TypeId), usize>, - map: HashMap>, -} - -impl WidgetState { - pub fn new() -> Self { - Self::default() - } - pub fn add(&mut self, id: WidgetId, data: T) -> WeakState { - let ty = TypeId::of::(); - let count = self.counts.entry((id, ty)).or_default(); - let i = *count; - let key = Key { ty, i, id }; - self.map.insert(key, Box::new(data)); - self.widgets.entry(id).or_default().insert((ty, i)); - *count += 1; - WeakState { - key, - _pd: PhantomData, - } - } - pub fn remove(&mut self, id: WidgetId) { - for &(ty, i) in self.widgets.get(&id).into_iter().flatten() { - self.map.remove(&Key { id, ty, i }); - } - } - pub fn get(&self, state: WeakState) -> &T { - self.map.get(&state.key).unwrap().downcast_ref().unwrap() - } - pub fn get_mut(&mut self, state: WeakState) -> &mut T { - self.map - .get_mut(&state.key) - .unwrap() - .downcast_mut() - .unwrap() - } -} - -#[derive(Clone, Copy)] -pub struct WeakState { - key: Key, - _pd: PhantomData, -} - -pub trait HasWidgetState { - fn widget_state(&self) -> &WidgetState; - fn widget_state_mut(&mut self) -> &mut WidgetState; -} - -impl<'a, T: 'static> FnOnce<(&'a mut WidgetState,)> for WeakState { - type Output = &'a mut T; - - extern "rust-call" fn call_once(self, (state,): (&'a mut WidgetState,)) -> Self::Output { - 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. -/// `StdRsc` supplies the shared `Index`/`IndexMut` implementation; a blanket -/// implementation over every possible resource type would conflict. -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/iris/src/rsc/task.rs b/iris/src/rsc/task.rs deleted file mode 100644 index 71b293f..0000000 --- a/iris/src/rsc/task.rs +++ /dev/null @@ -1,153 +0,0 @@ -use iris_core::HasState; -use std::{ - pin::Pin, - sync::{ - Arc, - mpsc::{Receiver as SyncReceiver, Sender as SyncSender, channel as sync_channel}, - }, -}; -use tokio::{ - runtime::Runtime, - sync::mpsc::{ - UnboundedReceiver as AsyncReceiver, UnboundedSender as AsyncSender, - unbounded_channel as async_channel, - }, -}; - -/// Waits without blocking the task runtime's worker thread. -pub async fn sleep(duration: std::time::Duration) { - tokio::time::sleep(duration).await; -} - -/// Wakes the platform UI thread so it can apply queued task updates. -/// -/// Waking does not itself mean drawing. Once the updates have run, the host -/// asks the retained UI tree whether anything visible became dirty and only -/// then schedules a frame. -pub trait WakeTaskQueue: Send + Sync + 'static { - fn wake(&self); -} - -pub type TaskMsgSender = SyncSender>>; -pub type TaskMsgReceiver = SyncReceiver>>; - -pub trait TaskUpdate: FnOnce(&mut Rsc::State, &mut Rsc) + Send {} -impl TaskUpdate for F {} - -pub struct Tasks { - start: AsyncSender, - wake: Arc, - msg_send: SyncSender>>, -} - -pub struct TaskCtx { - send: TaskMsgSender, - wake: Arc, -} - -impl Clone for TaskCtx { - fn clone(&self) -> Self { - Self { - send: self.send.clone(), - wake: self.wake.clone(), - } - } -} - -impl TaskCtx { - pub fn update(&mut self, f: impl TaskUpdate + 'static) { - if self.send.send(Box::new(f)).is_ok() { - self.wake.wake(); - } - } -} -impl TaskCtx { - fn new(send: TaskMsgSender, wake: Arc) -> Self { - Self { send, wake } - } -} - -type BoxTask = Pin + Send>>; - -impl Tasks { - pub fn init(wake: Arc) -> (Self, TaskMsgReceiver) { - let (start, start_recv) = async_channel(); - let (msgs, msgs_recv) = sync_channel(); - std::thread::spawn(|| { - let rt = Runtime::new().unwrap(); - rt.block_on(listen(start_recv)) - }); - ( - Self { - start, - msg_send: msgs, - wake, - }, - msgs_recv, - ) - } - - /// A cloneable, platform-neutral way for an application-owned thread to - /// submit work to the UI thread. - pub fn context(&self) -> TaskCtx { - TaskCtx::new(self.msg_send.clone(), self.wake.clone()) - } - - pub fn spawn) + 'static + std::marker::Send>(&mut self, task: F) - where - F::CallOnceFuture: Send, - { - let send = self.msg_send.clone(); - let wake = self.wake.clone(); - let _ = self.start.send(Box::pin(async move { - task(TaskCtx::new(send, wake)).await; - })); - } -} - -async fn listen(mut recv: AsyncReceiver) { - while let Some(task) = recv.recv().await { - tokio::spawn(task); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{ - sync::atomic::{AtomicUsize, Ordering}, - time::Duration, - }; - - struct TestRsc; - - impl HasState for TestRsc { - type State = usize; - } - - #[derive(Default)] - struct WakeCounter(AtomicUsize); - - impl WakeTaskQueue for WakeCounter { - fn wake(&self) { - self.0.fetch_add(1, Ordering::Relaxed); - } - } - - #[test] - fn every_update_wakes_the_ui_queue() { - let wakes = Arc::new(WakeCounter::default()); - let (tasks, updates) = Tasks::::init(wakes.clone()); - let mut ctx = tasks.context(); - - ctx.update(|state: &mut usize, _| *state += 1); - ctx.update(|state: &mut usize, _| *state += 2); - - assert_eq!(wakes.0.load(Ordering::Relaxed), 2); - let mut state = 0; - let mut rsc = TestRsc; - updates.recv_timeout(Duration::from_secs(1)).unwrap()(&mut state, &mut rsc); - updates.recv_timeout(Duration::from_secs(1)).unwrap()(&mut state, &mut rsc); - assert_eq!(state, 3); - } -} diff --git a/iris/src/widget/image.rs b/iris/src/widget/image.rs deleted file mode 100644 index 77fe446..0000000 --- a/iris/src/widget/image.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::prelude::*; -use image::DynamicImage; - -pub struct Image { - handle: TextureHandle, -} - -impl Widget for Image { - fn draw(&mut self, painter: &mut Painter) { - let size = self.handle.size(); - painter.texture_within(&self.handle, size.align(Align::TOP_LEFT)); - painter.set_size(Size::abs(size)); - } - - fn size_hint(&self, axis: Axis) -> Option { - Some(LayoutLen::abs(self.handle.size().axis(axis))) - } - - fn is_size_independent(&self) -> bool { - true - } -} - -pub fn image(image: impl LoadableImage) -> impl WidgetFn { - let image = image.get_image().expect("Failed to load image"); - move |state| Image { - handle: state.ui_mut().textures.add(image), - } -} - -pub trait LoadableImage { - fn get_image(self) -> Result; -} - -impl LoadableImage for &str { - fn get_image(self) -> Result { - image::open(self).map_err(|e| format!("{e:?}")) - } -} - -impl LoadableImage for String { - fn get_image(self) -> Result { - image::open(self).map_err(|e| format!("{e:?}")) - } -} - -impl LoadableImage for &[u8; LEN] { - fn get_image(self) -> Result { - image::load_from_memory(self).map_err(|e| format!("{e:?}")) - } -} - -impl LoadableImage for DynamicImage { - fn get_image(self) -> Result { - Ok(self) - } -} diff --git a/iris/src/widget/layout/align.rs b/iris/src/widget/layout/align.rs deleted file mode 100644 index fe166a5..0000000 --- a/iris/src/widget/layout/align.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::prelude::*; - -pub struct Aligned { - pub inner: StrongWidget, - pub align: Align, -} - -impl Widget for Aligned { - fn draw(&mut self, painter: &mut Painter) { - let used = painter.widget(&self.inner).size(); - let density = painter.density(); - let (x, y) = self.align.tuple(); - let region = UiRegion::new( - used.x - .apply_rest(density) - .align(x.unwrap_or(AxisAlign::Neg)), - used.y - .apply_rest(density) - .align(y.unwrap_or(AxisAlign::Neg)), - ); - painter.place(&self.inner, region); - painter.set_size(used); - } -} diff --git a/iris/src/widget/layout/layer.rs b/iris/src/widget/layout/layer.rs deleted file mode 100644 index 47cf790..0000000 --- a/iris/src/widget/layout/layer.rs +++ /dev/null @@ -1,17 +0,0 @@ -use crate::prelude::*; - -pub struct LayerOffset { - pub inner: StrongWidget, - pub offset: usize, -} - -impl Widget for LayerOffset { - fn draw(&mut self, painter: &mut Painter) { - for _ in 0..self.offset { - painter.next_layer(); - } - let used = painter.widget(&self.inner).size(); - painter.place_used(&self.inner, used, UiRegion::FULL); - painter.set_size(used); - } -} diff --git a/iris/src/widget/layout/lazy_span.rs b/iris/src/widget/layout/lazy_span.rs deleted file mode 100644 index 9e9d2eb..0000000 --- a/iris/src/widget/layout/lazy_span.rs +++ /dev/null @@ -1,1689 +0,0 @@ -use crate::prelude::*; -use iris_core::util::HashMap; -use std::collections::VecDeque; - -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 } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Edge { - Leading, - Trailing, -} - -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; -const GENEROUS_PADDING: f32 = 100_000.0; -const CONTENT_OFFSET_REBASE: f32 = 65_536.0; - -#[derive(Debug, Clone, Copy)] -struct Anchor { - slot: isize, - edge: Edge, - offset: f32, -} - -#[derive(Debug, Clone, Copy)] -struct RowExtent { - slot: isize, - lead: f32, - trail: f32, -} - -#[derive(Clone, Copy)] -enum Placement { - Leading(f32), - Trailing(f32), -} - -impl Placement { - fn edges(self, height: f32) -> (f32, f32) { - match self { - Placement::Leading(lead) => (lead, lead + height), - Placement::Trailing(trail) => (trail - height, trail), - } - } -} - -pub struct LazySpan { - dir: Dir, - items: VecDeque, - more_before: Option, - more_after: Option, - anchor: Option, - ctl: ScrollController, - viewport_len: f32, - last_viewport_len: f32, - pending_tap: Option, - extents: HashMap, - heights: HashMap, - content_offset: f32, - at_start: bool, - at_end: bool, - content_lead: f32, - content_trail: f32, - no_more_before: bool, - no_more_after: bool, -} - -impl LazySpan { - 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(), - content_offset: 0.0, - } - } - - pub fn len(&self) -> usize { - self.items.len() - } - - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - 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(); - } - - 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(); - } - - 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 - } - - 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) - } - - pub fn clear(&mut self) { - self.items.clear(); - self.anchor = None; - self.ctl.set_pinned_to_end(true); - self.heights.clear(); - self.extents.clear(); - self.content_offset = 0.0; - } - - 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); - } - - 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), - } - } - - pub fn jump_to_end(&mut self) { - self.anchor = None; - self.pending_tap = None; - } - - 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; - } - - fn flip_pos(&self, pos: f32) -> f32 { - match self.dir.sign { - Sign::Pos => pos, - Sign::Neg => self.viewport_len - pos, - } - } - - fn flip_delta(&self, amt: f32) -> f32 { - match self.dir.sign { - Sign::Pos => -amt, - Sign::Neg => amt, - } - } - - 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, - } - } - - 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), - } - } - - 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; - } - } - } - - fn rehome_anchor(&mut self) { - let Some(anchor) = self.anchor else { - return; - }; - if self.extents.values().any(|e| e.slot == anchor.slot) { - return; - } - 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, - }); - } - - fn overscroll_gap(&self, lead: f32, trail: f32) -> Option { - if self.at_start == self.at_end { - return None; - } - 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) - } - - fn lay_out(&mut self, painter: &mut Painter, stable_anchor_lead: Option) -> (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, stable_anchor_lead, true); - - 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), None, false); - 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), None, false); - 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; - - 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); - } - - fn intersects_viewport(&self, lead: f32, trail: f32) -> bool { - trail > 0.0 && lead < self.viewport_len - } - - 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 - } - - fn row_region(&self, lead: f32, trail: f32) -> UiRegion { - Self::abs_region( - self.dir, - lead - self.content_offset, - trail - self.content_offset, - ) - } - - fn apply_content_offset(&self, painter: &mut Painter) { - let screen_offset = match self.dir.sign { - Sign::Pos => self.content_offset, - Sign::Neg => -self.content_offset, - }; - painter.set_child_offset(Vec2::from_axis(self.dir.axis, screen_offset, 0.0)); - } - - fn rebase_content_offset(&mut self) { - if self.content_offset.abs() >= CONTENT_OFFSET_REBASE { - self.content_offset = 0.0; - } - } - - fn stabilize_lead(&mut self, painter: &mut Painter, from: f32, to: f32) { - self.content_offset += to - from; - self.apply_content_offset(painter); - } - - fn place( - &mut self, - painter: &mut Painter, - slot: isize, - placement: Placement, - stable_lead: Option, - is_anchor: bool, - ) -> (f32, f32) { - 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()); - let known_anchor_height = (is_anchor && stable_lead.is_some()).then(|| { - painter.known_len(self.slot_widget(slot), axis).map(|len| { - len.apply_rest(density) - .within_len(container_len) - .to_abs(output_len) - }) - }); - let known_anchor_height = known_anchor_height.flatten(); - - if let Some(h) = cached { - let (lead, trail) = placement.edges(known_anchor_height.unwrap_or(h)); - if let Some(old_lead) = stable_lead { - self.stabilize_lead(painter, old_lead, lead); - } - if !self.intersects_viewport(lead, trail) { - return (lead, trail); - } - } - - let height = match (cached, known_anchor_height) { - (Some(_), Some(height)) => { - let (lead, trail) = placement.edges(height); - let region = self.row_region(lead, trail); - painter.widget_within(self.slot_widget(slot), region); - height - } - (Some(h), None) => { - let (lead, trail) = placement.edges(h); - let region = self.row_region(lead, trail); - let used = painter.widget_within(self.slot_widget(slot), region).size(); - let height = resolve(used); - if height != h { - let (new_lead, new_trail) = placement.edges(height); - if stable_lead.is_some() { - self.stabilize_lead(painter, lead, new_lead); - } - let region = self.row_region(new_lead, new_trail); - painter.place(self.slot_widget(slot), region); - } - height - } - (None, None) => { - let measure_from = match placement { - Placement::Leading(lead) => lead, - Placement::Trailing(_) => 0.0, - }; - let first = self.row_region(measure_from, measure_from + GENEROUS_PADDING); - let height = resolve(painter.widget_within(self.slot_widget(slot), first).size()); - let (lead, trail) = placement.edges(height); - if is_anchor { - self.stabilize_lead(painter, measure_from, lead); - } - let region = self.row_region(lead, trail); - painter.place(self.slot_widget(slot), region); - height - } - (None, Some(_)) => unreachable!("an uncached row has no retained height"), - }; - let (lead, trail) = placement.edges(height); - if let Some(k) = key { - self.heights.insert(k, height); - if self.intersects_viewport(lead, trail) { - self.extents.insert(k, RowExtent { slot, lead, trail }); - } - } - (lead, trail) - } - - /// **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 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) - } - - 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 - }; - match self.dir.sign { - Sign::Pos => Travel { - back: backward, - fwd: forward, - }, - Sign::Neg => Travel { - back: forward, - fwd: backward, - }, - } - } -} - -impl Scrollable for LazySpan { - fn controller(&self) -> &ScrollController { - &self.ctl - } - - fn controller_mut(&mut self) -> &mut ScrollController { - &mut self.ctl - } -} - -impl Widget for LazySpan { - fn child_order(&self) -> ChildOrder { - ChildOrder::Axis(self.dir.axis) - } - - fn draw(&mut self, painter: &mut Painter) { - // A lazy span drives its own fling because which rows exist is a - // function of where it is scrolled to; it cannot be moved as one - // retained lump by a `ScrollArea` around it. - if self.ctl.advance(painter.frame_time()) { - painter.request_next_frame(); - } - 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(); - painter.set_size(Size::REST); - return; - } - - // 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. - let delta = self.ctl.take_delta(); - if delta != 0.0 { - let amt = self.flip_delta(delta); - self.move_anchor(amt); - } - - if let Some(tap) = self.pending_tap.take() { - self.reanchor_at_tap(tap); - } - self.rebase_content_offset(); - let stable_anchor_lead = self.anchor.and_then(|anchor| { - self.extents - .values() - .find(|extent| extent.slot == anchor.slot) - .map(|extent| extent.lead) - }); - self.extents.clear(); - self.apply_content_offset(painter); - - let (lead, trail) = self.lay_out(painter, stable_anchor_lead); - - if let Some(gap) = self.overscroll_gap(lead, trail) { - let stable_anchor_lead = self.anchor.and_then(|anchor| { - self.extents - .values() - .find(|extent| extent.slot == anchor.slot) - .map(|extent| extent.lead) - }); - self.move_anchor(gap); - self.extents.clear(); - self.lay_out(painter, stable_anchor_lead); - } - - self.rehome_anchor(); - self.update_snap_end(); - self.ctl.set_travel(self.travel()); - painter.set_size(Size::REST); - } - - fn size_hint(&self, _axis: Axis) -> Option { - Some(LayoutLen::REST) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Instant; - - const FLING_ROW_H: f32 = 20.0; - - 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: Ui, - } - - impl UiRsc for TestRsc { - fn ui(&self) -> &Ui { - &self.ui - } - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } - } - - fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget, StrongWidget) { - let rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let sized = rsc.ui.widgets.add_strong(Sized { - inner: rect.any(), - x: None, - y: Some(LayoutLen::abs(height)), - }); - (sized.weak(), sized.any()) - } - - 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() - } - - 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()) - } - - #[test] - fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() { - let mut rsc = TestRsc { ui: Ui::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)); - 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, - ); - } - } - - #[test] - fn a_dir_up_span_grows_upward_from_item_zero() { - let mut rsc = TestRsc { ui: Ui::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 - .window_region(row, &rsc) - .expect("every short-list row is drawn"); - (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" - ); - - 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))); - } - - #[test] - fn a_delta_moves_both_directions_the_same_way_on_screen() { - let moved_by = |dir: Dir| { - let mut rsc = TestRsc { ui: Ui::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}", - ); - }; - push( - &mut rsc, - &mut render, - match dir.sign { - Sign::Pos => 60.0, - Sign::Neg => -60.0, - }, - ); - - let top = |render: &UiRenderState, rsc: &TestRsc| { - render - .window_region(&rows[4], rsc) - .expect("row 4 is still drawn") - .top_left - .y - }; - let before = top(&render, &rsc); - push(&mut rsc, &mut render, -10.0); - top(&render, &rsc) - 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", - ); - } - } - - #[test] - fn a_reversed_span_hit_tests_in_screen_space() { - let mut rsc = TestRsc { ui: Ui::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: Ui::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); - - 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)); - } - - fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { - let (bg_id, _, row) = resizable_background_row(rsc, height); - (bg_id, row) - } - - fn resizable_background_row( - rsc: &mut TestRsc, - height: f32, - ) -> (WidgetId, WeakWidget, StrongWidget) { - let bg = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let bg_id = bg.id(); - let fg_rect = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - let fg = rsc.ui.widgets.add_strong(Sized { - inner: fg_rect.any(), - x: None, - y: Some(LayoutLen::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()) - } - - #[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: Ui::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(LayoutLen::abs(new_height)); - render.update(&root, &mut rsc); - - let px = render - .active - .get(&bg_id) - .unwrap_or_else(|| { - panic!("row {key_to_change} resized to {new_height}px lost its background") - }) - .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() { - let mut rsc = TestRsc { ui: Ui::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 provisional 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: Ui::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(); - - 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 = render.take_counters().draws; - - 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) - ); - } - 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: Ui::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); - - 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); - rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(LayoutLen::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]; - 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:?}" - ); - 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: Ui::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); - - 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(LayoutLen::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]; - 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: Ui::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(); - - rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); - render.update(&root, &mut rsc); - render.take_counters(); - - rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); - render.update(&root, &mut rsc); - let counters = render.take_counters(); - let (draws, moves) = (counters.draws, counters.moves); - - assert_eq!(draws, 1, "n={n}: only the list should really draw"); - assert_eq!(moves, 1, "n={n}: the whole retained run should move once"); - } - } - - #[test] - fn a_large_accumulated_offset_rebases_without_moving_the_content() { - let mut rsc = TestRsc { ui: Ui::default() }; - let mut list = LazySpan::new(Dir::DOWN, Pin::End); - let keys: Vec = (0..100).collect(); - let rows = push_rows(&mut rsc, &mut list, &keys, 1_000.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(CONTENT_OFFSET_REBASE + 1.0); - render.update(&root, &mut rsc); - - let anchor_key = { - let list = rsc.ui.widgets.get(&list_weak).unwrap(); - let anchor = list.anchor.expect("the populated list has an anchor"); - list.slot_key(anchor.slot) - .expect("the anchor is a real row away from the sentinels") - }; - let anchor_row = &rows[anchor_key as usize]; - let before = render - .window_region(anchor_row, &rsc) - .expect("the rehomed anchor is retained") - .top_left - .y; - - rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); - render.update(&root, &mut rsc); - let after = render - .window_region(anchor_row, &rsc) - .expect("the anchor remains retained after the rebase") - .top_left - .y; - assert!( - (after - before - 1.0).abs() < 0.5, - "the rebase must be invisible; expected +1px, got {}px", - after - before, - ); - } - - #[test] - fn replacing_the_last_row_stays_pinned_to_the_bottom() { - let mut rsc = TestRsc { ui: Ui::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); - - { - 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:?}" - ); - } - - #[test] - fn replace_back_forgets_the_evicted_keys_own_height() { - let mut rsc = TestRsc { ui: Ui::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" - ); - } - - #[test] - fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() { - let mut rsc = TestRsc { ui: Ui::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); - 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" - ); - } - } - - #[test] - fn replacing_the_last_row_many_times_does_not_leak_primitives() { - let mut rsc = TestRsc { ui: Ui::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 { - 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" - ); - } - - #[test] - fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() { - let mut rsc = TestRsc { ui: Ui::default() }; - let mut list = LazySpan::new(Dir::DOWN, Pin::End); - 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()); - - let (row2, row2_bg) = rows[2]; - rsc.ui.widgets.get_dyn_mut(row2).unwrap(); - rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap(); - - 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(), - ); - } - - 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) - } - - fn fling_frame( - rsc: &mut TestRsc, - root: &StrongWidget, - render: &mut UiRenderState, - now: Instant, - ) -> bool { - render.update_at(root, rsc, now) - } - - #[test] - fn a_fling_moves_the_list_and_then_settles() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (scroll, root, mut render) = build_flingable_list(&mut rsc); - let list_weak = scroll; - - 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, &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" - ); - } - - #[test] - fn a_fling_requests_frames_until_it_settles() { - let mut rsc = TestRsc { ui: Ui::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()); - - assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); - - 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 = render.update_at(&root, &mut rsc, now); - 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" - ); - - let now = start + std::time::Duration::from_millis(steps * 16); - assert!(!render.update_at(&root, &mut rsc, now)); - } - - #[test] - fn a_negative_delta_moves_toward_the_end() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (scroll, root, mut render) = build_flingable_list(&mut rsc); - let list_weak = scroll; - 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}" - ); - } - - #[test] - fn amt_counts_only_what_the_child_could_take() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (scroll, root, mut render) = build_flingable_list(&mut rsc); - 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() - ); - - 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}" - ); - } - - #[test] - fn a_fling_stops_at_the_first_row() { - let mut rsc = TestRsc { ui: Ui::default() }; - let (scroll, root, mut render) = build_flingable_list(&mut rsc); - let list_weak = scroll; - 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, &root, &mut render, now) { - break; - } - } - let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); - assert!(list_ref.at_start, "the fling should have reached the start"); - 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 - ); - } - - #[test] - fn scrolling_past_the_start_lands_on_it_in_the_same_frame() { - let mut rsc = TestRsc { ui: Ui::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: Ui::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/iris/src/widget/layout/max_size.rs b/iris/src/widget/layout/max_size.rs deleted file mode 100644 index fae22e3..0000000 --- a/iris/src/widget/layout/max_size.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::prelude::*; - -pub struct MaxSize { - pub inner: StrongWidget, - pub x: Option, - pub y: Option, -} - -impl MaxSize { - fn clamp(len: LayoutLen, max: Option, output: f32, density: f32) -> LayoutLen { - 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); - if len_px > max_px { - max.fold_dp(density) - } else { - len - } - } - - 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) { - 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(); - let size = Size { - x: Self::clamp(used.x, self.x, output.x, density), - y: Self::clamp(used.y, self.y, output.y, density), - }; - painter.place_used(&self.inner, size, UiRegion::FULL); - painter.set_size(size); - } -} diff --git a/iris/src/widget/layout/mod.rs b/iris/src/widget/layout/mod.rs deleted file mode 100644 index d9c1499..0000000 --- a/iris/src/widget/layout/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -mod align; -mod layer; -mod lazy_span; -mod max_size; -mod offset; -mod pad; -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_area::*; -pub use scrollable::*; -pub use sized::*; -pub use span::*; -pub use stack::*; diff --git a/iris/src/widget/layout/offset.rs b/iris/src/widget/layout/offset.rs deleted file mode 100644 index 3765838..0000000 --- a/iris/src/widget/layout/offset.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::prelude::*; - -pub struct Offset { - pub inner: StrongWidget, - pub amt: UiVec2, -} - -impl Widget for Offset { - fn draw(&mut self, painter: &mut Painter) { - let region = UiRegion::FULL.offset(self.amt); - let used = painter.widget_within(&self.inner, region).size(); - painter.place_used(&self.inner, used, region); - painter.set_size(used); - } -} diff --git a/iris/src/widget/layout/pad.rs b/iris/src/widget/layout/pad.rs deleted file mode 100644 index e90fa6c..0000000 --- a/iris/src/widget/layout/pad.rs +++ /dev/null @@ -1,174 +0,0 @@ -use crate::prelude::*; - -pub struct Pad { - pub padding: Padding, - pub inner: StrongWidget, - pub exact_region: bool, -} - -impl Widget for Pad { - fn draw(&mut self, painter: &mut Painter) { - let density = painter.density(); - let offered = painter.px_size(); - let used = painter - .widget_within(&self.inner, self.padding.fixed_region(density)) - .size(); - let region = self.padding.region_for(used, density); - painter.place_used(&self.inner, used, region); - let size = Size { - x: self.padding.left.fold_dp(density) + used.x + self.padding.right.fold_dp(density), - y: self.padding.top.fold_dp(density) + used.y + self.padding.bottom.fold_dp(density), - }; - let needed = size.to_uivec2(density).to_abs(painter.output_size()); - if needed.x <= offered.x + 0.01 && needed.y <= offered.y + 0.01 { - self.exact_region = false; - } - painter.set_size(size); - } - - fn requires_exact_region(&self) -> bool { - self.exact_region - } -} - -pub struct Padding { - pub left: LayoutLen, - pub right: LayoutLen, - pub top: LayoutLen, - pub bottom: LayoutLen, -} - -impl Padding { - pub const ZERO: Self = Self { - left: LayoutLen::ZERO, - right: LayoutLen::ZERO, - top: LayoutLen::ZERO, - bottom: LayoutLen::ZERO, - }; - - pub fn uniform(amt: impl Into) -> Self { - let amt = amt.into(); - Self { - left: amt, - right: amt, - top: amt, - bottom: amt, - } - } - fn fixed_region(&self, density: f32) -> UiRegion { - let mut region = UiRegion::FULL; - let left = self.left.fold_dp(density); - let right = self.right.fold_dp(density); - let top = self.top.fold_dp(density); - let bottom = self.bottom.fold_dp(density); - region.x.start += UiScalar::new(left.rel, left.abs); - region.x.end -= UiScalar::new(right.rel, right.abs); - region.y.start += UiScalar::new(top.rel, top.abs); - region.y.end -= UiScalar::new(bottom.rel, bottom.abs); - region - } - - fn region_for(&self, inner: Size, density: f32) -> UiRegion { - UiRegion { - x: padded_axis(self.left, inner.x, self.right, density), - y: padded_axis(self.top, inner.y, self.bottom, density), - } - } - pub fn x(amt: impl Into) -> Self { - let amt = amt.into(); - Self { - left: amt, - right: amt, - top: LayoutLen::ZERO, - bottom: LayoutLen::ZERO, - } - } - pub fn y(amt: impl Into) -> Self { - let amt = amt.into(); - Self { - left: LayoutLen::ZERO, - right: LayoutLen::ZERO, - top: amt, - bottom: amt, - } - } - - pub fn top(amt: impl Into) -> Self { - let mut s = Self::ZERO; - s.top = amt.into(); - s - } - - pub fn bottom(amt: impl Into) -> Self { - let mut s = Self::ZERO; - s.bottom = amt.into(); - s - } - - pub fn left(amt: impl Into) -> Self { - let mut s = Self::ZERO; - s.left = amt.into(); - s - } - - pub fn right(amt: impl Into) -> Self { - let mut s = Self::ZERO; - s.right = amt.into(); - s - } - - pub fn with_top(mut self, amt: impl Into) -> Self { - self.top = amt.into(); - self - } - - pub fn with_bottom(mut self, amt: impl Into) -> Self { - self.bottom = amt.into(); - self - } - - pub fn with_left(mut self, amt: impl Into) -> Self { - self.left = amt.into(); - self - } - - pub fn with_right(mut self, amt: impl Into) -> Self { - self.right = amt.into(); - self - } -} - -fn padded_axis(before: LayoutLen, inner: LayoutLen, after: LayoutLen, density: f32) -> UiSpan { - let lengths = [ - before.fold_dp(density), - inner.fold_dp(density), - after.fold_dp(density), - ]; - let total = lengths - .iter() - .copied() - .fold(LayoutLen::ZERO, |sum, len| sum + len); - let fixed = UiScalar::new(total.rel, total.abs); - let mut cursor = UiScalar::ZERO; - let mut inner_span = UiSpan::FULL; - for (index, len) in lengths.into_iter().enumerate() { - let start = cursor; - if len.rest > 0.0 { - let rest_end = UiScalar::rel(len.rest / total.rest); - let available_end = (UiScalar::FULL + cursor) - fixed; - cursor = rest_end.within(&cursor.to(available_end)); - } - cursor.rel += len.rel; - cursor.abs += len.abs; - if index == 1 { - inner_span = start.to(cursor); - } - } - inner_span -} - -impl> From for Padding { - fn from(amt: T) -> Self { - Self::uniform(amt.into()) - } -} diff --git a/iris/src/widget/layout/scroll_area.rs b/iris/src/widget/layout/scroll_area.rs deleted file mode 100644 index 1c7d0dd..0000000 --- a/iris/src/widget/layout/scroll_area.rs +++ /dev/null @@ -1,393 +0,0 @@ -use crate::prelude::*; - -pub struct ScrollArea { - inner: StrongWidget, - ctl: ScrollController, - container_len: f32, - 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 { - fn draw(&mut self, painter: &mut Painter) { - if self.ctl.advance(painter.frame_time()) { - painter.request_next_frame(); - } - let axis = self.ctl.axis(); - let container_len = painter.px_size().axis(axis); - self.container_len = container_len; - self.ctl.set_density(painter.density()); - - let delta = self.ctl.take_delta(); - let travelled = self.ctl.amt() - delta; - self.ctl.set_amt(travelled); - - let hint = self.content_len.unwrap_or(container_len); - let used = painter - .widget_within(&self.inner, self.child_region(hint)) - .size(); - - 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); - - 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, - }); - - let size = painter - .place(&self.inner, self.child_region(measured)) - .size(); - painter.set_size(size); - } -} - -impl ScrollArea { - pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self { - Self { - inner, - ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin), - container_len: 0.0, - content_len: None, - } - } - - 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 std::time::{Duration, Instant}; - - fn area() -> (Fixture, WidgetId) { - area_on(Axis::Y) - } - - fn area_on(axis: Axis) -> (Fixture, WidgetId) { - let mut rsc = TestRsc { ui: Ui::default() }; - let fill = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)).any(); - let id = fill.id(); - let long = Some(LayoutLen::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, - }; - fixture.get().scroll(-400.0); - fixture.draw(); - assert!((fixture.amt() - 400.0).abs() < 0.01); - (fixture, id) - } - - 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() - } - - fn fling_frame(&mut self, now: Instant) -> bool { - self.render.update_at(&self.root, &mut self.rsc, now) - } - } - - fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) { - drag(f, id, sense, Vec2::new(0.0, y), t); - } - - fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) { - let pointer = PointerRequests::default(); - f.get().drag(&pointer, id, sense, pos, t); - 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, - ); - 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() - ); - 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()); - } - - #[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() - ); - } - - #[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()); - } - - #[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()); - } - - #[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, - ); - 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" - ); - - 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() - ); - } - } - - #[test] - fn a_fling_stops_at_the_end_of_the_content() { - 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() - ); - } - } - - #[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" - ); - - 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/iris/src/widget/layout/scrollable.rs b/iris/src/widget/layout/scrollable.rs deleted file mode 100644 index dddf7db..0000000 --- a/iris/src/widget/layout/scrollable.rs +++ /dev/null @@ -1,326 +0,0 @@ -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. -#[derive(Clone, Copy, Eq, PartialEq, Debug)] -pub enum Pin { - Start, - End, - Neg, - Pos, -} - -impl Pin { - 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, - } - } -} - -/// `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 { - 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 { - pub const UNBOUNDED: Self = Self { - back: f32::INFINITY, - fwd: f32::INFINITY, - }; - - fn toward(&self, delta: f32) -> f32 { - if delta >= 0.0 { self.back } else { self.fwd } - } -} - -pub struct ScrollController { - dir: Dir, - /// 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, - pending: f32, - travel: Travel, - pinned_to_end: bool, - gesture: DragGesture, - 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, - } - } - - pub fn axis(&self) -> Axis { - self.dir.axis - } - - pub fn dir(&self) -> Dir { - self.dir - } - - pub fn amt(&self) -> f32 { - self.amt - } - - 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`. - 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 - } - - 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; - } - - 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(); - } - } - - 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, cancelling any fling already in progress. Answers whether - /// one actually started. The owning widget advances it from `draw` and - /// requests another frame while it remains active. - 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() - } - - pub(crate) fn advance(&mut self, now: Instant) -> bool { - let delta = self.fling.advance(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, for callers that need to - /// distinguish a release which began coasting from one which did not. - pub fn drag( - &mut self, - pointer: &PointerRequests, - id: WidgetId, - sense: CursorSense, - pos_window: Vec2, - now: Instant, - ) -> bool { - 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), - 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; - - fn scroll(&mut self, amt: f32) { - self.controller_mut().scroll(amt); - } - - fn fling(&mut self, velocity: f32) -> bool { - self.controller_mut().fling(velocity) - } - - fn cancel_fling(&mut self) { - self.controller_mut().cancel_fling(); - } - - 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) - } - - 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); - } -} - -pub fn scroll_senses(w: WL, axis: Axis) -> impl WidgetIdFn -where - Rsc: HasEvents, - W: Widget + Scrollable, - WL: WidgetLike, -{ - w.on(CursorSense::Scroll(axis), move |ctx, rsc| { - let delta = ctx.data.scroll_delta.axis(axis) * 50.0; - ctx.widget(rsc).scroll(delta); - }) - .on(CursorSense::drag(axis), |ctx, rsc: &mut Rsc| { - let id = ctx.widget.id(); - let (sense, pos) = (ctx.data.sense, ctx.data.cursor.pos); - ctx.widget(rsc) - .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); - }) -} diff --git a/iris/src/widget/layout/sized.rs b/iris/src/widget/layout/sized.rs deleted file mode 100644 index 92a6ded..0000000 --- a/iris/src/widget/layout/sized.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::prelude::*; - -pub struct Sized { - pub inner: StrongWidget, - pub x: Option, - pub y: Option, -} - -impl Widget for Sized { - fn draw(&mut self, painter: &mut Painter) { - let density = painter.density(); - let mut region = UiRegion::FULL; - if let Some(x) = self.x { - region.x = x.apply_rest(density).align(AxisAlign::Neg); - } - if let Some(y) = self.y { - region.y = y.apply_rest(density).align(AxisAlign::Neg); - } - let used = painter.widget_within(&self.inner, region).size(); - let size = 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), - }; - painter.place_used(&self.inner, size, UiRegion::FULL); - painter.set_size(size); - } - - fn size_hint(&self, axis: Axis) -> Option { - match axis { - Axis::X => self.x, - Axis::Y => self.y, - } - } - - fn requires_exact_region(&self) -> bool { - // `Sized` may be measured in a provisional box and then placed in - // the content-sized box it reported. Its own region can be corrected - // by a move, but its child consumed the original box and must see the - // final one too. - true - } -} diff --git a/iris/src/widget/layout/span.rs b/iris/src/widget/layout/span.rs deleted file mode 100644 index d44da86..0000000 --- a/iris/src/widget/layout/span.rs +++ /dev/null @@ -1,206 +0,0 @@ -use crate::prelude::*; -use std::marker::PhantomData; - -pub struct Span { - pub children: Vec, - pub dir: Dir, - pub gap: LayoutLen, - pub compact_orthogonal: bool, -} - -impl Widget for Span { - fn child_order(&self) -> ChildOrder { - ChildOrder::Axis(self.dir.axis) - } - - fn draw(&mut self, painter: &mut Painter) { - let axis = self.dir.axis; - let gap = self.gap.apply_rest(painter.density()).abs; - - let gap_total = gap * self.children.len().saturating_sub(1) as f32; - let mut measured = Vec::with_capacity(self.children.len()); - let mut total = LayoutLen::abs(gap_total); - let mut cursor = UiScalar::rel_min(); - for child in &self.children { - let (len, drawn) = match painter.known_len(child, axis) { - Some(len) => (len, false), - None => { - let mut slot = UiSpan::new(cursor, UiScalar::rel_max()); - if self.dir.sign == Sign::Neg { - slot.flip(); - } - let region = UiRegion::from_axis(axis, slot, UiSpan::FULL); - let len = painter.widget_within(child, region).size().axis(axis); - (len, true) - } - }; - measured.push((len, drawn)); - total += len; - cursor.abs += len.abs + gap; - cursor.rel += len.rel; - } - - let mut start = UiScalar::rel_min(); - let mut ortho_len = LayoutLen::ZERO; - let mut ortho_mixed = false; - for (child, &(len, drawn)) in self.children.iter().zip(&measured) { - let child_region = child_region(axis, self.dir.sign, total, gap, &mut start, len); - let used = if drawn { - painter.place(child, child_region).size() - } else { - painter.widget_within(child, child_region).size() - }; - - 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 = LayoutLen::default(); - } else if self.compact_orthogonal { - let ortho = ortho_len - .apply_rest(painter.density()) - .align(AxisAlign::Neg); - let mut start = UiScalar::rel_min(); - for (child, &(len, _)) in self.children.iter().zip(&measured) { - let mut region = child_region(axis, self.dir.sign, total, gap, &mut start, len); - *region.axis_mut(!axis) = ortho; - painter.place(child, region); - } - } - - let along = if total.rest == 0.0 && total.rel == 0.0 { - total - } else { - LayoutLen::default() - }; - - painter.set_size(Size::from_axis(axis, along, ortho_len)); - } -} - -fn child_region( - axis: Axis, - sign: Sign, - total: LayoutLen, - gap: f32, - start: &mut UiScalar, - len: LayoutLen, -) -> UiRegion { - let mut span = UiSpan::FULL; - span.start = *start; - 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; - start.abs += gap; - let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); - if sign == Sign::Neg { - region.flip(axis); - } - region -} - -impl Span { - pub fn empty(dir: Dir) -> Self { - Self { - children: Vec::new(), - dir, - gap: LayoutLen::ZERO, - compact_orthogonal: false, - } - } - - /// Shrink the span and its children to the widest orthogonal child - /// instead of leaving children in the orthogonal space offered by its - /// parent. - pub fn compact(mut self) -> Self { - self.compact_orthogonal = true; - self - } - - pub fn gap(mut self, gap: impl Into) -> Self { - self.gap = gap.into(); - self - } - - pub fn push(&mut self, w: StrongWidget) { - self.children.push(w); - } - - pub fn pop(&mut self) -> Option { - self.children.pop() - } -} - -pub struct SpanBuilder, Tag> { - pub children: Wa, - pub dir: Dir, - pub gap: LayoutLen, - pub compact_orthogonal: bool, - _pd: PhantomData<(State, Tag)>, -} - -impl, Tag> WidgetFnTrait - for SpanBuilder -{ - type Widget = Span; - - #[track_caller] - fn run(self, rsc: &mut Rsc) -> Self::Widget { - Span { - children: self.children.add(rsc).arr.into_iter().collect(), - dir: self.dir, - gap: self.gap, - compact_orthogonal: self.compact_orthogonal, - } - } -} - -impl, Tag> - SpanBuilder -{ - pub fn new(children: Wa, dir: Dir) -> Self { - Self { - children, - dir, - gap: LayoutLen::ZERO, - compact_orthogonal: false, - _pd: PhantomData, - } - } - - pub fn gap(mut self, gap: impl Into) -> Self { - self.gap = gap.into(); - self - } - - /// Shrink the span and its children to the widest orthogonal child - /// instead of filling the orthogonal space offered by its parent. - pub fn compact(mut self) -> Self { - self.compact_orthogonal = true; - self - } -} - -impl std::ops::Deref for Span { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.children - } -} - -impl std::ops::DerefMut for Span { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.children - } -} diff --git a/iris/src/widget/layout/stack.rs b/iris/src/widget/layout/stack.rs deleted file mode 100644 index 6d5407f..0000000 --- a/iris/src/widget/layout/stack.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::marker::PhantomData; - -use crate::prelude::*; - -pub struct Stack { - pub children: Vec, - pub size: StackSize, -} - -impl Widget for Stack { - fn draw(&mut self, painter: &mut Painter) { - let density = painter.density(); - let known = match self.size { - StackSize::Default => Some(Size::REST), - StackSize::Child(i) => self.children.get(i).and_then(|child| { - Some(Size { - x: painter.known_len(child, Axis::X)?, - y: painter.known_len(child, Axis::Y)?, - }) - }), - }; - let region = known.map(|size| size.to_uivec2(density).align(RegionAlign::TOP_LEFT)); - let mut used = known - .is_none() - .then(|| Vec::with_capacity(self.children.len())); - let mut selected = known.unwrap_or_default(); - for (i, child) in self.children.iter().enumerate() { - if i == 0 { - painter.child_layer(); - } else { - painter.next_layer(); - } - let child_size = match region { - Some(region) => painter.widget_within(child, region).size(), - None => painter.widget(child).size(), - }; - if matches!(self.size, StackSize::Child(target) if target == i) { - selected = child_size; - } - if let Some(used) = &mut used { - used.push(child_size); - } - } - let size = match self.size { - StackSize::Default => Size::default(), - StackSize::Child(_) => selected, - }; - if let Some(used) = used { - let final_region = size.to_uivec2(density).align(RegionAlign::TOP_LEFT); - for (child, child_size) in self.children.iter().zip(used) { - let child_region = child_size - .to_uivec2(density) - .align(RegionAlign::TOP_LEFT) - .within(&final_region); - painter.place(child, child_region); - } - } - painter.set_size(size); - } - - fn size_hint(&self, _axis: Axis) -> Option { - match self.size { - StackSize::Default => Some(LayoutLen::REST), - StackSize::Child(_) => None, - } - } -} - -#[derive(Default, Debug)] -pub enum StackSize { - #[default] - Default, - Child(usize), -} - -pub struct StackBuilder, Tag> { - pub children: Wa, - pub size: StackSize, - _pd: PhantomData<(State, Tag)>, -} - -impl, Tag> WidgetFnTrait - for StackBuilder -{ - type Widget = Stack; - - #[track_caller] - fn run(self, rsc: &mut Rsc) -> Self::Widget { - Stack { - children: self.children.add(rsc).arr.into_iter().collect(), - size: self.size, - } - } -} - -impl, Tag> - StackBuilder -{ - pub fn new(children: Wa) -> Self { - Self { - children, - size: StackSize::default(), - _pd: PhantomData, - } - } - - pub fn size(mut self, size: StackSize) -> Self { - self.size = size; - self - } -} diff --git a/iris/src/widget/mask.rs b/iris/src/widget/mask.rs deleted file mode 100644 index 144c0d3..0000000 --- a/iris/src/widget/mask.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::prelude::*; - -pub struct Masked { - pub shape: Option, - pub inner: StrongWidget, -} - -impl Widget for Masked { - fn draw(&mut self, painter: &mut Painter) { - match &self.shape { - Some(shape) => { - painter.child_layer(); - painter.widget(shape); - painter.set_mask_to_widget(shape); - painter.next_layer(); - } - None => painter.set_mask(painter.region()), - } - let used = painter.widget(&self.inner).size(); - painter.place_used(&self.inner, used, UiRegion::FULL); - painter.set_size(used); - } - - fn requires_exact_region(&self) -> bool { - true - } -} diff --git a/iris/src/widget/mod.rs b/iris/src/widget/mod.rs deleted file mode 100644 index 2dc3385..0000000 --- a/iris/src/widget/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -mod image; -mod layout; -mod mask; -mod ptr; -mod rect; -mod text; -mod trait_fns; - -pub use image::*; -pub use layout::*; -pub use mask::*; -pub use ptr::*; -pub use rect::*; -pub use text::*; -pub use trait_fns::*; diff --git a/iris/src/widget/ptr.rs b/iris/src/widget/ptr.rs deleted file mode 100644 index ce3b625..0000000 --- a/iris/src/widget/ptr.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::prelude::*; -use std::marker::{Sized, Unsize}; - -pub struct WidgetPtr { - pub inner: Option, -} - -impl Widget for WidgetPtr { - fn draw(&mut self, painter: &mut Painter) { - let size = if let Some(id) = &self.inner { - let used = painter.widget(id).size(); - painter.place_used(id, used, UiRegion::FULL); - used - } else { - Size::ZERO - }; - painter.set_size(size); - } - - fn is_size_independent(&self) -> bool { - self.inner.is_none() - } -} - -impl WidgetPtr { - pub fn new() -> Self { - Self::default() - } - pub fn empty() -> Self { - Self { - inner: Default::default(), - } - } - pub fn set>(&mut self, to: StrongWidget) { - self.inner = Some(to) - } - - pub fn replace>( - &mut self, - to: StrongWidget, - ) -> Option { - self.inner.replace(to) - } -} - -impl Default for WidgetPtr { - fn default() -> Self { - Self::empty() - } -} diff --git a/iris/src/widget/rect.rs b/iris/src/widget/rect.rs deleted file mode 100644 index 8125eff..0000000 --- a/iris/src/widget/rect.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::prelude::*; - -#[derive(Clone)] -pub struct Rect { - paint: PaintValue, - pub radius: Len, - pub thickness: f32, - pub inner_radius: f32, -} - -impl Rect { - pub fn new(paint: impl Paint) -> Self { - Self { - paint: paint.into_value(), - radius: Len::ZERO, - inner_radius: 0.0, - thickness: 0.0, - } - } - pub fn paint(mut self, paint: impl Paint) -> Self { - self.paint = paint.into_value(); - self - } - pub fn color(self, paint: impl Paint) -> Self { - self.paint(paint) - } - - pub fn set_paint(&mut self, paint: impl Paint) { - self.paint = paint.into_value(); - } - pub fn is_paint(&self, paint: &PaintId) -> bool { - self.paint.is(paint) - } - 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) { - let paint = painter.paint_value(&mut self.paint); - painter.primitive(RectPrimitive { - paint, - radius: self.radius.fold_dp(painter.density()).abs, - thickness: self.thickness, - inner_radius: self.inner_radius, - }); - painter.set_size(Size::REST); - } - - fn size_hint(&self, _axis: Axis) -> Option { - Some(LayoutLen::REST) - } -} - -pub fn rect(paint: impl Paint) -> Rect { - Rect::new(paint) -} diff --git a/iris/src/widget/text/build.rs b/iris/src/widget/text/build.rs deleted file mode 100644 index 0385db4..0000000 --- a/iris/src/widget/text/build.rs +++ /dev/null @@ -1,148 +0,0 @@ -use crate::prelude::*; -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, -} - -impl> TextBuilder { - pub fn size(mut self, size: impl UiNum) -> Self { - self.attrs.font_size = size.to_f32(); - self.attrs.line_height = self.attrs.font_size * LINE_HEIGHT_MULT; - self - } - pub fn color(mut self, color: PaintId) -> Self { - self.attrs.color = color; - self - } - pub fn family(mut self, family: impl AsRef) -> Self { - self.attrs.family = family.as_ref().to_owned(); - self - } - pub fn line_height(mut self, height: f32) -> Self { - self.attrs.line_height = height; - self - } - pub fn text_align(mut self, align: impl Into) -> Self { - self.attrs.align = align.into(); - self - } - pub fn center_text(mut self) -> Self { - self.attrs.align = Align::CENTER; - self - } - pub fn overflow(mut self, overflow: TextOverflow) -> Self { - self.attrs.overflow = overflow; - self - } - pub fn overflow_position(mut self, position: impl Into) -> Self { - self.attrs.overflow_position = position.into(); - self - } - 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, - } - } -} - -impl TextBuilder { - pub fn hint, Tag>( - self, - hint: W, - ) -> 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, - } - } -} - -pub trait TextBuilderOutput: Sized { - type Output; - fn run>( - state: &mut State, - builder: TextBuilder, - ) -> Self::Output; -} - -pub struct TextOutput; -impl TextBuilderOutput for TextOutput { - type Output = Text; - - fn run>( - state: &mut Rsc, - builder: TextBuilder, - ) -> Self::Output { - let mut buf = TextBuffer::new(&builder.content); - buf.set_spans(builder.spans); - let hint = builder.hint.get(state); - let resources = state.ui().text.clone(); - Text { - view: TextView::new(TextResources::add(resources, buf, builder.attrs), hint), - } - } -} - -pub struct TextEditOutput { - mode: EditMode, -} - -impl TextBuilderOutput for TextEditOutput { - type Output = TextEdit; - - fn run>( - state: &mut State, - builder: TextBuilder, - ) -> Self::Output { - let mut buf = TextBuffer::new(&builder.content); - buf.set_spans(builder.spans); - let resources = state.ui().text.clone(); - TextEdit::new( - TextView::new( - TextResources::add(resources, buf, builder.attrs), - builder.hint.get(state), - ), - builder.output.mode, - ) - } -} - -impl, H: WidgetOption> FnOnce<(&mut State,)> - for TextBuilder -{ - type Output = O::Output; - - extern "rust-call" fn call_once(self, args: (&mut State,)) -> Self::Output { - O::run(args.0, self) - } -} - -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/iris/src/widget/text/edit.rs b/iris/src/widget/text/edit.rs deleted file mode 100644 index 72946f9..0000000 --- a/iris/src/widget/text/edit.rs +++ /dev/null @@ -1,709 +0,0 @@ -use crate::prelude::*; -use iris_core::PaintId; -use parley::{Affinity, Layout, Selection}; -use std::{ - cell::Ref, - ops::{Deref, DerefMut}, -}; -#[cfg(not(target_os = "android"))] -use winit::{ - event::KeyEvent, - keyboard::{Key, NamedKey}, -}; - -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub enum Motion { - Left, - Right, - LeftWord, - RightWord, - Up, - Down, - LineStart, - LineEnd, -} - -pub struct TextEdit { - pub(super) view: TextView, - #[cfg_attr(target_os = "android", allow(dead_code))] - history: Vec<(String, Option)>, - pub(crate) press_origin: Option, - pub mode: EditMode, -} - -#[derive(Clone, Copy, PartialEq)] -pub enum EditMode { - SingleLine, - MultiLine, -} - -impl TextEdit { - pub fn new(view: TextView, mode: EditMode) -> Self { - Self { - view, - history: Default::default(), - press_origin: None, - mode, - } - } - - pub fn selected_text(&self) -> Option { - self.view.selection.selected_text(&self.view.text.text()) - } - - /// 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) -> Ref<'_, str> { - self.view.text.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> { - self.view.selection.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 { - self.view.selection.caret() - } -} - -impl Widget for TextEdit { - fn draw(&mut self, painter: &mut Painter) { - let used = self.view.draw_selectable(painter, true); - painter.set_size(used); - } - - fn requires_exact_region(&self) -> bool { - true - } - - fn access_role(&self) -> accesskit::Role { - match self.mode { - EditMode::SingleLine => accesskit::Role::TextInput, - EditMode::MultiLine => accesskit::Role::MultilineTextInput, - } - } -} - -impl TextEdit { - fn selection_ctx(&mut self) -> TextSelectionCtx<'_> { - TextSelectionCtx { - view: &mut self.view, - } - } - - pub fn set_overflow(&mut self, overflow: TextOverflow) { - self.view.set_overflow(overflow); - } - - pub fn set_overflow_position(&mut self, position: impl Into) { - self.view.set_overflow_position(position); - } - - fn layout(&self) -> Ref<'_, Layout> { - self.view.text.layout() - } - - #[cfg_attr(target_os = "android", allow(dead_code))] - fn refresh(&mut self) { - self.selection_ctx().refresh(); - } - - pub fn take(&mut self) -> String { - let text = self.view.text.text().to_string(); - self.set(""); - text - } - - pub fn set(&mut self, text: &str) { - let text = self.string(text); - self.view.text.set_text(text); - self.view.selection.deselect(); - } - - pub fn set_with_spans(&mut self, text: &str, spans: Vec) { - let text = self.string(text); - self.view.text.set_text(text); - self.view.text.set_spans(spans); - self.view.selection.deselect(); - } - - pub fn motion(&mut self, motion: Motion, select: bool) { - let Some(sel) = self.view.selection.range else { - return; - }; - let layout = self.layout(); - let sel = if !select && !sel.is_collapsed() { - match motion { - Motion::Left | Motion::LeftWord => { - Selection::from(sel.text_range().start_cursor(&layout)) - } - 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) - }; - drop(layout); - self.view.selection.range = Some(sel); - } - - pub fn replace(&mut self, len: usize, text: &str) { - let text = self.string(text); - for _ in 0..len { - self.backspace(false); - } - self.insert_str(&text); - } - - fn string(&self, text: &str) -> String { - if self.mode == EditMode::SingleLine { - text.replace('\n', "") - } else { - text.to_string() - } - } - - pub fn insert(&mut self, text: &str) { - let text = self.string(text); - self.insert_str(&text); - } - - fn insert_str(&mut self, text: &str) { - if text.is_empty() { - return; - } - self.clear_span(); - let at = match self.view.selection.range { - 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.view.text.text().len()); - self.view - .text - .edit_text(|content| content.insert_str(at, text)); - self.set_caret(at + text.len()); - } - - pub fn clear_span(&mut self) -> bool { - let Some(sel) = self.view.selection.range else { - return false; - }; - if sel.is_collapsed() { - return false; - } - let range = sel.text_range(); - self.view - .text - .edit_text(|content| content.replace_range(range.clone(), "")); - self.set_caret(range.start); - true - } - - fn set_caret(&mut self, index: usize) { - self.selection_ctx().set_caret(index); - } - - pub fn newline(&mut self) { - if self.mode == EditMode::MultiLine { - self.insert_str("\n"); - } - } - - pub fn backspace(&mut self, word: bool) { - if self.clear_span() { - return; - } - let Some(sel) = self.view.selection.range 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() - }; - drop(layout); - self.delete_range(start, end); - } - - pub fn delete(&mut self, word: bool) { - if self.clear_span() { - return; - } - let Some(sel) = self.view.selection.range else { - return; - }; - let start = sel.focus().index(); - if start >= self.view.text.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() - }; - drop(layout); - self.delete_range(start, end); - } - - fn delete_range(&mut self, start: usize, end: usize) { - let len = self.view.text.text().len(); - let (start, end) = (start.min(end).min(len), start.max(end).min(len)); - if start == end { - return; - } - self.view - .text - .edit_text(|content| content.replace_range(start..end, "")); - self.set_caret(start); - } - - /// 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); - } - - /// 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 { - self.selection_ctx().byte_at(pos, size) - } - - pub fn select_all(&mut self) { - self.selection_ctx().select_all(); - } - - pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { - self.selection_ctx().select(pos, size, drag, recent); - } - - pub fn deselect(&mut self) { - self.selection_ctx().deselect(); - } - - #[cfg(not(target_os = "android"))] - pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { - let old = (self.view.text.text().to_string(), self.view.selection.range); - let mut undo = false; - let res = self.apply_event_inner(event, modifiers, &mut undo); - if undo { - if let Some((old, selection)) = self.history.pop() { - self.set(&old); - self.view.selection.range = selection; - self.refresh(); - } - } else if self.view.text.text().as_ref() != old.0 { - self.history.push(old); - } - res - } - - #[cfg(not(target_os = "android"))] - fn apply_event_inner( - &mut self, - event: &KeyEvent, - modifiers: &Modifiers, - undo: &mut bool, - ) -> TextInputResult { - match &event.logical_key { - Key::Named(named) => match named { - NamedKey::Backspace => self.backspace(modifiers.control), - NamedKey::Delete => self.delete(modifiers.control), - NamedKey::Space => self.insert(" "), - NamedKey::Enter => { - if modifiers.shift { - self.newline(); - } else { - return TextInputResult::Submit; - } - } - NamedKey::ArrowRight => { - let motion = if modifiers.control { - Motion::RightWord - } else { - Motion::Right - }; - self.motion(motion, modifiers.shift); - } - NamedKey::ArrowLeft => { - let motion = if modifiers.control { - Motion::LeftWord - } else { - 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; - } - _ => return TextInputResult::Unused, - }, - Key::Character(text) => { - if modifiers.control { - match text.as_str() { - "v" => return TextInputResult::Paste, - "c" => { - if let Some(content) = self.selected_text() { - return TextInputResult::Copy(content); - } - } - "x" => { - if let Some(content) = self.selected_text() { - self.clear_span(); - return TextInputResult::Copy(content); - } - } - "a" => self.select_all(), - "z" => *undo = true, - _ => self.insert(text), - } - } else { - self.insert(text); - } - } - _ => return TextInputResult::Unused, - } - TextInputResult::Used - } -} - -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), - } -} - -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, - pub control: bool, -} - -impl Modifiers { - pub fn clear(&mut self) { - self.shift = false; - self.control = false; - } -} - -pub enum TextInputResult { - Used, - Unused, - Unfocus, - Submit, - Copy(String), - Paste, -} - -impl TextInputResult { - pub fn unfocus(&self) -> bool { - matches!(self, TextInputResult::Unfocus) - } -} - -impl Deref for TextEdit { - type Target = TextView; - - fn deref(&self) -> &Self::Target { - &self.view - } -} - -impl DerefMut for TextEdit { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.view - } -} - -#[cfg(test)] -mod tests { - use super::*; - use iris_core::{TextAttrs, TextBuffer, TextResources}; - - fn edit(text: &str, mode: EditMode) -> TextEdit { - let resources = std::rc::Rc::new(std::cell::RefCell::new(TextResources::default())); - let handle = TextResources::add(resources, TextBuffer::new(text), TextAttrs::default()); - TextEdit::new(TextView::new(handle, None), mode) - } - - fn ctx(text: &mut TextEdit) -> &mut TextEdit { - text - } - - fn content(text: &TextEdit) -> String { - text.text().to_string() - } - - #[test] - fn insert_at_the_caret() { - let mut t = edit("ac", EditMode::SingleLine); - ctx(&mut t).set_caret(1); - ctx(&mut t).insert("b"); - assert_eq!(content(&t), "abc"); - assert_eq!(t.caret(), Some(2)); - } - - #[test] - fn backspace_removes_the_character_before_the_caret() { - let mut t = edit("abc", EditMode::SingleLine); - ctx(&mut t).set_caret(2); - ctx(&mut t).backspace(false); - assert_eq!(content(&t), "ac"); - } - - #[test] - fn backspace_at_the_start_does_nothing() { - let mut t = edit("abc", EditMode::SingleLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).backspace(false); - assert_eq!(content(&t), "abc"); - } - - #[test] - fn delete_removes_the_character_after_the_caret() { - let mut t = edit("abc", EditMode::SingleLine); - ctx(&mut t).set_caret(1); - ctx(&mut t).delete(false); - assert_eq!(content(&t), "ac"); - } - - #[test] - fn delete_at_the_end_does_nothing() { - let mut t = edit("abc", EditMode::SingleLine); - ctx(&mut t).set_caret(3); - ctx(&mut t).delete(false); - assert_eq!(content(&t), "abc"); - } - - #[test] - fn select_all_then_typing_replaces_everything() { - let mut t = edit("hello", EditMode::SingleLine); - ctx(&mut t).select_all(); - assert_eq!(t.selected_text().as_deref(), Some("hello")); - ctx(&mut t).insert("x"); - assert_eq!(content(&t), "x"); - } - - #[test] - fn clearing_a_span_leaves_the_caret_at_its_start() { - let mut t = edit("abcdef", EditMode::SingleLine); - ctx(&mut t).select_all(); - assert!(ctx(&mut t).clear_span()); - assert_eq!(content(&t), ""); - assert_eq!(t.caret(), Some(0)); - } - - #[test] - fn tapping_an_empty_field_places_a_caret_so_typing_lands() { - let mut t = edit("", EditMode::MultiLine); - ctx(&mut t).select(vec2(40.0, 20.0), vec2(1080.0, 2400.0), false, false); - assert!(t.caret().is_some(), "a tap must leave a caret behind"); - ctx(&mut t).insert("hi"); - assert_eq!(content(&t), "hi"); - } - - #[test] - fn tapping_past_the_end_of_the_text_clamps_to_the_end() { - let mut t = edit("abc", EditMode::MultiLine); - ctx(&mut t).select(vec2(9000.0, 9000.0), vec2(1080.0, 2400.0), false, false); - assert_eq!(t.caret(), Some(3)); - } - - #[test] - fn dragging_without_a_previous_selection_selects_nothing() { - let mut t = edit("abc", EditMode::MultiLine); - ctx(&mut t).select(vec2(10.0, 10.0), vec2(1080.0, 2400.0), true, false); - assert!(t.selection_range().is_none()); - } - - #[test] - fn a_single_line_field_refuses_newlines() { - let mut t = edit("", EditMode::SingleLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).insert("a\nb"); - assert_eq!(content(&t), "ab"); - ctx(&mut t).newline(); - assert_eq!(content(&t), "ab"); - } - - #[test] - fn a_multi_line_field_keeps_newlines() { - let mut t = edit("", EditMode::MultiLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).insert("a\nb"); - assert_eq!(content(&t), "a\nb"); - } - - #[test] - fn take_empties_the_field_and_hands_back_what_was_there() { - let mut t = edit("some text", EditMode::SingleLine); - assert_eq!(ctx(&mut t).take(), "some text"); - assert_eq!(content(&t), ""); - } - - #[test] - fn ime_preedit_replaces_its_own_previous_text() { - let mut t = edit("", EditMode::SingleLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).replace(0, "n"); - assert_eq!(content(&t), "n"); - ctx(&mut t).replace(1, "ni"); - assert_eq!(content(&t), "ni"); - ctx(&mut t).replace(2, "に"); - assert_eq!(content(&t), "に"); - } - - #[test] - fn composing_advances_the_caret_with_the_growing_text() { - let mut t = edit("", EditMode::SingleLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).replace(0, "h"); - assert_eq!(t.caret(), Some(1)); - ctx(&mut t).replace(1, "hi"); - assert_eq!(content(&t), "hi"); - assert_eq!(t.caret(), Some(2)); - ctx(&mut t).replace(2, "hit"); - assert_eq!(content(&t), "hit"); - assert_eq!(t.caret(), Some(3)); - } - - #[test] - fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() { - let mut t = edit("say ", EditMode::SingleLine); - ctx(&mut t).set_caret(4); - ctx(&mut t).replace(0, "hi"); - assert_eq!(content(&t), "say hi"); - ctx(&mut t).replace(0, " "); - assert_eq!(content(&t), "say hi "); - assert_eq!(t.caret(), Some(7)); - } - - #[test] - fn delete_byte_range_removes_exactly_that_range() { - let mut t = edit("hello world", EditMode::SingleLine); - ctx(&mut t).delete_byte_range(5, 11); - assert_eq!(content(&t), "hello"); - assert_eq!(t.caret(), Some(5)); - } - - #[test] - fn set_cursor_byte_collapses_to_a_caret_there() { - let mut t = edit("hello world", EditMode::SingleLine); - ctx(&mut t).select_all(); - ctx(&mut t).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 = edit("abc", EditMode::SingleLine); - ctx(&mut t).set_caret(0); - ctx(&mut t).motion(Motion::Right, false); - assert_eq!(t.caret(), Some(1)); - ctx(&mut t).motion(Motion::Right, true); - assert_eq!(t.selected_text().as_deref(), Some("b")); - } - - #[test] - fn an_unshifted_arrow_collapses_a_span_to_its_edge() { - let mut t = edit("abcdef", EditMode::SingleLine); - ctx(&mut t).select_all(); - ctx(&mut t).motion(Motion::Left, false); - assert_eq!(t.caret(), Some(0)); - - ctx(&mut t).select_all(); - ctx(&mut t).motion(Motion::Right, false); - assert_eq!(t.caret(), Some(6)); - } - - #[test] - fn multibyte_text_is_edited_by_byte_offset() { - let mut t = edit("aé", EditMode::SingleLine); - ctx(&mut t).set_caret(3); - ctx(&mut t).backspace(false); - assert_eq!(content(&t), "a"); - } -} diff --git a/iris/src/widget/text/mod.rs b/iris/src/widget/text/mod.rs deleted file mode 100644 index 745cc88..0000000 --- a/iris/src/widget/text/mod.rs +++ /dev/null @@ -1,745 +0,0 @@ -mod build; -mod edit; -mod selection; - -pub use build::*; -pub use edit::*; -pub use selection::*; - -use crate::prelude::*; - -pub struct Text { - view: TextView, -} - -pub struct TextView { - text: TextHandle, - pub hint: Option, - selection: TextSelection, - overflow: OverflowState, -} - -#[derive(Default)] -struct OverflowState { - active: bool, - viewport: Vec2, - pan: f32, - leading_marker: f32, - trailing_marker: f32, - content_start: f32, - content_end: f32, - suppress_content: bool, - leading_omitted: Option, - trailing_omitted: Option, -} - -#[derive(Clone)] -struct OmittedText { - range: std::ops::Range, - far_edge: usize, -} - -impl TextView { - fn is_blank(&self) -> bool { - self.text.text().is_empty() - } - - pub fn wrap_width(&self) -> Option { - self.text.width() - } - - pub fn new(text: TextHandle, hint: Option) -> Self { - Self { - text, - hint, - selection: TextSelection::default(), - overflow: OverflowState::default(), - } - } - - pub fn region(&self) -> UiRegion { - let align = self.text.attrs().align; - let size = if self.overflow.active { - self.overflow.viewport - } else { - self.text.rendered().map(|t| t.size).unwrap_or(Vec2::ZERO) - }; - size.align(align) - } - - fn render(&mut self, painter: &mut Painter) -> RenderedText { - let width = if self.text.attrs().overflow == TextOverflow::Wrap { - Some(painter.px_size().x) - } else { - None - }; - let tex = painter.render_text(&self.text, width); - if crate::diagnostics::trace_enabled() { - log::debug!( - target: "iris::frame", - "iris text render: chars={} width={width:?} glyphs={} size={:?}", - self.text.text().chars().count(), - tex.glyphs.len(), - tex.size, - ); - } - tex - } - - fn omitted_range(&self, start_x: f32, end_x: f32, far_x: f32) -> OmittedText { - self.text.with_layout(|layout, text| { - let at = |x| { - parley::Selection::from_point(layout, x, self.overflow.viewport.y * 0.5) - .focus() - .index() - }; - let a = at(start_x).min(text.len()); - let b = at(end_x).min(text.len()); - // Parley selections are contiguous source ranges. That is exact - // for an ordinary LTR or RTL run; a clipped side of pathological - // mixed-bidi text can cover discontiguous logical ranges, which - // this selection model cannot express. - OmittedText { - range: a.min(b)..a.max(b), - far_edge: at(far_x).min(text.len()), - } - }) - } - - /// Snaps a pair of canonical-layout clip edges outward from any shaped - /// cluster or ligature they cross. The text itself stays at its exact - /// (possibly animated) pan; only the opaque ellipsis boundary snaps. - fn ellipsis_clip(&self, left: f32, right: f32) -> (f32, f32) { - self.text.with_layout(|layout, _| { - let y = self.overflow.viewport.y * 0.5; - let cluster_group = |x| { - let (mut first, _) = parley::Cluster::from_point(layout, x, y)?; - while first.is_ligature_continuation() { - first = first.previous_visual()?; - } - let start = first.visual_offset()?; - let mut end = start + first.advance(); - let mut last = first; - while let Some(next) = last.next_visual() { - if !next.is_ligature_continuation() { - break; - } - end += next.advance(); - last = next; - } - Some((start, end)) - }; - let left = cluster_group(left).map_or( - left, - |(start, end)| { - if left <= start { start } else { end } - }, - ); - let right = cluster_group(right).map_or( - right, - |(start, end)| { - if right >= end { end } else { start } - }, - ); - (left, right) - }) - } - - fn local_text_point(&self, pos: Vec2, size: Vec2) -> Vec2 { - let mut pos = pos - self.region().top_left().to_abs(size); - pos.x += self.overflow.pan; - pos - } - - fn omitted_at(&self, pos: Vec2, size: Vec2) -> Option { - let pos = pos - self.region().top_left().to_abs(size); - if self.overflow.suppress_content && pos.x >= 0.0 && pos.x <= self.overflow.viewport.x { - let len = self.text.text().len(); - Some(OmittedText { - range: 0..len, - far_edge: if pos.x < self.overflow.viewport.x * 0.5 { - 0 - } else { - len - }, - }) - } else if pos.x >= 0.0 && pos.x <= self.overflow.content_start { - self.overflow.leading_omitted.clone() - } else if pos.x >= self.overflow.content_end && pos.x <= self.overflow.viewport.x { - self.overflow.trailing_omitted.clone() - } else { - None - } - } - - fn omitted_is_selected(&self, omitted: &OmittedText) -> bool { - self.selection.range.is_some_and(|selection| { - let selected = selection.text_range(); - selected.start < omitted.range.end && omitted.range.start < selected.end - }) - } - - fn prepare_overflow( - &mut self, - painter: &mut Painter, - tex: &RenderedText, - follow_caret: bool, - ) -> Option { - self.overflow = OverflowState::default(); - let available = painter.px_size().x.max(0.0); - let excess = (tex.size.x - available).max(0.0); - let (overflow, overflow_position) = { - let attrs = self.text.attrs(); - (attrs.overflow, attrs.overflow_position) - }; - if excess <= 0.0 || matches!(overflow, TextOverflow::Visible | TextOverflow::Wrap) { - return None; - } - - let marker = - (overflow == TextOverflow::Ellipsis).then(|| painter.render_ellipsis(&self.text)); - - let mut pan = overflow_position - .resolve(painter.density()) - .to_abs(excess) - .clamp(0.0, excess); - if follow_caret && let Some(selection) = self.selection.range { - let caret = self - .text - .with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH)); - for _ in 0..2 { - let marker_width = marker.as_ref().map_or(0.0, |marker| marker.size.x); - let leading = pan > 0.0; - let trailing = pan < excess; - let markers_fit = marker_width - * (usize::from(leading) + usize::from(trailing)) as f32 - <= available; - let leading_inset = if markers_fit && leading { - marker_width - } else { - 0.0 - }; - let trailing_inset = if markers_fit && trailing { - marker_width - } else { - 0.0 - }; - if caret.x0 as f32 <= pan + leading_inset { - pan = caret.x0 as f32 - leading_inset; - } else if caret.x1 as f32 >= pan + available - trailing_inset { - pan = caret.x1 as f32 - available + trailing_inset; - } - pan = pan.clamp(0.0, excess); - } - let old_pan = overflow_position.resolve(painter.density()).to_abs(excess); - if pan != old_pan { - self.text.set_overflow_position(Len::abs(pan)); - } - } - - self.overflow.active = true; - self.overflow.viewport = vec2(available, tex.size.y); - self.overflow.pan = pan; - self.overflow.content_end = available; - if overflow != TextOverflow::Ellipsis { - return None; - } - - let marker = marker.expect("ellipsis mode prepared no ellipsis glyph"); - let leading = pan > 0.0; - let trailing = pan < excess; - let markers_fit = - marker.size.x * (usize::from(leading) + usize::from(trailing)) as f32 <= available; - if markers_fit { - self.overflow.leading_marker = if leading { marker.size.x } else { 0.0 }; - self.overflow.trailing_marker = if trailing { marker.size.x } else { 0.0 }; - let (clip_start, clip_end) = self.ellipsis_clip( - pan + self.overflow.leading_marker, - pan + available - self.overflow.trailing_marker, - ); - if clip_start >= clip_end { - self.overflow.suppress_content = true; - return Some(marker); - } - self.overflow.content_start = (clip_start - pan).clamp(0.0, available); - self.overflow.content_end = (clip_end - pan).clamp(0.0, available); - if leading { - self.overflow.leading_omitted = - Some(self.omitted_range(-tex.size.x, clip_start, -tex.size.x)); - } - if trailing { - self.overflow.trailing_omitted = - Some(self.omitted_range(clip_end, tex.size.x * 2.0, tex.size.x * 2.0)); - } - } else { - self.overflow.suppress_content = true; - } - Some(marker) - } - - fn draw_inner(&mut self, painter: &mut Painter, follow_caret: bool) -> Size { - let tex = self.render(painter); - if self.is_blank() - && let Some(hint) = &self.hint - { - self.overflow = OverflowState::default(); - return painter.widget(hint).size(); - } - let marker = self.prepare_overflow(painter, &tex, follow_caret); - let align = self.text.attrs().align; - if !self.overflow.active { - let within = tex.size.align(align).within(&painter.region()); - painter.glyphs(&tex, within); - return Size::abs(tex.size); - } - - let viewport = self.overflow.viewport.align(align); - let marker_highlight = painter.paint(&PaintId::SKY); - if self.overflow.suppress_content - && self - .selection - .range - .is_some_and(|selection| !selection.is_collapsed()) - { - painter.primitive_within( - RectPrimitive::color(marker_highlight), - viewport.within(&painter.region()), - ); - } else { - if self - .overflow - .leading_omitted - .as_ref() - .is_some_and(|omitted| self.omitted_is_selected(omitted)) - { - let marker_region = vec2(self.overflow.content_start, self.overflow.viewport.y) - .align(Align::TOP_LEFT) - .within(&viewport) - .within(&painter.region()); - painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region); - } - if self - .overflow - .trailing_omitted - .as_ref() - .is_some_and(|omitted| self.omitted_is_selected(omitted)) - { - let marker_region = vec2( - self.overflow.viewport.x - self.overflow.content_end, - self.overflow.viewport.y, - ) - .align(Align::TOP_LEFT) - .offset(vec2(self.overflow.content_end, 0.0)) - .within(&viewport) - .within(&painter.region()); - painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region); - } - } - if let Some(marker) = marker { - if self.overflow.leading_marker > 0.0 { - painter.glyphs(&marker, viewport.within(&painter.region())); - } - if self.overflow.trailing_marker > 0.0 { - let offset = vec2( - self.overflow.viewport.x - self.overflow.trailing_marker, - 0.0, - ); - painter.glyphs(&marker, viewport.offset(offset).within(&painter.region())); - } - } - painter.child_layer(); - let content_width = if self.overflow.suppress_content { - 0.0 - } else { - (self.overflow.content_end - self.overflow.content_start).max(0.0) - }; - let horizontal_mask = vec2(content_width, self.overflow.viewport.y) - .align(Align::TOP_LEFT) - .offset(vec2(self.overflow.content_start, 0.0)) - .within(&viewport) - .within(&painter.region()); - let mask = UiRegion::new(horizontal_mask.x, UiSpan::FULL); - painter.set_mask(mask); - let origin = tex - .size - .align(Align::TOP_LEFT) - .offset(vec2(-self.overflow.pan, 0.0)) - .within(&viewport) - .within(&painter.region()); - painter.glyphs(&tex, origin); - Size::abs(self.overflow.viewport) - } - - pub fn draw(&mut self, painter: &mut Painter) -> Size { - self.draw_inner(painter, false) - } - - pub(super) fn draw_selectable(&mut self, painter: &mut Painter, caret: bool) -> Size { - let base = painter.layer; - painter.child_layer(); - let used = self.draw_inner(painter, caret); - painter.layer = base; - let region = self.region(); - - let Some(selection) = self.selection.range else { - return used; - }; - let geometry = self - .text - .with_layout(|layout, _| selection.geometry(layout)); - for (rect, _) in geometry { - let size = vec2(rect.width() as f32, rect.height() as f32); - let top_left = vec2(rect.x0 as f32 - self.overflow.pan, rect.y0 as f32); - let paint = painter.paint(&PaintId::SKY); - painter.primitive_within( - RectPrimitive::color(paint), - size.align(Align::TOP_LEFT).offset(top_left).within(®ion), - ); - } - if caret { - let caret = self - .text - .with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH)); - let size = vec2(caret.width() as f32, caret.height() as f32); - let mut x = caret.x0 as f32 - self.overflow.pan; - if self.overflow.active && !self.overflow.suppress_content { - x = x.clamp( - self.overflow.content_start, - (self.overflow.content_end - size.x).max(self.overflow.content_start), - ); - } - let top_left = vec2(x, caret.y0 as f32); - let paint = painter.paint(&PaintId::WHITE); - painter.primitive_within( - RectPrimitive::color(paint), - size.align(Align::TOP_LEFT).offset(top_left).within(®ion), - ); - } - used - } - - pub fn content(&self) -> String { - self.text.text().to_string() - } - - pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { - self.text.attrs_mut() - } - - fn set_overflow(&mut self, overflow: TextOverflow) { - self.text.set_overflow(overflow); - } - - fn set_overflow_position(&mut self, position: impl Into) { - self.text.set_overflow_position(position.into()); - } -} - -impl Text { - pub fn set_text(&mut self, content: impl Into) { - if self.view.text.set_text(content) { - self.view.selection.deselect(); - } - } - - pub fn selected_text(&self) -> Option { - self.view.selection.selected_text(&self.view.text.text()) - } - - pub fn selection_range(&self) -> Option> { - self.view.selection.range() - } - - pub fn set_with_spans(&mut self, content: impl Into, spans: Vec) { - self.view.text.set_text(content); - self.view.text.set_spans(spans); - self.view.selection.deselect(); - } - - pub fn content(&self) -> String { - self.view.content() - } - - pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { - self.view.attrs_mut() - } - - pub fn set_overflow(&mut self, overflow: TextOverflow) { - self.view.set_overflow(overflow); - } - - pub fn set_overflow_position(&mut self, position: impl Into) { - self.view.set_overflow_position(position); - } -} - -impl Widget for Text { - fn draw(&mut self, painter: &mut Painter) { - let size = if self.view.selection.range.is_some() { - self.view.draw_selectable(painter, false) - } else { - self.view.draw(painter) - }; - painter.set_size(size); - } - - fn requires_exact_region(&self) -> bool { - true - } -} - -pub(super) const CARET_WIDTH: f32 = 1.0; - -#[cfg(test)] -mod tests { - use crate::layout_tests::TestRsc; - use crate::prelude::*; - - fn rendered_text(content: &str) -> (TestRsc, UiRenderState, WeakWidget, StrongWidget) { - let mut rsc = TestRsc { ui: Ui::default() }; - let text = wtext(content).add_strong(&mut rsc); - let id = text.weak(); - let root = text.any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - (rsc, render, id, root) - } - - #[test] - fn display_text_and_edit_text_use_the_same_selection_engine() { - let (mut rsc, _render, text, _root) = rendered_text("hello there"); - text.selection(&mut rsc).select_all(); - - let edit = wtext("hello there") - .editable(EditMode::MultiLine) - .add_strong(&mut rsc); - let edit_id = edit.weak(); - edit_id(&mut rsc).select_all(); - - assert_eq!( - rsc.ui.widgets[text].selection_range(), - rsc.ui.widgets[edit_id].selection_range() - ); - assert_eq!( - rsc.ui.widgets[text].selected_text(), - rsc.ui.widgets[edit_id].selected_text() - ); - } - - #[test] - fn changing_display_text_clears_its_now_stale_selection() { - let (mut rsc, mut render, text, root) = rendered_text("before"); - text.selection(&mut rsc).select_all(); - assert_eq!( - rsc.ui.widgets[text].selected_text().as_deref(), - Some("before") - ); - - rsc.ui.widgets[text].set_text("after"); - render.update(&root, &mut rsc); - - assert_eq!(rsc.ui.widgets[text].selected_text(), None); - assert_eq!(rsc.ui.widgets[text].selection_range(), None); - } - - #[test] - fn display_text_draws_the_shared_highlight_without_an_editing_caret() { - let (mut rsc, mut render, text, root) = rendered_text("selected"); - let plain = render.active[&text.id()].primitives.len(); - text.selection(&mut rsc).select_all(); - render.update(&root, &mut rsc); - let selected = render.active[&text.id()].primitives.len(); - - let edit = wtext("selected") - .editable(EditMode::MultiLine) - .add_strong(&mut rsc); - let edit_id = edit.weak(); - let edit_root = edit.any(); - let mut edit_render = UiRenderState::new(); - edit_render.resize((800.0, 600.0)); - edit_render.update(&edit_root, &mut rsc); - edit_id(&mut rsc).select_all(); - edit_render.update(&edit_root, &mut rsc); - let editable = edit_render.active[&edit_id.id()].primitives.len(); - - assert!( - selected > plain, - "the selection added no highlight primitive" - ); - assert_eq!( - editable, - selected + 1, - "editable text should add exactly its caret to the shared highlight" - ); - } - - #[test] - fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() { - let mut rsc = TestRsc { ui: Ui::default() }; - let root = wtext("hello there") - .size(18) - .color(PaintId::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.borrow().atlas.glyph_count(); - assert!(rasterised > 0, "the first frame rasterised no glyphs"); - - rsc.ui.text.borrow_mut().atlas.clear(); - assert_eq!(rsc.ui.text.borrow().atlas.glyph_count(), 0); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - - assert_eq!( - rsc.ui.text.borrow().atlas.glyph_count(), - rasterised, - "the second frame re-emitted its cached glyphs instead of \ - re-rendering them against the fresh atlas" - ); - } - - #[test] - fn ellipsis_position_moves_without_reshaping_and_is_not_a_source_hit() { - let mut rsc = TestRsc { ui: Ui::default() }; - let text = wtext("one two three four five") - .overflow(TextOverflow::Ellipsis) - .size(18) - .add(&mut rsc); - let root = text.width(abs(90)).add_strong(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - render.take_counters(); - - let height = rsc.ui.widgets[text].view.overflow.viewport.y; - assert!(rsc.ui.widgets[text].view.overflow.trailing_marker > 0.0); - assert_eq!( - text.selection(&mut rsc) - .source_byte_at(vec2(89.0, height * 0.5), vec2(90.0, height)), - None, - ); - - rsc.ui.widgets[text].set_overflow_position(rel(0.5)); - render.update(&root, &mut rsc); - assert_eq!(render.take_counters().shapes, 0); - assert!(rsc.ui.widgets[text].view.overflow.leading_marker > 0.0); - assert!(rsc.ui.widgets[text].view.overflow.trailing_marker > 0.0); - { - let view = &rsc.ui.widgets[text].view; - let edges = [ - view.overflow.pan + view.overflow.content_start, - view.overflow.pan + view.overflow.content_end, - ]; - view.text.with_layout(|layout, _| { - for edge in edges { - let (cluster, _) = - parley::Cluster::from_point(layout, edge, view.overflow.viewport.y * 0.5) - .unwrap(); - let start = cluster.visual_offset().unwrap(); - let end = start + cluster.advance(); - assert!( - (edge - start).abs() < 0.01 || (edge - end).abs() < 0.01, - "ellipsis clip {edge} crossed cluster {start}..{end}", - ); - } - }); - } - - rsc.ui.widgets[text].set_overflow_position(rel(1)); - render.update(&root, &mut rsc); - assert_eq!(render.take_counters().shapes, 0); - assert!(rsc.ui.widgets[text].view.overflow.leading_marker > 0.0); - assert_eq!(rsc.ui.widgets[text].view.overflow.trailing_marker, 0.0); - } - - #[test] - fn overflow_masks_only_the_horizontal_axis() { - let mut rsc = TestRsc { ui: Ui::default() }; - let text = wtext("glyphs jumping") - .overflow(TextOverflow::Hidden) - .size(18) - .add(&mut rsc); - let root = text.width(abs(50)).add_strong(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - - let mask = rsc.ui.masks[render.active[&text.id()].own_mask.idx()]; - let region = render.primitives.instance(mask.primitive).region; - assert_eq!(region.y, UiSpan::FULL); - assert_ne!(region.x, UiSpan::FULL); - } - - #[test] - fn selecting_an_ellipsis_selects_the_omitted_source_text() { - let mut rsc = TestRsc { ui: Ui::default() }; - let text = wtext("one two three four five") - .overflow(TextOverflow::Ellipsis) - .size(18) - .add(&mut rsc); - let root = text.width(abs(90)).add_strong(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - - let height = rsc.ui.widgets[text].view.overflow.viewport.y; - text.selection(&mut rsc) - .select(vec2(89.0, height * 0.5), vec2(90.0, height), false, false); - let selected = rsc.ui.widgets[text].selected_text().unwrap(); - assert!(!selected.is_empty()); - assert!("one two three four five".ends_with(&selected)); - assert!(!selected.contains('…')); - } - - #[test] - fn a_viewport_too_narrow_for_its_markers_exposes_no_hidden_source_hit() { - let mut rsc = TestRsc { ui: Ui::default() }; - let text = wtext("too wide") - .overflow(TextOverflow::Ellipsis) - .size(18) - .add(&mut rsc); - let root = text.width(abs(1)).add_strong(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - - let height = rsc.ui.widgets[text].view.overflow.viewport.y; - assert_eq!( - text.selection(&mut rsc) - .source_byte_at(vec2(0.5, height * 0.5), vec2(1.0, height)), - None, - ); - text.selection(&mut rsc) - .select(vec2(0.5, height * 0.5), vec2(1.0, height), false, false); - assert_eq!( - rsc.ui.widgets[text].selected_text().as_deref(), - Some("too wide") - ); - } - - #[test] - fn an_editable_caret_updates_the_overflow_position_it_exposes() { - let mut rsc = TestRsc { ui: Ui::default() }; - let edit = wtext("one two three four five") - .overflow(TextOverflow::Hidden) - .editable(EditMode::SingleLine) - .size(18) - .add(&mut rsc); - let root = edit.width(abs(90)).add_strong(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - render.update(&root, &mut rsc); - - let len = rsc.ui.widgets[edit].content().len(); - edit(&mut rsc).set_cursor_byte(len); - render.update(&root, &mut rsc); - - let attrs = rsc.ui.widgets[edit].view.text.attrs(); - assert!(attrs.overflow_position.abs > 0.0); - assert_eq!(attrs.overflow_position.rel, 0.0); - } -} diff --git a/iris/src/widget/text/selection.rs b/iris/src/widget/text/selection.rs deleted file mode 100644 index fc5227b..0000000 --- a/iris/src/widget/text/selection.rs +++ /dev/null @@ -1,782 +0,0 @@ -use crate::prelude::*; -use parley::{Affinity, Selection as ParleySelection}; -use std::time::Instant; - -/// The selection state shared by display text and editable text. Editing, -/// focus and IME state deliberately live in `TextEdit`; this owns only the -/// state whose meaning comes from a shaped text layout. -#[derive(Default)] -pub(super) struct TextSelection { - pub(super) range: Option, - double_hit: Option, -} - -impl TextSelection { - pub(super) fn selected_text(&self, text: &str) -> Option { - let selection = self.range?; - if selection.is_collapsed() { - return None; - } - Some(text[selection.text_range()].to_string()) - } - - pub(super) fn range(&self) -> Option> { - Some(self.range?.text_range()) - } - - pub(super) fn caret(&self) -> Option { - Some(self.range?.focus().index()) - } - - pub(super) fn deselect(&mut self) { - self.range = None; - self.double_hit = None; - } -} - -/// Selection operations shared by ordinary and editable text, using the -/// widget's arena-backed layout rather than maintaining an editable-only copy -/// of the geometry and hit testing. -pub struct TextSelectionCtx<'a> { - pub(super) view: &'a mut TextView, -} - -impl TextSelectionCtx<'_> { - pub(crate) fn refresh(&mut self) { - if let Some(selection) = self.view.selection.range { - self.view.selection.range = Some( - self.view - .text - .with_layout(|layout, _| selection.refresh(layout)), - ); - } - } - - /// The byte offset in the text nearest `pos`. Positions and `size` use - /// the same widget-local coordinates as a `CursorSense` event. - pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { - if let Some(omitted) = self.view.omitted_at(pos, size) { - return omitted.far_edge; - } - let pos = self.view.local_text_point(pos, size); - self.view.text.with_layout(|layout, _| { - ParleySelection::from_point(layout, pos.x, pos.y) - .focus() - .index() - }) - } - - /// Like [`byte_at`](Self::byte_at), except an ellipsis is presentation - /// rather than source text and therefore returns `None` instead of a - /// nearby byte. Use this for links and other source-span hit testing. - pub fn source_byte_at(&mut self, pos: Vec2, size: Vec2) -> Option { - if self.view.omitted_at(pos, size).is_some() { - None - } else { - Some(self.byte_at(pos, size)) - } - } - - pub fn select_all(&mut self) { - let len = self.view.text.text().len(); - if len == 0 { - return; - } - self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { - let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default()); - let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default()); - ParleySelection::new(anchor, focus) - })); - } - - pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { - let omitted = self.view.omitted_at(pos, size); - let pos = self.view.local_text_point(pos, size); - let previous = self.view.selection.range; - let previous_hit = self.view.selection.double_hit; - - let outcome = self.view.text.with_layout(|layout, _| { - if let Some(omitted) = omitted { - let anchor = if drag { - previous - .map(|selection| selection.anchor()) - .unwrap_or_else(|| { - parley::Cursor::from_byte_index( - layout, - omitted.range.start, - Affinity::default(), - ) - }) - } else { - parley::Cursor::from_byte_index( - layout, - omitted.range.start, - Affinity::default(), - ) - }; - let focus = parley::Cursor::from_byte_index( - layout, - if drag { - omitted.far_edge - } else { - omitted.range.end - }, - Affinity::default(), - ); - return Some((Some(ParleySelection::new(anchor, focus)), None)); - } - if drag { - previous.map(|selection| { - ( - Some(selection.extend_to_point(layout, pos.x, pos.y)), - previous_hit, - ) - }) - } else { - let hit = ParleySelection::from_point(layout, pos.x, pos.y); - let index = hit.focus().index(); - Some(if recent && previous_hit == Some(index) { - ( - Some(ParleySelection::line_from_point(layout, pos.x, pos.y)), - None, - ) - } else if recent - && previous.map(|selection| selection.focus().index()) == Some(index) - { - ( - Some(ParleySelection::word_from_point(layout, pos.x, pos.y)), - Some(index), - ) - } else { - (Some(hit), None) - }) - } - }); - - if let Some((range, double_hit)) = outcome { - self.view.selection.range = range; - self.view.selection.double_hit = double_hit; - } - } - - pub fn deselect(&mut self) { - self.view.selection.deselect(); - } - - pub(crate) fn set_caret(&mut self, index: usize) { - let index = index.min(self.view.text.text().len()); - self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { - ParleySelection::from_byte_index(layout, index, Affinity::default()) - })); - } - - fn select_between(&mut self, anchor: usize, focus: usize) { - let len = self.view.text.text().len(); - self.view.selection.range = Some(self.view.text.with_layout(|layout, _| { - let anchor = - parley::Cursor::from_byte_index(layout, anchor.min(len), Affinity::default()); - let focus = - parley::Cursor::from_byte_index(layout, focus.min(len), Affinity::default()); - ParleySelection::new(anchor, focus) - })); - } -} - -/// Gives an ordinary `Text` handle access to the same selection operations as -/// `TextEdit`. Gesture policy is intentionally not part of this trait; a -/// selection controller and an editor's focus handler do different things -/// with the same mechanics. -pub trait TextSelectable { - fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a>; -} - -impl> TextSelectable for I { - fn selection<'a>(&self, ui: &'a mut impl UiRsc) -> TextSelectionCtx<'a> { - let ui: &mut UiData = ui.ui_mut(); - TextSelectionCtx { - view: &mut ui.widgets.get_mut(self).unwrap().view, - } - } -} - -/// Selection across the ordinary `Text` descendants of the widget this -/// controller is attached to. The controller owns the cross-widget gesture -/// and command state; each text leaf owns only its local Parley selection. -pub struct SelectionController { - anchor: Option<(WidgetId, usize)>, - order: Vec, - selected: Vec, - gesture: DragGesture, - scroll: Option>, - separator: String, - last_input: Option<(Instant, CursorSense, SelectionInput)>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum SelectionInput { - Tapped, - Handled, -} - -impl Default for SelectionController { - fn default() -> Self { - Self::new() - } -} - -impl SelectionController { - pub fn new() -> Self { - Self { - anchor: None, - order: Vec::new(), - selected: Vec::new(), - gesture: DragGesture::new(), - scroll: None, - separator: String::new(), - last_input: None, - } - } - - pub fn with_scroll(mut self, scroll: WeakWidget) -> Self { - self.scroll = Some(scroll); - self - } - - pub fn separator(mut self, separator: impl Into) -> Self { - self.separator = separator.into(); - self - } - - fn text_order(host: WidgetId, rsc: &impl UiRsc, render: &UiRenderState) -> Vec { - fn visit(id: WidgetId, rsc: &impl UiRsc, render: &UiRenderState, out: &mut Vec) { - if rsc - .widgets() - .get_dyn(id) - .is_some_and(|widget| widget.as_any().is::()) - { - out.push(id); - return; - } - // An editor owns its own focus, commands and selection gesture. - if rsc - .widgets() - .get_dyn(id) - .is_some_and(|widget| widget.as_any().is::()) - { - return; - } - for child in render.ordered_children(id, rsc) { - visit(child, rsc, render, out); - } - } - - let mut out = Vec::new(); - visit(host, rsc, render, &mut out); - out - } - - fn with_text( - rsc: &mut impl UiRsc, - id: WidgetId, - f: impl FnOnce(&mut TextSelectionCtx<'_>) -> T, - ) -> Option { - let ui: &mut UiData = rsc.ui_mut(); - let text = ui - .widgets - .get_dyn_mut(id)? - .as_any_mut() - .downcast_mut::()?; - let mut ctx = TextSelectionCtx { - view: &mut text.view, - }; - Some(f(&mut ctx)) - } - - fn locate( - &self, - rsc: &impl UiRsc, - render: &UiRenderState, - pos: Vec2, - ) -> Option<(WidgetId, Vec2, Vec2)> { - self.order.iter().find_map(|&id| { - let active = render.active.get(&id)?; - let region = render.window_region(&id, rsc)?; - (region.contains(pos) && render.mask_admits(active.mask, pos, rsc)).then(|| { - ( - id, - pos - region.top_left, - region.bot_right - region.top_left, - ) - }) - }) - } - - fn deselect(&mut self, rsc: &mut impl UiRsc) { - let anchor = self.anchor.take().map(|(id, _)| id); - let anchor_was_selected = anchor.is_some_and(|anchor| self.selected.contains(&anchor)); - for id in self.selected.drain(..) { - Self::with_text(rsc, id, |text| text.deselect()); - } - if let Some(anchor) = anchor - && !anchor_was_selected - { - Self::with_text(rsc, anchor, |text| text.deselect()); - } - } - - fn begin(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) { - self.deselect(rsc); - let byte = Self::with_text(rsc, id, |text| { - text.select(pos, size, false, false); - text.byte_at(pos, size) - }); - self.anchor = byte.map(|byte| (id, byte)); - } - - fn extend(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) { - let Some((anchor, anchor_byte)) = self.anchor else { - return; - }; - let Some(anchor_at) = self.order.iter().position(|&candidate| candidate == anchor) else { - self.deselect(rsc); - return; - }; - let Some(focus_at) = self.order.iter().position(|&candidate| candidate == id) else { - return; - }; - let Some(focus_byte) = Self::with_text(rsc, id, |text| text.byte_at(pos, size)) else { - return; - }; - let (lo, hi) = if anchor_at <= focus_at { - (anchor_at, focus_at) - } else { - (focus_at, anchor_at) - }; - - for &old_id in &self.selected { - if !self.order[lo..=hi].contains(&old_id) { - Self::with_text(rsc, old_id, |text| text.deselect()); - } - } - - for &text_id in &self.order[lo..=hi] { - let forward = anchor_at <= focus_at; - Self::with_text(rsc, text_id, |text| { - let len = text.view.text.text().len(); - let (start, end) = if text_id == anchor && text_id == id { - (anchor_byte, focus_byte) - } else if text_id == anchor { - (anchor_byte, if forward { len } else { 0 }) - } else if text_id == id { - (if forward { 0 } else { len }, focus_byte) - } else { - (0, len) - }; - text.select_between(start, end); - }); - } - self.selected.clear(); - self.selected.extend_from_slice(&self.order[lo..=hi]); - } - - pub fn drag( - &mut self, - id: ControllerId, - rsc: &mut Rsc, - input: &CursorData<'_>, - ) -> SelectionInput { - // A leaf listener and the controller host may both cover one point on - // the same layer. They are two routes for one physical sample, not two - // gestures; the second route must observe the first route's decision. - if let Some((last, sense, outcome)) = self.last_input - && last == input.cursor.time - && sense == input.sense - { - return outcome; - } - self.order = Self::text_order(id.host(), rsc, input.render); - let hit = self.locate(rsc, input.render, input.cursor.pos); - let mut press = PressState::default(); - if self.gesture.starts_press(input.sense) { - press.scrolling = self.scroll.is_some_and(|scroll| scroll(rsc).is_scrolling()); - if let Some(scroll) = self.scroll { - scroll(rsc).cancel_fling(); - } - } - press.already_selected = self.has_selection(rsc); - let outcome = self.gesture.handle( - input.pointer, - id.host(), - input.sense, - input.cursor.pos, - input.cursor.time, - press, - ); - let input_result = match outcome { - GestureOutcome::Pan(delta) => { - if let Some(scroll) = self.scroll { - scroll(rsc).scroll(delta); - } - SelectionInput::Handled - } - GestureOutcome::SelectStart => { - if let Some((text, pos, size)) = hit { - self.begin(rsc, text, pos, size); - rsc.set_command_target(Some(id)); - } - SelectionInput::Handled - } - GestureOutcome::SelectExtend => { - if let Some((text, pos, size)) = hit { - self.extend(rsc, text, pos, size); - } - SelectionInput::Handled - } - GestureOutcome::Released(Some(velocity)) => { - if let Some(scroll) = self.scroll { - scroll(rsc).fling(velocity); - } - SelectionInput::Handled - } - GestureOutcome::Tapped => { - if self.anchor.is_some() || !self.selected.is_empty() { - self.deselect(rsc); - rsc.set_command_target(None); - SelectionInput::Handled - } else { - SelectionInput::Tapped - } - } - GestureOutcome::Cancelled - | GestureOutcome::Undecided - | GestureOutcome::Released(None) => SelectionInput::Handled, - }; - self.last_input = Some((input.cursor.time, input.sense, input_result)); - input_result - } - - pub fn has_selection(&self, rsc: &impl UiRsc) -> bool { - self.selected.iter().any(|&id| { - rsc.widgets() - .get_dyn(id) - .and_then(|widget| widget.as_any().downcast_ref::()) - .is_some_and(|text| text.selected_text().is_some()) - }) - } - - pub fn selected_text(&self, rsc: &impl UiRsc) -> Option { - let parts: Vec = self - .selected - .iter() - .filter_map(|&id| { - rsc.widgets() - .get_dyn(id) - .and_then(|widget| widget.as_any().downcast_ref::()) - .and_then(Text::selected_text) - }) - .collect(); - (!parts.is_empty()).then(|| parts.join(&self.separator)) - } -} - -impl Controller for SelectionController { - fn command(&mut self, command: Command, rsc: &mut Rsc) -> CommandResult { - match command { - Command::Copy => self - .selected_text(rsc) - .map(CommandResult::Copy) - .unwrap_or(CommandResult::Unused), - Command::SelectAll => { - self.deselect(rsc); - for &id in &self.order { - Self::with_text(rsc, id, |text| text.select_all()); - } - self.selected.extend_from_slice(&self.order); - CommandResult::Used - } - Command::Escape => { - self.deselect(rsc); - CommandResult::Used - } - } - } -} - -#[cfg(test)] -mod controller_tests { - use super::*; - - struct TestRsc { - ui: Ui, - events: EventManager, - } - - impl UiRsc for TestRsc { - fn ui(&self) -> &Ui { - &self.ui - } - - fn ui_mut(&mut self) -> &mut Ui { - &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 TestRsc { - type State = (); - } - - impl HasEvents for TestRsc { - fn events(&self) -> &EventManager { - &self.events - } - - fn events_mut(&mut self) -> &mut EventManager { - &mut self.events - } - } - - fn two_texts( - dir: Dir, - ) -> ( - TestRsc, - UiRenderState, - WeakWidget, - WeakWidget, - WeakWidget, - StrongWidget, - ) { - let mut rsc = TestRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - let first = wtext("first").add(&mut rsc); - let second = wtext("second").add(&mut rsc); - let host = (first, second) - .span(dir) - .controller(SelectionController::new().separator("|")) - .add(&mut rsc); - let root = host.upgrade(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((400.0, 200.0)); - render.update(&root, &mut rsc); - (rsc, render, host, first, second, root) - } - - #[test] - fn a_span_orders_selection_on_its_visual_axis() { - let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::LEFT); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.with_controller::(id, |selection, rsc| { - selection.order = SelectionController::text_order(host.id(), rsc, &render); - }); - rsc.set_command_target(Some(id)); - - assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used); - assert_eq!( - rsc.run_command(Command::Copy), - CommandResult::Copy("second|first".to_string()) - ); - } - - #[test] - fn a_widget_without_an_order_override_keeps_draw_order() { - let mut rsc = TestRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - let first = wtext("back").add(&mut rsc); - let second = wtext("front").add(&mut rsc); - let host = (first, second) - .stack() - .controller(SelectionController::new().separator("|")) - .add(&mut rsc); - let root = host.upgrade(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((400.0, 200.0)); - render.update(&root, &mut rsc); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.with_controller::(id, |selection, rsc| { - selection.order = SelectionController::text_order(host.id(), rsc, &render); - }); - rsc.set_command_target(Some(id)); - - assert_eq!(rsc.run_command(Command::SelectAll), CommandResult::Used); - assert_eq!( - rsc.run_command(Command::Copy), - CommandResult::Copy("back|front".to_string()) - ); - } - - #[test] - fn nearest_controller_prefers_the_inner_scope() { - let mut rsc = TestRsc { - ui: Ui::default(), - events: EventManager::default(), - }; - let leaf = wtext("leaf").add(&mut rsc); - let inner = (leaf,) - .span(Dir::DOWN) - .controller(SelectionController::new()) - .add(&mut rsc); - let outer = (inner,) - .span(Dir::DOWN) - .controller(SelectionController::new()) - .add(&mut rsc); - let root = outer.upgrade(&mut rsc).any(); - let mut render = UiRenderState::new(); - render.resize((400.0, 200.0)); - render.update(&root, &mut rsc); - - let found = rsc - .events() - .controllers - .nearest_id::(leaf.id(), &render) - .unwrap(); - assert_eq!(found.host(), inner.id()); - } - - #[test] - fn command_target_outlives_pointer_release_and_copies_the_controller_selection() { - let (mut rsc, render, host, first, second, _root) = two_texts(Dir::DOWN); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.with_controller::(id, |selection, rsc| { - selection.order = SelectionController::text_order(host.id(), rsc, &render); - let first_size = render.window_region(&first, rsc).unwrap().size(); - let second_size = render.window_region(&second, rsc).unwrap().size(); - selection.begin(rsc, first.id(), Vec2::ZERO, first_size); - selection.extend(rsc, second.id(), second_size, second_size); - }); - rsc.set_command_target(Some(id)); - - assert_eq!( - rsc.run_command(Command::Copy), - CommandResult::Copy("first|second".to_string()) - ); - } - - #[test] - fn tapping_after_selection_deselects_and_releases_the_command_target() { - let (mut rsc, render, host, _first, _second, _root) = two_texts(Dir::DOWN); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.with_controller::(id, |selection, rsc| { - selection.order = SelectionController::text_order(host.id(), rsc, &render); - let order = selection.order.clone(); - for &text in &order { - SelectionController::with_text(rsc, text, |text| text.select_all()); - } - selection.selected = order; - }); - rsc.set_command_target(Some(id)); - - let pointer = PointerRequests::default(); - let now = Instant::now(); - rsc.with_controller::(id, |selection, rsc| { - let press = CursorData { - pos: Vec2::ZERO, - size: Vec2::ZERO, - scroll_delta: Vec2::ZERO, - hover: Default::default(), - cursor: CursorState { - pos: Vec2::ZERO, - time: now, - ..Default::default() - }, - drag_axis: None, - captured: false, - sense: CursorSense::PressStart(CursorButton::Left), - render: &render, - pointer: &pointer, - }; - selection.drag(id, rsc, &press); - let release = CursorData { - pos: Vec2::ZERO, - size: Vec2::ZERO, - scroll_delta: Vec2::ZERO, - hover: Default::default(), - cursor: CursorState { - pos: Vec2::ZERO, - time: now + std::time::Duration::from_millis(20), - ..Default::default() - }, - drag_axis: None, - captured: false, - sense: CursorSense::PressEnd(CursorButton::Left), - render: &render, - pointer: &pointer, - }; - assert_eq!(selection.drag(id, rsc, &release), SelectionInput::Handled); - }); - - assert_eq!(rsc.events().controllers.command_target(), None); - assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused); - } - - #[test] - fn removing_a_controller_host_clears_its_command_target() { - let (mut rsc, mut render, host, _first, _second, root) = two_texts(Dir::DOWN); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.set_command_target(Some(id)); - - drop(root); - render.update(None, &mut rsc); - rsc.free(); - - assert_eq!(rsc.events().controllers.command_target(), None); - assert_eq!(rsc.run_command(Command::Copy), CommandResult::Unused); - } - - #[test] - fn removing_a_host_during_a_callback_does_not_restore_its_controller() { - let (mut rsc, _render, host, _first, _second, _root) = two_texts(Dir::DOWN); - let id = rsc - .events() - .controllers - .id::(host.id()) - .unwrap(); - rsc.set_command_target(Some(id)); - - rsc.with_controller::(id, |_selection, rsc| { - rsc.events_mut().controllers.remove(host.id()); - }); - - assert!( - rsc.events() - .controllers - .id::(host.id()) - .is_none() - ); - assert_eq!(rsc.events().controllers.command_target(), None); - } -} diff --git a/iris/src/widget/trait_fns.rs b/iris/src/widget/trait_fns.rs deleted file mode 100644 index 089f73e..0000000 --- a/iris/src/widget/trait_fns.rs +++ /dev/null @@ -1,156 +0,0 @@ -use super::*; -use crate::prelude::*; - -// these methods should "not require any context" (require unit) because they're in core -widget_trait! { - pub trait CoreWidget; - - fn pad(self, padding: impl Into) -> impl WidgetFn { - |state| Pad { - padding: padding.into(), - inner: self.add_strong(state), - exact_region: false, - } - } - - fn align(self, align: impl Into) -> impl WidgetFn { - move |state| Aligned { - inner: self.add_strong(state), - align: align.into(), - } - } - - fn center(self) -> impl WidgetFn { - self.align(Align::CENTER) - } - - fn label(self, label: impl Into) -> impl WidgetIdFn { - |state| { - let id = self.add(state); - state.ui_mut().widgets.set_label(id, label.into()); - id - } - } - - fn sized(self, size: impl Into) -> impl WidgetFn { - let size = size.into(); - move |state| Sized { - inner: self.add_strong(state), - x: Some(size.x), - y: Some(size.y), - } - } - - fn max_width(self, len: impl Into) -> impl WidgetFn { - let len = len.into(); - move |state| MaxSize { - inner: self.add_strong(state), - x: Some(len), - y: None, - } - } - - fn max_height(self, len: impl Into) -> impl WidgetFn { - let len = len.into(); - move |state| MaxSize { - inner: self.add_strong(state), - x: None, - y: Some(len), - } - } - - fn width(self, len: impl Into) -> impl WidgetFn { - let len = len.into(); - move |state| Sized { - inner: self.add_strong(state), - x: Some(len), - y: None, - } - } - - fn height(self, len: impl Into) -> impl WidgetFn { - let len = len.into(); - move |state| Sized { - inner: self.add_strong(state), - x: None, - y: Some(len), - } - } - - fn offset(self, amt: impl Into) -> impl WidgetFn { - move |state| Offset { - inner: self.add_strong(state), - amt: amt.into(), - } - } - - /// 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| { - 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), - } - } - - fn masked_by(self, shape: impl WidgetLike) -> impl WidgetFn { - move |state| Masked { - shape: Some(shape.add_strong(state)), - inner: self.add_strong(state), - } - } - - fn background(self, w: impl WidgetLike) -> impl WidgetFn { - move |state| Stack { - children: vec![w.add_strong(state), self.add_strong(state)], - size: StackSize::Child(1), - } - } - - fn foreground(self, w: impl WidgetLike) -> impl WidgetFn { - move |state| Stack { - children: vec![self.add_strong(state), w.add_strong(state)], - size: StackSize::Child(0), - } - } - - fn layer_offset(self, offset: usize) -> impl WidgetFn { - move |state| LayerOffset { - inner: self.add_strong(state), - offset, - } - } - - fn to_any(self) -> impl WidgetIdFn { - |state| self.add(state) - } - - fn set_ptr(self, ptr: WeakWidget, state: &mut Rsc) { - let id = self.add_strong(state); - state.ui_mut().widgets[ptr].inner = Some(id); - } -} - -pub trait CoreWidgetArr, Tag> { - fn span(self, dir: Dir) -> SpanBuilder; - fn stack(self) -> StackBuilder; -} - -impl, Tag> - CoreWidgetArr for Wa -{ - fn span(self, dir: Dir) -> SpanBuilder { - SpanBuilder::new(self, dir) - } - fn stack(self) -> StackBuilder { - StackBuilder::new(self) - } -} diff --git a/iris/tests/allocation.rs b/iris/tests/allocation.rs deleted file mode 100644 index 1d4e3c7..0000000 --- a/iris/tests/allocation.rs +++ /dev/null @@ -1,126 +0,0 @@ -use iris::prelude::*; -use std::{ - alloc::{GlobalAlloc, Layout, System}, - cell::Cell, - time::{Duration, Instant}, -}; - -struct CountingAllocator; - -thread_local! { - static TRACKING: Cell = const { Cell::new(false) }; - static ALLOCATIONS: Cell = const { Cell::new(0) }; -} - -fn note_allocation() { - TRACKING.with(|tracking| { - if tracking.get() { - ALLOCATIONS.with(|allocations| allocations.set(allocations.get() + 1)); - } - }); -} - -unsafe impl GlobalAlloc for CountingAllocator { - unsafe fn alloc(&self, layout: Layout) -> *mut u8 { - let ptr = unsafe { System.alloc(layout) }; - note_allocation(); - ptr - } - - unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { - let ptr = unsafe { System.alloc_zeroed(layout) }; - note_allocation(); - ptr - } - - unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { System.dealloc(ptr, layout) } - } - - unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { - let ptr = unsafe { System.realloc(ptr, layout, new_size) }; - note_allocation(); - ptr - } -} - -#[global_allocator] -static ALLOCATOR: CountingAllocator = CountingAllocator; - -fn allocations_during(run: impl FnOnce()) -> usize { - ALLOCATIONS.with(|allocations| allocations.set(0)); - TRACKING.with(|tracking| tracking.set(true)); - run(); - TRACKING.with(|tracking| tracking.set(false)); - ALLOCATIONS.with(Cell::get) -} - -struct TestRsc { - ui: Ui, -} - -impl UiRsc for TestRsc { - fn ui(&self) -> &Ui { - &self.ui - } - - fn ui_mut(&mut self) -> &mut Ui { - &mut self.ui - } -} - -struct FrameRequester; - -impl Widget for FrameRequester { - fn draw(&mut self, painter: &mut Painter) { - let paint = painter.paint(&PaintId::WHITE); - painter.primitive(RectPrimitive::color(paint)); - painter.request_next_frame(); - painter.set_size(Size::REST); - } -} - -#[test] -fn a_warmed_self_redraw_allocates_nothing() { - let mut rsc = TestRsc { ui: Ui::default() }; - let root = rsc.ui.widgets.add_strong(FrameRequester).any(); - let mut render = UiRenderState::new(); - render.resize((800.0, 600.0)); - let start = Instant::now(); - - for frame in 0..3 { - assert!(render.update_at(&root, &mut rsc, start + Duration::from_millis(frame * 16),)); - } - - let allocations = allocations_during(|| { - assert!(render.update_at(&root, &mut rsc, start + Duration::from_millis(48),)); - }); - assert_eq!(allocations, 0); -} - -#[test] -fn refreshing_retained_accessibility_allocates_nothing() { - let mut rsc = TestRsc { ui: Ui::default() }; - let leaf = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); - rsc.ui.widgets.set_label(&leaf, "named".to_owned()); - let offset = rsc.ui.widgets.add_strong(Offset { - inner: leaf.any(), - 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(); - assert!(access.refresh(rsc.widgets(), &render, &rsc)); - - rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(20.0, 0.0)); - render.update(&root, &mut rsc); - - let moved = allocations_during(|| assert!(access.refresh(rsc.widgets(), &render, &rsc))); - assert_eq!(moved, 0); - - let unchanged = allocations_during(|| assert!(!access.refresh(rsc.widgets(), &render, &rsc))); - assert_eq!(unchanged, 0); -} diff --git a/iris/tests/color_space.rs b/iris/tests/color_space.rs deleted file mode 100644 index 5869ee5..0000000 --- a/iris/tests/color_space.rs +++ /dev/null @@ -1,176 +0,0 @@ -#![recursion_limit = "256"] - -use iris::{ - harness::{Harness, HarnessState}, - prelude::*, -}; -use pollster::FutureExt; -use std::sync::OnceLock; -use wgpu::TextureFormat; - -const SIZE: Vec2 = Vec2::new(2.0, 1.0); -const SOLID: Srgba8 = Srgba8::rgb(17, 127, 231); -const CHANGED_SOLID: Srgba8 = Srgba8::rgb(243, 139, 168); -const IMAGE: Srgba8 = Srgba8::rgb(205, 214, 244); - -/// Covers the whole colour path rather than a conversion helper: an sRGB -/// literal enters the linear paint buffer, an sRGB image is sampled as -/// linear, the Iris shader returns both, and the sRGB attachment encodes the -/// stored bytes. Replacing only the paint-table entry also proves that a -/// theme change reaches an already-retained primitive. -#[test] -fn solid_paints_and_images_round_trip_through_an_srgb_target() { - let gpu = Gpu::open(); - let mut harness = Harness::new(SIZE, 1.0); - let solid = harness.rsc.ui.paints.add(SOLID); - let bitmap = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( - 1, - 1, - image::Rgba([IMAGE.r, IMAGE.g, IMAGE.b, IMAGE.a]), - )); - let bitmap = image::>(bitmap)(&mut harness.rsc); - let root = (rect(solid.clone()).sized((1, 1)), bitmap) - .span(Dir::RIGHT) - .add_strong(&mut harness.rsc) - .any(); - harness.state.set_root(&mut harness.rsc, root); - harness.frame(0); - - let mut renderer = - UiRenderNode::new(&gpu.device, &gpu.queue, TextureFormat::Rgba8UnormSrgb, SIZE) - .expect("the Iris pipeline should accept an sRGB render target"); - let first = render(&gpu, &mut renderer, &mut harness); - assert_pixel(first[0], SOLID, "linear paint buffer -> sRGB attachment"); - assert_pixel(first[1], IMAGE, "sRGB texture -> shader -> sRGB attachment"); - - harness.rsc.ui.paints.set(&solid, CHANGED_SOLID); - let changed = render(&gpu, &mut renderer, &mut harness); - assert_pixel( - changed[0], - CHANGED_SOLID, - "updated paint table -> retained primitive", - ); - assert_pixel(changed[1], IMAGE, "unchanged image after paint update"); -} - -fn render(gpu: &Gpu, renderer: &mut UiRenderNode, harness: &mut Harness) -> [[u8; 4]; 2] { - renderer.update(&gpu.device, &gpu.queue, &mut harness.rsc.ui); - let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { - label: Some("Iris colour-space target"), - size: wgpu::Extent3d { - width: 2, - height: 1, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8UnormSrgb, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, - view_formats: &[], - }); - let view = texture.create_view(&Default::default()); - let readback = gpu.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Iris colour-space readback"), - size: 256, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut encoder = gpu.device.create_command_encoder(&Default::default()); - { - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Iris colour-space pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &view, - depth_slice: None, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(LinearRgba::BLACK.to_wgpu()), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - renderer.draw(&mut pass); - } - encoder.copy_texture_to_buffer( - texture.as_image_copy(), - wgpu::TexelCopyBufferInfo { - buffer: &readback, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(256), - rows_per_image: Some(1), - }, - }, - wgpu::Extent3d { - width: 2, - height: 1, - depth_or_array_layers: 1, - }, - ); - gpu.queue.submit([encoder.finish()]); - - let slice = readback.slice(..); - slice.map_async(wgpu::MapMode::Read, |result| { - result.expect("mapping the colour-space readback") - }); - gpu.device - .poll(wgpu::PollType::wait_indefinitely()) - .expect("waiting for the colour-space readback"); - let mapped = slice - .get_mapped_range() - .expect("reading the mapped colour-space buffer"); - let pixels = [ - mapped[0..4].try_into().unwrap(), - mapped[4..8].try_into().unwrap(), - ]; - drop(mapped); - readback.unmap(); - pixels -} - -fn assert_pixel(got: [u8; 4], want: Srgba8, path: &str) { - let want = [want.r, want.g, want.b, want.a]; - assert!( - got.into_iter() - .zip(want) - .all(|(got, want)| got.abs_diff(want) <= 1), - "{path}: stored {got:?}, expected {want:?} (within one code value)", - ); -} - -fn vulkan_instance() -> &'static wgpu::Instance { - static INSTANCE: OnceLock = OnceLock::new(); - INSTANCE.get_or_init(wgpu::Instance::default) -} - -struct Gpu { - device: wgpu::Device, - queue: wgpu::Queue, -} - -impl Gpu { - fn open() -> Self { - let adapter = vulkan_instance() - .request_adapter(&wgpu::RequestAdapterOptions::default()) - .block_on() - .expect("no wgpu adapter, so Iris's colour-space pipeline went unchecked"); - let info = adapter.get_info(); - eprintln!( - "color_space: {} ({:?}, {})", - info.name, info.backend, info.driver - ); - let (device, queue) = adapter - .request_device(&wgpu::DeviceDescriptor { - required_limits: iris_core::device_limits(), - ..Default::default() - }) - .block_on() - .expect("could not get a device from the adapter"); - Self { device, queue } - } -} diff --git a/iris/tests/mask_sdf.rs b/iris/tests/mask_sdf.rs deleted file mode 100644 index 96002a5..0000000 --- a/iris/tests/mask_sdf.rs +++ /dev/null @@ -1,298 +0,0 @@ -#![recursion_limit = "256"] - -use std::sync::OnceLock; - -use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2}; -use pollster::FutureExt; -use wgpu::util::DeviceExt; - -const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25); -const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0); - -const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0]; - -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); - -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.", - ); - - 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", - ); -} - -fn probe_at(x: u32, y: u32) -> Vec2 { - Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP) -} - -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()), - }); - - 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(), - }], - }); - - 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 -} - -fn vulkan_instance() -> &'static wgpu::Instance { - static INSTANCE: OnceLock = OnceLock::new(); - INSTANCE.get_or_init(wgpu::Instance::default) -} - -struct Gpu { - device: wgpu::Device, - queue: wgpu::Queue, -} - -impl Gpu { - 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 { - required_limits: iris_core::device_limits(), - ..Default::default() - }) - .block_on() - .expect("could not get a device from the adapter"); - Self { device, queue } - } -} - -#[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], -} - -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"), - ) -} - -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"); -}