From 1e6d3b1eddbbb680a20753cbd3d17990b16b7756 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Thu, 10 Sep 2026 00:44:13 -0400 Subject: [PATCH] Prune commentary and stale Rust port notes --- Cargo.toml | 90 +-- benches/fling_spline_reference.py | 5 - benches/message_list.rs | 129 ---- benches/report_to_touch.py | 2 - benches/velocity_reference.py | 10 - core/Cargo.toml | 6 +- core/build-icon-font.sh | 2 - core/src/event/manager.rs | 1 - core/src/event/mod.rs | 13 - core/src/icon.rs | 34 - core/src/orientation/align.rs | 10 +- core/src/orientation/len.rs | 18 - core/src/primitive/color.rs | 1 - core/src/primitive/layer.rs | 9 - core/src/primitive/text.rs | 653 +++++++---------- core/src/primitive/texture.rs | 46 -- core/src/render/atlas.rs | 78 --- core/src/render/data.rs | 54 -- core/src/render/frame_report.rs | 247 ------- core/src/render/mod.rs | 137 ---- core/src/render/primitive.rs | 137 ---- core/src/render/sdf.rs | 27 - core/src/render/shader.wgsl | 86 +-- core/src/render/texture.rs | 62 -- core/src/render/util/mod.rs | 26 - core/src/ui/access.rs | 27 - core/src/ui/active.rs | 11 - core/src/ui/mod.rs | 21 - core/src/ui/painter.rs | 84 --- core/src/ui/render_state.rs | 253 +------ core/src/util/arena.rs | 8 - core/src/util/dirty.rs | 28 - core/src/util/id.rs | 2 - core/src/util/math.rs | 3 - core/src/util/typemap.rs | 1 - core/src/util/vec2.rs | 1 - core/src/widget/data.rs | 1 - core/src/widget/handle.rs | 7 - core/src/widget/like.rs | 1 - core/src/widget/mod.rs | 7 - core/src/widget/widgets.rs | 12 - examples/bench_images.rs | 24 - examples/message_list.rs | 36 - examples/tabs/main.rs | 4 - macro/src/lib.rs | 7 - rig-input/Cargo.toml | 22 +- rig-input/src/main.rs | 23 - run-headless.sh | 4 - rust-toolchain.toml | 9 +- src/access_tests.rs | 21 - src/android/access.rs | 18 - src/android/ime.rs | 51 -- src/android/insets.rs | 49 +- src/android/mod.rs | 14 - src/android/platform.rs | 6 - src/android/render.rs | 110 --- src/android/view.rs | 251 ------- src/attr.rs | 53 -- src/default/access.rs | 10 - src/default/app.rs | 3 - src/default/attr.rs | 2 - src/default/logging.rs | 27 - src/default/mod.rs | 42 -- src/default/platform.rs | 11 - src/default/render.rs | 25 - src/diagnostics.rs | 34 - src/event.rs | 13 - src/harness.rs | 77 -- src/layout_tests.rs | 268 ------- src/lib.rs | 7 - src/platform.rs | 19 - src/sense.rs | 1043 ++-------------------------- src/sense_tests.rs | 149 ---- src/widget/position/lazy_span.rs | 631 +---------------- src/widget/position/pad.rs | 5 - src/widget/position/scroll_area.rs | 72 -- src/widget/position/scrollable.rs | 189 ----- src/widget/text/build.rs | 4 - src/widget/text/edit.rs | 102 --- src/widget/text/mod.rs | 41 -- src/widget/trait_fns.rs | 25 - tabs-ui/Cargo.toml | 6 +- tabs-ui/src/lib.rs | 16 - tests/mask_sdf.rs | 109 --- 84 files changed, 334 insertions(+), 5648 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 385e188..763ac68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,6 @@ name = "iris" version.workspace = true edition.workspace = true -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] iris-core = { workspace = true } iris-macro = { workspace = true } @@ -15,83 +13,32 @@ wgpu = { workspace = true } image = { workspace = true } accesskit = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } -# For diagnostics visible through android_logger (or whatever logger the -# app crate installs) -- this crate never installs one itself. Not in the -# android-only block below any more: the lines that matter most are in -# shared widget code, which the host backend compiles too. +# The embedding app installs the logger. log = "0.4.34" -# winit everywhere except Android; android-view (below) is what stands in -# for it there. Both backends live in this crate (see `src/android/mod.rs`'s -# doc comment) but are never compiled together: winit's own Android support -# pulls in `android-activity`, which panics at compile time unless one of -# its own backend features is picked, and picking one is exactly what -# `iris-core` was kept free of (RUST.md's I0b). Confirmed by trying it -# 2026-09-05: `cargo ndk -t x86_64 -P 26 build -p iris` failed inside -# `android-activity` itself with "Either game-activity or native-activity -# must be enabled" before this split existed. +# 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"] } -# I4 (RUST.md): the desktop half of the AccessKit push, `winit`'s own -# adapter over `accesskit`. No pin needed the way android-view's rev is -# pinned -- this is an ordinary crates.io release with no local abort to -# track (that finding is Android-only, see below). accesskit_winit = "0.34.0" -# Pinned to the exact commit RUST.md's E1 (2026-09-04) measured on this -# emulator -- real Vulkan rendering, a working `InputConnection`, and the -# accesskit-detach abort, all against this rev specifically. Advancing it -# wants re-running E1's checks, the same reason the nightly toolchain pin -# is dated rather than floating. +# 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" } -# I4 (RUST.md): the Android half of the AccessKit push, over android-view's -# `AccessibilityNodeProvider`. **0.8.0 carries the same detach-abort E1 -# found on 0.4.0** (the `State` enum still never returns to `Inactive`, -# and `send_completed_event` still unwraps a Java exception) -- advancing -# the version is not the fix, so pinning to a specific rev buys nothing -# here the way it does for android-view itself. `android/view.rs`'s -# `raise_if_enabled` is the mitigation, carried from E1. +# 0.8.0 still aborts on detach; `view.rs::raise_if_enabled` mitigates it. accesskit_android = "0.8.0" -# Not re-exported by android-view (only `jni` and `ndk` are), and needed -# for `android/insets.rs`'s own id -> state map -- the same reason -# android-view's own `PEER_MAP` carries one. send_wrapper = "0.6.0" [features] -# RUST.md's I5 "Where iris's frame time goes" diagnosis: pins the -# `wgpu::Instance` to `Backends::GL` instead of `Backends::PRIMARY`, so one -# build can be measured on either backend. A compile-time feature rather -# than an env var because nothing on this machine can hand an env var to an -# already-launched Android process (there is no `am start` environment and -# no system-property reader here to add one). -# -# **Not needed to get GLES in the emulator**, whatever the history here -# says: the emulator's guest has no hardware Vulkan at all, so an ordinary -# build's runtime fallback lands on GLES by itself (docs/RUST.md, "What the -# emulator gives a GPU app"). Keeping the emulator on the same binary the -# phone runs is the point. What this feature is still for is forcing GLES -# on a machine that *does* have Vulkan -- the desktop -- which is why -# `default/render.rs` reads it too: -# ./run-headless.sh transcript --shot /tmp/x.png -- -p transcript-ui \ -# --features iris/force-gles +# 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] tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread", "time"] } -# The tabs example's widget tree. A dev-dependency cycle back to this -# package is fine -- cargo excludes dev-dependencies from the graph used -# to build the library itself, so this only matters for `--examples`. tabs-ui = { path = "tabs-ui" } -# `tests/mask_sdf.rs` only: the grid it hands the GPU and the coverages it -# reads back. wgpu and pollster are ordinary dependencies already. bytemuck = { workspace = true } -# Plain Instant-timed binaries, not criterion -- see benches/message_list.rs's -# header for why. `harness = false` opts out of the unstable `#[bench]` -# test-crate harness cargo would otherwise want, in favour of an ordinary -# `fn main()`. [[bench]] name = "message_list" harness = false @@ -108,32 +55,11 @@ members = [ version = "0.1.0" edition = "2024" -# Debug info is the reason a `cargo test --workspace` here was taking half -# an hour, and it is worth the paragraph. Measured 2026-09-08: with rustc's -# default `debug = true`, linking this workspace's test binaries wrote -# **~54 GB** (one single test binary's linker wrote 16.9 GB) and left an -# **88 GB** `target/`. Eight test binaries each statically link the whole -# wgpu + naga + winit + parley graph, and at the default every one of them -# gets a full copy of that graph's DWARF written into it. On a btrfs at 83% -# full the linkers then sat in `handle_reserve_ticket` -- uninterruptible, -# waiting on space reservation -- at about 20 MB/s between them, which is -# what "the tests are slow" actually was. Not CPU: the machine was 87% idle -# throughout. -# -# `line-tables-only` keeps what is actually read from a backtrace -- the -# file and line of every frame, which is what a panicking test prints and -# what gdb needs to name the frames of a segfault. What it gives up is -# inspecting variables in a debugger; when that is wanted, ask for it on -# the command line for that one run rather than paying for it on every -# build: -# -# RUSTFLAGS="-C debuginfo=2" cargo test -p iris --test whatever +# 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" -# The tests are what this is really for; `cargo test` uses `dev` for -# dependencies and `test` for the test targets themselves, so setting only -# `dev` leaves the eight big binaries at the default. [profile.test] debug = "line-tables-only" diff --git a/benches/fling_spline_reference.py b/benches/fling_spline_reference.py index 3a9af55..e9ad37b 100644 --- a/benches/fling_spline_reference.py +++ b/benches/fling_spline_reference.py @@ -31,9 +31,6 @@ END_TENSION = 1.0 P1 = START_TENSION * INFLEXION P2 = 1.0 - END_TENSION * (1.0 - INFLEXION) -# ViewConfiguration.getScrollFriction(), and SplineOverScroller's own -# "look and feel tuning" constant -- a different number in a different place -# of the same formula, which is the pair iris got the wrong way round once. SCROLL_FRICTION = 0.015 TUNING = 0.84 GRAVITY_EARTH = 9.80665 @@ -54,7 +51,6 @@ def spline_positions(): while True: x = x_min + (x_max - x_min) / 2.0 coef = 3.0 * x * (1.0 - x) - # Solved on the P1/P2 curve... tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x if abs(tx - alpha) < 1e-5: break @@ -62,7 +58,6 @@ def spline_positions(): x_max = x else: x_min = x - # ...and sampled on the tension curve. position[i] = coef * ((1.0 - x) * START_TENSION + x * END_TENSION) + x * x * x position[NB_SAMPLES] = 1.0 return position diff --git a/benches/message_list.rs b/benches/message_list.rs index 1bf0b50..d360f6b 100644 --- a/benches/message_list.rs +++ b/benches/message_list.rs @@ -1,75 +1,6 @@ -//! On-demand benchmarks for iris's message-list scenario -- IRIS_TODO.md's -//! "Benchmarks" item, and RUST.md's I3. Never run by `cargo test`; run -//! explicitly with `cargo bench --bench message_list --release` or -//! `./run-bench.sh`. -//! -//! **Why a plain `Instant`-timed binary, not criterion.** Every scenario -//! here is really "how many `Widget::draw` calls and primitive rewrites did -//! this frame cost," which `UiRenderState::take_counters` already answers -//! exactly (see `iris/src/layout_tests.rs`, which this file's harness -//! mirrors). A short loop that times itself and prints the counters -//! alongside the wall time says everything criterion's warm-up/sampling/ -//! outlier-removal machinery would add on top, for scenarios that are -//! fundamentally about a *count*, not a noisy microbenchmark distribution -//! -- and it avoids a new dependency this crate does not otherwise need. -//! Per the code rules, the plain option is also the one shorter to explain. -//! -//! **The list under test is `iris::widget::LazySpan` (RUST.md's I3), not a -//! `ScrollArea` over a `Span` of pre-built rows.** Earlier versions of this -//! file built their own giant `Span` and wrapped it in `ScrollArea`, which -//! meant (a)/(b)/(c) below were measuring "move one big child," never the -//! virtualised widget the app's transcript screen actually needs. `LazySpan` -//! still needs every row's *widget* built up front by the caller (its -//! module doc explains why: it only ever sees `&dyn Widget` through -//! `Painter`, so it cannot construct a row lazily on its own) -- what -//! virtualisation buys is that only the rows currently on screen are ever -//! *drawn*, which is what the draw/rewrite/move counters below are -//! measuring, not construction time. -//! -//! Scenarios (LAYOUT.md's O(1) move chain, lazy_span.rs's module doc, and -//! IRIS_TODO.md's "Benchmarks" wording): -//! -//! - (a) first-frame cost of a message list of N wrapped-text rows, some -//! with an image, for N = 100 / 1,000 / 10,000. With a virtualised list -//! this is expected to stop scaling with N once N exceeds a screenful -- -//! the draw/rewrite counters below are the number that used to grow 10x -//! per 10x N and should not any more. -//! - (b) per-frame cost of scrolling that list -- must be O(1) moves, not -//! re-layout. -//! - (c) the input-box case: growing a fixed-height field at the bottom of -//! the screen must move the message list above it, not re-lay its rows. -//! Reports frame time *and* the draw/rewrite/move counters LAYOUT.md -//! section 8 defines. -//! - (d) insert-above-anchor: paging older history onto the front of an -//! already-scrolled list. `LazySpan::push_front` is an O(1) index update -//! (lazy_span.rs's module doc); this measures that none of the rows already -//! on screen are touched by it. -//! - (e) expand-a-row-holding-its-edge: growing one row's height with a -//! tap recorded near one of its edges (lazy_span.rs's `note_tap`) must move -//! only the rows on the far side of it, never redraw the ones already -//! correctly placed. -//! -//! - (g) redraw-one-big-text: a single text widget of N glyphs redrawn in -//! place, which is what a tool card rebuilt on a tap costs. Every one of -//! its primitives is freed and rewritten, and so renumbered in the -//! layer's draw order -- the pass that used to be O(N^2) there -//! (`UiRenderState::apply_free`, fixed 2026-09-08). The number to watch -//! is per-glyph: it must stay flat as N grows, not grow with it. -//! -//! (f), many images with zero steady-state bind-group creation, needs a -//! real `wgpu` device and lives in `iris/examples/bench_images.rs` instead, -//! driven through `run-headless.sh` -- see that file's header. -//! -//! `UiRenderState`/`Widgets` touch no GPU or window (as `layout_tests.rs` -//! notes), so everything here runs as an ordinary `--release` binary with -//! no compositor. Numbers are recorded in RUST.md's I3 box, not here -- -//! this file is the rig, not the result. - use iris::prelude::*; use std::time::Instant; -/// The minimal `UiRsc` a benchmark needs -- identical in shape to -/// `layout_tests.rs`'s `TestRsc`. struct BenchRsc { ui: UiData, } @@ -83,18 +14,12 @@ impl UiRsc for BenchRsc { } } -/// Long enough to force real wrapping at a phone-plausible column width, and -/// varied enough (no two rows byte-identical) that nothing can special-case -/// on repeated content. const BODY: &str = "The quick brown fox jumps over the lazy dog. Iris lays \ out wrapped text by shaping once per width and caching the result, so a \ row that is offered the same width twice does not reshape. This sentence \ exists only to give a row enough text to wrap across several lines at a \ typical phone column width."; -/// One message row: a wrapped `Text`, and every `image_every`th row also an -/// `Image` beneath it -- a small in-memory RGBA square rather than a file, -/// so N=10,000 rows costs no disk I/O. fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { let mut text = Text::new(format!("Message {i}: {BODY}")); text.wrap = true; @@ -113,9 +38,6 @@ fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { } } -/// A virtualised `LazySpan` of `n` message rows, one in `image_every` of them -/// carrying an image (0 disables images entirely). Returns the list widget -/// (weak, so the caller can drive it) and the erased root to render. fn build_message_list( rsc: &mut BenchRsc, n: usize, @@ -127,9 +49,6 @@ fn build_message_list( list.push_back(LazyItem::new(i as u64, row)); } let list = rsc.ui.widgets.add_strong(list); - // Driven through the span's own `ScrollController`, like every other - // scroll area in iris: what this measures has to be the path the app - // actually takes. (list.weak(), list.any()) } @@ -140,7 +59,6 @@ fn report(label: &str, elapsed: std::time::Duration, draws: u64, rewrites: u64, ); } -/// (a) First-frame cost of a message list of N rows. fn bench_first_frame(n: usize) { let mut rsc = BenchRsc { ui: UiData::default(), @@ -162,10 +80,6 @@ fn bench_first_frame(n: usize) { ); } -/// (b) Per-frame cost of scrolling an already-laid-out list of N rows. -/// Warms up (one no-op tick, matching `ScrollArea`'s own need for it before an -/// ordinary Rust `layout_tests.rs` scrolling test becomes a same-size move -/// rather than a resize), then times a run of individual scroll ticks. fn bench_scroll(n: usize, ticks: usize) { let mut rsc = BenchRsc { ui: UiData::default(), @@ -205,14 +119,6 @@ fn bench_scroll(n: usize, ticks: usize) { ); } -/// (c) The input-box case: a fixed-height field at the bottom of the screen -/// growing by a line at a time, with a message list of N rows filling the -/// rest of the screen above it. Growing the input shrinks the *offered* -/// height of the list container (a single widget, from the outer `Span`'s -/// point of view) without changing the width it offers its content -- so -/// the rows underneath, which only care about width, must not redraw; the -/// list's own re-registration of where its content sits is the one O(1) -/// move this is checking for. fn bench_input_grows(n: usize, lines: usize) { let mut rsc = BenchRsc { ui: UiData::default(), @@ -276,13 +182,6 @@ fn bench_input_grows(n: usize, lines: usize) { ); } -/// (d) Insert-above-anchor: the list is scrolled to its very first loaded -/// row (`jump_to_start`, an O(1) re-anchor) rather than left at the default -/// bottom, so a row prepended above it is genuinely "inserted above the -/// anchor" rather than merely far off-screen at the far end. Each -/// `push_front` is O(1) (lazy_span.rs's module doc: the anchor's slot is an -/// index, bumped by one) and, since the prepended rows never enter the -/// viewport, none of them should cost a draw either. fn bench_insert_above_anchor(n: usize, inserts: usize) { let mut rsc = BenchRsc { ui: UiData::default(), @@ -300,9 +199,6 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) { let mut total_rewrites = 0u64; let mut total_moves = 0u64; for i in 0..inserts { - // Older-history rows: distinct keys below every existing one, so a - // real caller's paging code (prepending an older page) is exactly - // what this loop does. let row = build_row(&mut rsc, usize::MAX - i, 20); rsc.ui .widgets @@ -333,21 +229,11 @@ fn bench_insert_above_anchor(n: usize, inserts: usize) { ); } -/// (e) Expand-a-row-holding-its-edge: one row (fixed-height, so its size is -/// directly controllable) is grown a little at a time, each time preceded -/// by `note_tap` aimed at its own top edge -- the exact mechanism lazy_span.rs's -/// module doc describes and its unit tests check for correctness. This -/// measures its *cost*: only the rows on the far side of the grown one -/// (below it, since the top edge is held) should ever move, and nothing -/// should be redrawn purely because the list overall got taller. fn bench_expand_holds_edge(n: usize, growths: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); - // Near the end (not the very last row) so it is already on screen - // under the list's default bottom-anchored placement, for every N -- - // no scrolling needed to bring it into view before measuring. let growable_index = n.saturating_sub(3); let mut growable = None; for i in 0..n { @@ -415,23 +301,10 @@ fn bench_expand_holds_edge(n: usize, growths: usize) { ); } -/// (g) One text widget of `chars` characters, redrawn in place `redraws` -/// times -- an open tool card whose content is rebuilt, or any widget -/// holding a lot of text that a tap changes. -/// -/// A redraw frees every primitive the widget owned and writes fresh ones, -/// so every glyph is renumbered in its layer's draw order. Finding the -/// handle to renumber used to be a scan of everything the same widget -/// drew, which made one redraw quadratic in its own glyph count: 1.37s for -/// 51,200 glyphs on this machine, against 20ms to shape and rasterise the -/// same text. Print per-glyph rather than per-redraw, since flat is the -/// pass condition and a total says nothing without dividing it. fn bench_redraw_big_text(chars: usize, redraws: usize) { let mut rsc = BenchRsc { ui: UiData::default(), }; - // One character per glyph, and varied so nothing can collapse the - // string into a repeat. let content: String = (0..chars) .map(|i| char::from(b'a' + (i % 26) as u8)) .collect(); @@ -448,8 +321,6 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) { let mut total = std::time::Duration::ZERO; for _ in 0..redraws { - // Asking for the widget mutably is what marks it for redraw -- - // the same path a caller changing its content takes. rsc.ui.widgets.get_mut(&handle).unwrap(); let start = Instant::now(); render.update(&root, &mut rsc); diff --git a/benches/report_to_touch.py b/benches/report_to_touch.py index 252b7e4..cce32f2 100755 --- a/benches/report_to_touch.py +++ b/benches/report_to_touch.py @@ -38,8 +38,6 @@ LINE_RE = re.compile( r"iris input: action=(?P\w+) x=(?P-?[0-9.]+) y=(?P-?[0-9.]+) " r"t=(?P[0-9]+)ms history=(?P[0-9]+)(?P.*)$" ) -# One historical sample inside `rest`: `t:x,y`, space-separated, oldest first -# -- see `log_input_event`'s own doc for why order matters. HIST_RE = re.compile(r"(?P[0-9]+):(?P-?[0-9.]+),(?P-?[0-9.]+)") diff --git a/benches/velocity_reference.py b/benches/velocity_reference.py index c60bdc2..55a377b 100644 --- a/benches/velocity_reference.py +++ b/benches/velocity_reference.py @@ -85,7 +85,6 @@ HORIZON_MILLISECONDS = 100.0 ASSUME_POINTER_MOVE_STOPPED_MILLISECONDS = 40.0 MIN_SAMPLE_SIZE_LSQ2 = 3 -# ViewConfiguration.getScaledMaximumFlingVelocity(), in dp/s. MAXIMUM_FLING_VELOCITY_DP_S = 8000.0 # DefaultFlingBehavior.performFling's own threshold, in the units of the # positions fed to the tracker -- pixels per second here. @@ -103,7 +102,6 @@ def poly_fit_least_squares(x, y, sample_count, degree): m = sample_count n = truncated_degree + 1 - # a[i][h] = x[h]**i, pre-multiplied by the (always 1.0) weight. a = [[0.0] * m for _ in range(n)] for h in range(m): a[0][h] = 1.0 @@ -219,7 +217,6 @@ def average(samples): return (samples[-1][1] - samples[0][1]) / span -# --- The three recorded sample sets the Rust tests assert on. ---------------- # 1. `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds it: # the DOWN position, then one position per MOVE. The UP at t=20 adds no @@ -243,19 +240,12 @@ ACCELERATING_FLICK = [(0, 0.0), (10, 2.0), (20, 6.0), (30, 14.0), (40, 30.0), (5 # (a) An old, fast burst outside the 100ms horizon, then a slow steady # drag: the burst must not leak into the estimate. OLD_BURST_THEN_STEADY = [(0, 0.0)] + [(10 + i * 10, 1000.0 + i) for i in range(11)] -# (b) The finger stops for 48ms and then lifts. The gap exceeds -# AssumePointerMoveStopped, so the walk breaks after one sample and -# there is no fling -- what stops a "park it and let go" from -# flinging at whatever speed the finger arrived with. STOPPED_BEFORE_RELEASE = [(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)] -# 5. `sense.rs`'s own `drag_gesture_tests`: what `DragGesture` feeds for a -# press and two move frames, which is the fewest a fit can use. TWO_MOVE_FRAMES = [(0, 0.0), (8, 100.0), (16, 220.0)] # ... and one move frame, which Compose cannot fit either. ONE_MOVE_FRAME = [(0, 0.0), (8, 100.0)] -# The phone: 1080x2424 at content_scale 2.55. PHONE_DENSITY = 2.55 diff --git a/core/Cargo.toml b/core/Cargo.toml index cfe77c1..3b643f0 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -5,11 +5,7 @@ edition.workspace = true [dependencies] wgpu = { workspace = true } -# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair -# (renderer-creation error reporting, RUST.md's P0 phone-crash box) -- -# `block_on` turns that one async pop into the same synchronous call shape -# `device_limits()`'s two callers already use for `request_adapter`/ -# `request_device`, rather than making this crate's one entry point async. +# Keeps renderer creation synchronous while retrieving wgpu's async error scope. pollster = { workspace = true } bytemuck ={ workspace = true } image = { workspace = true } diff --git a/core/build-icon-font.sh b/core/build-icon-font.sh index 8b0c36f..0ad510c 100755 --- a/core/build-icon-font.sh +++ b/core/build-icon-font.sh @@ -25,8 +25,6 @@ # point of subsetting is to ship only the codepoints one app draws. set -euo pipefail -# Codepoint, then the Nerd Fonts glyph name it came from. Material Design -# Icons, as in the Compose app. GLYPHS=( U+F035D # md-menu_down -- a card that is open U+F035F # md-menu_right -- a card that opens diff --git a/core/src/event/manager.rs b/core/src/event/manager.rs index 94a5776..cda0ff0 100644 --- a/core/src/event/manager.rs +++ b/core/src/event/manager.rs @@ -79,7 +79,6 @@ type EventData = (E, Rc EventFn::Data<'a>> pub struct TypeEventManager { // TODO: reduce visiblity!! pub active: HashMap>, - /// This event's own input-wide state -- see [`Event::Global`]. pub global: E::Global, map: HashMap>>, } diff --git a/core/src/event/mod.rs b/core/src/event/mod.rs index 5619fb4..b856850 100644 --- a/core/src/event/mod.rs +++ b/core/src/event/mod.rs @@ -9,19 +9,6 @@ pub use rsc::*; pub trait Event: Sized + 'static + Clone { type Data<'a>: Clone = (); type State: Default = (); - /// State this event owns that belongs to no single widget -- what the - /// thing dispatching the event knows about the *input*, rather than - /// about a listener. `()` for almost every event; the cursor's is - /// `iris::sense::PointerInput` (which widget holds pointer capture, - /// and who is tracking the press in flight). - /// - /// It lives here so that such state has one owner, reached by `&mut` - /// through the event manager, instead of being parked on whatever - /// structure a handler happens to be able to reach and guarded with a - /// lock. Iris asked for that on 2026-09-08, of the pointer capture - /// that used to sit in a `Mutex` on `UiRenderState`: "everything - /// global should be stored in the general input handler, not in - /// specific senses with locking stuff." type Global: Default = (); #[allow(unused_variables)] fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { diff --git a/core/src/icon.rs b/core/src/icon.rs index e9f5f29..728f641 100644 --- a/core/src/icon.rs +++ b/core/src/icon.rs @@ -1,39 +1,5 @@ -//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships. -//! -//! **Why a bundled font rather than ordinary Unicode**: the disclosure -//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform -//! resolved, and once iris stopped bundling fonts (decided -//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a -//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is -//! also what the Compose app has always done for its icons -//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design -//! family, so an icon means the same thing in both apps. -//! -//! **Why not vector assets or drawn shapes**: an icon beside a line of -//! text wants that line's size, colour and baseline, and text gets all -//! three for free. This replaced `iris::widget::mark`, which drew the -//! triangle into a texture: correct, but one shape, and every further icon -//! would have been another bespoke rasteriser. -//! -//! Each constant here has to have a matching codepoint in -//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the -//! script did not subset is a glyph that silently isn't there. The subset -//! is the font's **Mono** face, where every glyph is one em wide and one -//! em tall, so two icons at one font size are one size without either -//! being given one -- and why an icon looks smaller than text at the same -//! size, since the glyph is drawn inside that em rather than filling it. -//! -//! Draw one with [`crate::Family::Icons`]: -//! -//! ```ignore -//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons) -//! ``` - -/// `md-menu_down` -- a filled triangle pointing down: this card is open. pub const OPEN: &str = "\u{F035D}"; -/// `md-menu_right` -- pointing right: this card opens. pub const CLOSED: &str = "\u{F035F}"; -/// `md-menu_up` -- pointing up: fold this group of cards away again. pub const COLLAPSE: &str = "\u{F0360}"; diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs index 876208a..03c1677 100644 --- a/core/src/orientation/align.rs +++ b/core/src/orientation/align.rs @@ -88,6 +88,10 @@ impl RegionAlign { pub const fn rel(&self) -> Vec2 { vec2(self.x.rel(), self.y.rel()) } + + pub const fn pos(self) -> UiVec2 { + UiVec2::from(self) + } } impl UiVec2 { @@ -192,9 +196,3 @@ const impl From for UiVec2 { Self::rel(align.rel()) } } - -impl RegionAlign { - pub const fn pos(self) -> UiVec2 { - UiVec2::from(self) - } -} diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 599e37b..d426edd 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -15,24 +15,6 @@ pub struct Len { /// the two are kept separate rather than one field a caller has to /// remember to pre-multiply. pub abs: f32, - /// Density-independent pixels -- Android's `dp` / CSS's reference pixel - /// (1 unit = 1/160in), resolved against the display's density at - /// layout time (`apply_rest`'s `density` parameter) rather than at the - /// point a widget is built, since density is a property of the device - /// this ends up running on, not of the widget tree. This is the unit - /// IRIS_TODO.md's "a density-independent length unit" item asked for, - /// 2026-09-06: before it existed, every size in the tree was `abs` - /// (physical pixels), and the only way to make a 16px design draw at - /// the right *size* on a denser display was a single global multiply - /// applied to the whole rendered scene after layout -- which is also - /// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn - /// at logical size and stretched by the scale"): a glyph rasterised at - /// 16 physical px and then stretched 3x by that global multiply is a - /// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at - /// layout time instead means the font size handed to the text shaper - /// is already the physical size (`16.0.dp() * 3.0`), so the glyph - /// atlas rasterises at the display's real resolution and nothing - /// downstream needs to stretch anything. pub dp: f32, pub rel: f32, pub rest: f32, diff --git a/core/src/primitive/color.rs b/core/src/primitive/color.rs index 2afb7a2..a7579c0 100644 --- a/core/src/primitive/color.rs +++ b/core/src/primitive/color.rs @@ -1,6 +1,5 @@ use std::marker::Destruct; -/// stored in linear for sane manipulation #[repr(C)] #[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)] pub struct Color { diff --git a/core/src/primitive/layer.rs b/core/src/primitive/layer.rs index 1db6ac6..b3c7dd9 100644 --- a/core/src/primitive/layer.rs +++ b/core/src/primitive/layer.rs @@ -14,19 +14,13 @@ struct LayerNode { #[derive(Clone, Copy, Debug)] enum Ptr { - /// continue on same level Next(usize), - /// go back to parent Parent(usize), - /// end None, } -/// TODO: currently this does not ever free layers -/// is that realistically desired? pub struct Layers { vec: Vec>, - /// index of last layer at top level (start at first = 0) last: usize, } @@ -36,9 +30,6 @@ struct Child { tail: usize, } -/// The draw order of every layer. The primitives themselves live in one -/// arena beside this (`UiRenderState::primitives`); a layer names the -/// slots it draws, which is what its vertex buffer is. pub type PrimitiveLayers = Layers; impl Layers { diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 74da0e1..c621c6e 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -12,40 +12,13 @@ use swash::{ zeno::{Format, Vector}, }; -/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built -/// by `iris/core/build-icon-font.sh`, holding only the codepoints -/// `crate::icon` names (992 bytes for three glyphs today). -/// -/// This is the one font bundled here, and it is not a text font: body and -/// monospace text still come from the platform's own collection -/// (decided 2026-09-07). An icon is the opposite case -- a small, -/// closed set of codepoints no system font is guaranteed to have -- which -/// is the same division the Compose app makes. const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf"); -/// What starting up found about text rendering, for the on-screen -/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log -/// once at startup ... the number of font families found, the default -/// family resolved"). Built once by `TextData::font_diagnostics` -- -/// `Default::default` still exists for callers (tests, examples) that -/// don't need the report. #[derive(Clone, Debug)] pub struct FontDiagnostics { - /// `Collection::family_names().count()` after registering the bundled - /// fonts -- system families plus the two bundled ones. pub families_found: usize, - /// The family `GenericFamily::SansSerif` resolves to first -- the - /// bundled "Noto Sans" unless registration itself failed. pub default_family: Option, - /// The family `GenericFamily::Monospace` resolves to first. pub default_mono_family: Option, - /// One resolved family name per style axis this crate actually uses - /// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a - /// bold/italic request is landing on a real face rather than being - /// silently absorbed by whatever the sans-serif default resolves to - /// for every weight (RUST.md's P0 box, "bold words render as blank - /// gaps" -- a family that resolves but has no distinct bold face is - /// exactly what produced that). pub regular_resolved: Option, pub bold_resolved: Option, pub italic_resolved: Option, @@ -57,8 +30,6 @@ pub struct FontDiagnostics { pub icon_family: Option, } -/// Everything text needs that outlives one string: the font collection, the -/// layout scratch space, the glyph rasteriser and the atlas they fill. pub struct TextData { pub font_cx: FontContext, pub layout_cx: LayoutContext, @@ -84,19 +55,6 @@ pub struct TextData { } impl Default for TextData { - /// Text comes entirely from the platform's own font collection -- - /// `FontContext::new()` builds a `fontique::Collection` with - /// `CollectionOptions::system_fonts` on by default, which is real - /// discovery on both targets this crate ships on: Android's backend - /// parses `/system/fonts` and `/system/etc/fonts.xml` and maps - /// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]` - /// and `Monospace` to the platform's `"monospace"` alias; the desktop - /// build's backend is fontconfig. No font is bundled or registered - /// here -- see the 2026-09-07 decision for why (matching what - /// the Compose app does: it takes body/monospace text from - /// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto - /// and its platform monospace face, and ships no text font of its own, - /// only its committed Nerd Fonts icon subset for fixed glyphs). fn default() -> Self { let mut font_cx = FontContext::new(); patch_android_monospace(&mut font_cx); @@ -112,16 +70,6 @@ impl Default for TextData { } } -/// Registers the bundled icon font as an ordinary named family and -/// answers the name it registered under -- read back from the collection -/// rather than written down here, so the name cannot drift from the file -/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release -/// ships). -/// -/// A *named* family rather than a generic one: nothing should fall back -/// to it for ordinary text, and nothing should fall back out of it for an -/// icon -- a system face that happens to have one of these codepoints -/// would draw somebody else's picture. fn register_icon_font(font_cx: &mut FontContext) -> Option { let blob = Blob::new(Arc::new(NERD_ICONS)); let id = font_cx @@ -133,24 +81,6 @@ fn register_icon_font(font_cx: &mut FontContext) -> Option { font_cx.collection.family_name(id).map(str::to_string) } -/// Works around `fontique` 0.11.1's Android backend never resolving -/// `GenericFamily::Monospace` (confirmed against -/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still -/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no -/// released fix to bump to yet -- see the 2026-09-07 decision, -/// "Platform fonts," for the full account). Two bugs stack, not one: -/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before* -/// `fonts.xml` is parsed into that same name map, and even after parsing, -/// AOSP's `fonts.xml` names it with a `` element -/// (not an ``) whose `` children the backend's own parser -/// does not read (a `TODO` in that match arm) -- so the name gets a -/// `FamilyId` with no font data behind it, and `family_by_name("monospace")` -/// comes back empty too. Confirmed on this checkout's emulator: `adb pull -/// /system/etc/fonts.xml` shows -/// `DroidSansMono.ttf` with no matching -/// alias. -/// /// So this reads `fonts.xml` itself (already on-device, already the /// authority Compose's own `Typeface.MONOSPACE` resolves through) for the /// filename that declaration names, then finds which of fontique's @@ -204,13 +134,6 @@ fn patch_android_monospace(font_cx: &mut FontContext) { } } -/// Reads the font filename `fonts.xml` names for its `"monospace"` family -/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a -/// real XML parser -- a new dependency for one well-known, stable AOSP file -/// whose structure fontique itself already parses with a full parser one -/// module over. Not a general XML reader; assumes the file has exactly one -/// `` element with at least one `` child, -/// which is the format on every AOSP `fonts.xml` this was checked against. #[cfg(target_os = "android")] fn android_monospace_font_filename() -> Option { let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string()); @@ -231,9 +154,6 @@ fn android_monospace_font_filename() -> Option { fn patch_android_monospace(_font_cx: &mut FontContext) {} impl TextData { - /// [`Family::Icons`] as the name the bundled font actually registered - /// under; everything else unchanged. - /// /// Cloned rather than borrowed because the caller needs it while the /// layout builder holds `&mut self` -- a `String` per shaped icon run, /// paid only when the layout is rebuilt. @@ -247,9 +167,6 @@ impl TextData { } } - /// Builds the startup report -- see `FontDiagnostics`. Queries the - /// collection directly (`fontique::Query`) rather than shaping a real - /// string, since all that's needed is which family each axis lands on. pub fn font_diagnostics(&mut self) -> FontDiagnostics { use parley::fontique::{Attributes, FontWidth, QueryStatus}; let families_found = self.font_cx.collection.family_names().count(); @@ -268,11 +185,6 @@ impl TextData { let default_mono_family = default_mono_family_id .and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string)); - // Resolves the family a (generic family, weight, style) query lands - // on, without holding the `Query`'s borrow of `collection` across - // the `family_name` lookup that needs it back -- the `FamilyId` is - // captured out of the closure first, then looked up once `query` - // (and its borrow) has been dropped. let mut resolve_family = |generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option { let mut family_id = None; @@ -327,285 +239,7 @@ impl TextData { icon_family: self.icon_family.clone(), } } -} -/// Which family to ask for. Kept as an owned name rather than parley's -/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime. -#[derive(Clone, PartialEq)] -pub enum Family { - SansSerif, - Serif, - Monospace, - /// The bundled icon font -- see [`crate::icon`] for what is in it. - /// Named as an intention rather than as a font name because only - /// [`TextData`] knows what the file registered as; it resolves this - /// during shaping ([`TextData::resolve_family`]). - Icons, - Named(String), -} - -impl Family { - fn family(&self) -> FontFamily<'_> { - let name = match self { - Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif), - Self::Serif => FontFamilyName::Generic(GenericFamily::Serif), - Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace), - // Only reachable if `resolve_family` did not run, which no - // shaping path allows -- and sans-serif is the honest answer - // for a build whose icon font failed to register: the reader - // gets the platform's own tofu rather than a wrong picture. - Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif), - Self::Named(name) => FontFamilyName::Named(name.as_str().into()), - }; - FontFamily::Single(name) - } -} - -/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style -/// over `range` (a byte range into the buffer's text). Every field is -/// optional so a span only says what it changes -- e.g. a link span sets -/// `color` and `underline` and leaves weight/family at the paragraph's own -/// default. This is I5's answer to RUST.md's inline-rich-text ceiling -/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the -/// whole editor, with `// TODO: RichTextInput` beside it): parley's own -/// `RangedBuilder::push` already takes a style and a range, so per-span -/// bold/italic/monospace/colour/underline only needed plumbing this struct -/// through to it and giving each glyph its own colour at draw time (see -/// `PlacedGlyph::color` and `TextData::place` below) instead of the one -/// `RenderedText::color` every glyph used to share. -#[derive(Clone, PartialEq)] -pub struct SpanStyle { - pub range: Range, - pub color: Option, - pub family: Option, - /// Overrides `TextAttrs::font_size` for just this range -- what lets a - /// heading inside a transcript row's single `TextEdit` be bigger than - /// the paragraph text around it, so a whole markdown-folded row (block - /// and inline styling both) can stay one selectable text buffer instead - /// of one widget per block. - pub font_size: Option, - pub bold: bool, - pub italic: bool, - pub underline: bool, -} - -impl SpanStyle { - pub fn new(range: Range) -> Self { - Self { - range, - color: None, - family: None, - font_size: None, - bold: false, - italic: false, - underline: false, - } - } - pub fn color(mut self, color: UiColor) -> Self { - self.color = Some(color); - self - } - pub fn family(mut self, family: Family) -> Self { - self.family = Some(family); - self - } - pub fn font_size(mut self, size: f32) -> Self { - self.font_size = Some(size); - self - } - pub fn bold(mut self) -> Self { - self.bold = true; - self - } - pub fn italic(mut self) -> Self { - self.italic = true; - self - } - pub fn underline(mut self) -> Self { - self.underline = true; - self - } -} - -#[derive(Clone, PartialEq)] -pub struct TextAttrs { - pub color: UiColor, - pub font_size: f32, - pub line_height: f32, - pub family: Family, - pub wrap: bool, - /// inner alignment of text region (within where it's drawn) - pub align: RegionAlign, -} - -pub const LINE_HEIGHT_MULT: f32 = 1.1; - -impl Default for TextAttrs { - fn default() -> Self { - let size = 16.0; - Self { - color: UiColor::WHITE, - font_size: size, - line_height: size * LINE_HEIGHT_MULT, - family: Family::SansSerif, - wrap: false, - align: Align::CENTER_LEFT, - } - } -} - -/// A string together with its laid-out form. -/// -/// The text and the layout live in one place because parley's `Layout` borrows -/// nothing but is only meaningful against the string it was built from: keeping -/// them apart is how they get out of step. -pub struct TextBuffer { - text: String, - layout: Layout, - spans: Vec, - /// What the current layout was built for, so `shape` can decline to redo - /// work that would come out the same. Spans are not part of this key -- - /// `set_spans` forces `shaped` to `None` directly, the same way `edit` - /// does, since spans change far less often than a naive equality check - /// on the whole `Vec` would cost to compute every frame. - shaped: Option<(TextAttrs, Option, f32)>, -} - -impl TextBuffer { - pub fn new(text: impl Into) -> Self { - Self { - text: text.into(), - layout: Layout::new(), - spans: Vec::new(), - shaped: None, - } - } - - /// Replace this buffer's per-range style overrides (I5's rich text -- - /// see `SpanStyle`). Invalidates the layout unconditionally, mirroring - /// `set_text`. - pub fn set_spans(&mut self, spans: Vec) { - self.spans = spans; - self.shaped = None; - } - - pub fn new_empty() -> Self { - Self::new("") - } - - pub fn text(&self) -> &str { - &self.text - } - - pub fn layout(&self) -> &Layout { - &self.layout - } - - pub fn is_empty(&self) -> bool { - self.text.is_empty() - } - - pub fn set_text(&mut self, text: impl Into) { - let text = text.into(); - if text != self.text { - self.text = text; - self.shaped = None; - } - } - - /// Edit the string in place; invalidates the layout unconditionally, since - /// the caller is assumed to have changed something. - pub fn edit(&mut self) -> &mut String { - self.shaped = None; - &mut self.text - } - - pub fn size(&self) -> Vec2 { - Vec2::new(self.layout.width(), self.layout.height()) - } - - /// Lay the text out, unless it is already laid out for these - /// attributes, this width and this density. - /// - /// **`attrs.font_size`/`line_height` and every span's own `font_size` - /// are density-independent (dp) units, multiplied by `density` here -- - /// the one place text crosses from the widget tree's dp sizes into the - /// physical pixels the shaper and rasteriser (`TextData::place`) both - /// then work in.** This is what makes glyphs sharp on a dense display: - /// before this existed, `font_size` was already a physical-pixel value - /// (RUST.md's P0 box's global-scale stopgap resolved density by - /// stretching the whole rendered frame afterward instead), so a glyph - /// was rasterised small and then upscaled by whatever the display's - /// scale factor was -- exactly the blur Iris's report described. - /// Multiplying here instead means the font size hitting `ScaleContext` - /// in `place` below is already the display's real physical size, so - /// the atlas holds a bitmap at the resolution it is actually shown at. - /// `GlyphKey.size` already keys on that resolved `font_size` - /// (`(font_size * 16.0).round()`), so a cache entry is naturally per - /// physical size with no change needed there. - pub fn shape( - &mut self, - data: &mut TextData, - attrs: &TextAttrs, - width: Option, - density: f32, - ) { - if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { - return; - } - // Resolved before the builder borrows `data`: `Family::Icons` - // names an intention, and the name behind it lives on `TextData`. - let base_family = data.resolve_family(&attrs.family); - let span_families: Vec> = self - .spans - .iter() - .map(|span| span.family.as_ref().map(|f| data.resolve_family(f))) - .collect(); - let mut builder = data - .layout_cx - .ranged_builder(&mut data.font_cx, &self.text, 1.0, true); - builder.push_default(StyleProperty::FontFamily(base_family.family())); - builder.push_default(StyleProperty::FontSize(attrs.font_size * density)); - builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( - attrs.line_height * density, - ))); - builder.push_default(StyleProperty::Brush(attrs.color)); - for (span, family) in self.spans.iter().zip(&span_families) { - let range = span.range.clone(); - if let Some(color) = span.color { - builder.push(StyleProperty::Brush(color), range.clone()); - } - if let Some(family) = family { - builder.push(StyleProperty::FontFamily(family.family()), range.clone()); - } - if let Some(size) = span.font_size { - builder.push(StyleProperty::FontSize(size * density), range.clone()); - } - if span.bold { - builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); - } - if span.italic { - builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone()); - } - if span.underline { - builder.push(StyleProperty::Underline(true), range.clone()); - } - } - builder.build_into(&mut self.layout, &self.text); - self.layout.break_all_lines(width); - self.layout - .align(Alignment::Start, AlignmentOptions::default()); - self.shaped = Some((attrs.clone(), width, density)); - } -} - -impl TextData { - /// Rasterise whatever of `buffer` is not in the atlas yet, and return where - /// each glyph goes relative to the text's top-left. - /// - /// Nothing is uploaded for a glyph already in the atlas, which is the point - /// of having one: a resize re-runs this and touches the GPU only if the new - /// width brought genuinely new glyphs into view. pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec { let mut placed = Vec::new(); for line in buffer.layout.lines() { @@ -622,8 +256,6 @@ impl TextData { continue; }; let coords_hash = hash_coords(coords); - // `font.data.id()` rather than the pointer, so the same font - // loaded twice is still one set of entries. let font_id = font.data.id(); for glyph in run.positioned_glyphs() { @@ -676,41 +308,7 @@ impl TextData { } placed } -} -fn hash_coords(coords: &[i16]) -> u64 { - // FxHash over the coordinates; they are short and change rarely. - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - for c in coords { - h ^= *c as u16 as u64; - h = h.wrapping_mul(0x1000_0000_01b3); - } - h -} - -/// A laid-out string, ready to draw: where each glyph goes and how big the -/// whole thing is. -/// -/// Cheap to clone and to keep, which is the point -- a widget holds one across -/// frames and re-emits its quads without going near the rasteriser. `color` -/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants -/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is -/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can -/// override per range. -#[derive(Clone)] -pub struct RenderedText { - pub glyphs: std::sync::Arc>, - pub size: Vec2, - pub color: UiColor, - /// The [`GlyphAtlas::generation`] the glyphs above were placed against. - /// A holder must re-render rather than re-emit these quads once the - /// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens - /// otherwise); `Painter::glyphs` debug-asserts it. - pub generation: u64, -} - -impl TextData { - /// Lay out and place in one step, which is what a widget wants. pub fn render( &mut self, buffer: &mut TextBuffer, @@ -730,16 +328,255 @@ impl TextData { } } +/// Which family to ask for. Kept as an owned name rather than parley's +/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime. +#[derive(Clone, PartialEq)] +pub enum Family { + SansSerif, + Serif, + Monospace, + /// The bundled icon font -- see [`crate::icon`] for what is in it. + /// Named as an intention rather than as a font name because only + /// [`TextData`] knows what the file registered as; it resolves this + /// during shaping ([`TextData::resolve_family`]). + Icons, + Named(String), +} + +impl Family { + fn family(&self) -> FontFamily<'_> { + let name = match self { + Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif), + Self::Serif => FontFamilyName::Generic(GenericFamily::Serif), + Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace), + Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif), + Self::Named(name) => FontFamilyName::Named(name.as_str().into()), + }; + FontFamily::Single(name) + } +} + +#[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: UiColor) -> Self { + self.color = Some(color); + self + } + pub fn family(mut self, family: Family) -> Self { + self.family = Some(family); + self + } + pub fn font_size(mut self, size: f32) -> Self { + self.font_size = Some(size); + self + } + pub fn bold(mut self) -> Self { + self.bold = true; + self + } + pub fn italic(mut self) -> Self { + self.italic = true; + self + } + pub fn underline(mut self) -> Self { + self.underline = true; + self + } +} + +#[derive(Clone, PartialEq)] +pub struct TextAttrs { + pub color: UiColor, + pub font_size: f32, + pub line_height: f32, + pub family: Family, + pub wrap: bool, + pub align: RegionAlign, +} + +pub const LINE_HEIGHT_MULT: f32 = 1.1; + +impl Default for TextAttrs { + fn default() -> Self { + let size = 16.0; + Self { + color: UiColor::WHITE, + font_size: size, + line_height: size * LINE_HEIGHT_MULT, + family: Family::SansSerif, + wrap: false, + 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<(TextAttrs, 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() == Some(&(attrs.clone(), width, density)) { + return; + } + let base_family = data.resolve_family(&attrs.family); + let span_families: Vec> = self + .spans + .iter() + .map(|span| span.family.as_ref().map(|f| data.resolve_family(f))) + .collect(); + let mut builder = data + .layout_cx + .ranged_builder(&mut data.font_cx, &self.text, 1.0, true); + builder.push_default(StyleProperty::FontFamily(base_family.family())); + builder.push_default(StyleProperty::FontSize(attrs.font_size * density)); + builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute( + attrs.line_height * density, + ))); + builder.push_default(StyleProperty::Brush(attrs.color)); + for (span, family) in self.spans.iter().zip(&span_families) { + let range = span.range.clone(); + if let Some(color) = span.color { + builder.push(StyleProperty::Brush(color), range.clone()); + } + if let Some(family) = family { + builder.push(StyleProperty::FontFamily(family.family()), range.clone()); + } + if let Some(size) = span.font_size { + builder.push(StyleProperty::FontSize(size * density), range.clone()); + } + if span.bold { + builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone()); + } + if span.italic { + builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone()); + } + if span.underline { + builder.push(StyleProperty::Underline(true), range.clone()); + } + } + builder.build_into(&mut self.layout, &self.text); + self.layout.break_all_lines(width); + self.layout + .align(Alignment::Start, AlignmentOptions::default()); + self.shaped = Some((attrs.clone(), width, density)); + } +} + +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>, + pub size: Vec2, + pub color: UiColor, + /// The [`GlyphAtlas::generation`] the glyphs above were placed against. + /// A holder must re-render rather than re-emit these quads once the + /// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens + /// otherwise); `Painter::glyphs` debug-asserts it. + pub generation: u64, +} + #[cfg(test)] mod tests { use super::*; use crate::icon; - /// Every codepoint `icon` names is actually in the subset the script - /// built. This is the failure `build-icon-font.sh`'s own comment warns - /// about -- a constant added on one side and not the other is a glyph - /// that silently isn't there -- and it is invisible at runtime, - /// because a missing glyph draws as nothing rather than as an error. #[test] fn every_icon_is_in_the_bundled_font() { let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses"); @@ -762,8 +599,6 @@ mod tests { } } - /// The font registers, so `Family::Icons` resolves to a real family - /// rather than falling through to sans-serif and drawing tofu. #[test] fn the_icon_family_registers_and_resolves() { let data = TextData::default(); diff --git a/core/src/primitive/texture.rs b/core/src/primitive/texture.rs index 04cd363..4424616 100644 --- a/core/src/primitive/texture.rs +++ b/core/src/primitive/texture.rs @@ -22,10 +22,6 @@ pub enum TextureKind { }, } -/// What a [`Textures::shared`] texture is a picture of -- exactly, not by -/// hash: `owner` names the widget kind whose description it is, and `id` -/// packs that description's own fields, so two owners cannot collide and -/// a debugger shows which picture a slot holds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SharedTextureKey { pub owner: &'static str, @@ -46,8 +42,6 @@ pub struct TextureHandle { pub struct Textures { free: Vec, images: Vec>, - /// What each slot is, kept beside the image so a slot can be pushed - /// again without the handle that knows -- see [`Textures::reupload`]. kinds: Vec, /// Textures built from a description rather than from a file, one per /// distinct description: see [`Textures::shared`]. The map holds a @@ -119,8 +113,6 @@ impl Textures { } } - /// Adds a page of the shared glyph atlas array. Only `atlas.rs` should - /// call this -- everything else wants `add`. pub fn add_page(&mut self, image: impl Into) -> TextureHandle { let image = image.into(); let size = image.dimensions().into(); @@ -152,18 +144,6 @@ impl Textures { } } - /// The one texture for `key`, building it on the first ask and handing - /// out a further reference to it every time after. - /// - /// **Why this exists**: a texture rasterised from a *description* -- - /// `widget::mark`'s triangle, from a direction and a colour -- has as - /// many copies as there are widgets asking for it, and each copy is - /// its own GPU texture, its own bind group and its own draw call. A - /// transcript screen with a folded card per tool call built one per - /// card: hundreds of 48x48 textures of three distinct pictures, - /// created and freed again as rows recycled. `make` is not called when - /// the key is already known, so the rasterising is paid once too. - /// /// The map keeps its own reference for the life of the `Textures`, so /// a shared slot is never freed and never reused for something else -- /// which is what makes a handle held by a long-lived widget safe. @@ -180,21 +160,16 @@ impl Textures { handle } - /// The stored image for a handle, to be written into before `patch`. pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage { self.images[handle.slot as usize] .as_mut() .expect("texture was freed while still held") } - /// Queue an upload of just `rect`, after writing it with `image_mut`. pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) { self.updates.push(Update::Patch(handle.slot, rect)); } - /// Queue every live slot for upload again, in slot order -- what a - /// genuinely new GPU device needs, in place of forgetting everything. - /// /// A new device starts with no textures, and the renderer-side mirror /// of these slots (`render::texture::GpuTextures`) starts empty with /// it. What it must not do is start empty while the handles widgets @@ -208,15 +183,6 @@ impl Textures { /// still holds the images: the slot list is rebuilt identically, /// including the empty slots, which go across as `PushFree` so the /// ones after them still land where they were. - /// - /// The glyph atlas comes back with it and is deliberately *not* - /// cleared any more: its pages are slots here, this side holds their - /// pixels, and re-uploading them restores exactly the atlas that was - /// there -- so an app switch no longer costs a re-rasterisation of - /// every glyph on screen either. - /// - /// Pending updates are dropped rather than kept: each is either a push - /// or a patch of a slot this replays in full. pub fn reupload(&mut self) { self.updates.clear(); self.updates @@ -275,8 +241,6 @@ impl TextureHandle { } } - /// The layer this page occupies in the shared atlas array texture. - /// Only valid for a page handle; see `image_index`'s note. pub fn layer(&self) -> u32 { match self.kind { TextureKind::Page { layer } => layer, @@ -320,9 +284,6 @@ mod tests { SharedTextureKey { owner: "test", id } } - /// What `widget::mark` needs: one texture per description, however - /// many widgets ask for it, and a different description is a - /// different texture. #[test] fn a_shared_texture_is_built_once_and_handed_out_again() { let mut textures = Textures::new(); @@ -341,9 +302,6 @@ mod tests { assert_ne!(first.image_index(), other.image_index()); } - /// The map's own reference is what keeps a shared slot alive: every - /// widget holding one can go away and the slot must not be recycled, - /// because the next widget to ask gets that same index back. #[test] fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() { let mut textures = Textures::new(); @@ -357,9 +315,6 @@ mod tests { ); } - /// A new GPU device gets the same slot numbering back, so a handle a - /// widget has been holding all along still names its own texture -- - /// the crash `reupload` replaced `reset` to fix. #[test] fn reupload_replays_every_slot_in_order_including_the_empty_ones() { let mut textures = Textures::new(); @@ -373,7 +328,6 @@ mod tests { ); drop(dropped); textures.free(); - // Drain the updates so far, the way a frame does. assert!(textures.updates().count() > 0); textures.reupload(); diff --git a/core/src/render/atlas.rs b/core/src/render/atlas.rs index 320b81b..4129e78 100644 --- a/core/src/render/atlas.rs +++ b/core/src/render/atlas.rs @@ -1,14 +1,3 @@ -//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text -//! is a quad per glyph rather than a texture per string. -//! -//! What this replaces is why it exists. Text used to be rasterised into its own -//! `RgbaImage` and uploaded as a whole texture, per text widget, every time -//! anything about it changed -- so every window resize re-rasterised and -//! re-uploaded every visible string, which is what the TODO meant by "resizing -//! (per frame) is really slow". Here a glyph is rasterised once for a given -//! font, size and subpixel offset and then reused by every string that contains -//! it, and a resize re-emits quads without touching the GPU's copy at all. - use crate::{ PatchRect, TextureHandle, Textures, UiColor, util::{HashMap, Vec2}, @@ -16,30 +5,16 @@ use crate::{ use image::RgbaImage; use swash::scale::image::{Content, Image}; -/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a -/// few thousand glyphs at UI sizes, and small enough that a page nobody fills -/// is not a big waste. Also the fixed width/height of every layer of the -/// shared array texture in `render::texture` -- `pub(crate)` so that module -/// can size it without a second constant to keep in sync. pub(crate) const PAGE: u32 = 1024; -/// Transparent margin kept around every glyph, so that sampling one cannot -/// pick up its neighbour along a shared edge. const PAD: u32 = 1; -/// Identifies a rasterised glyph. Anything that changes the pixels has to be in -/// here, or two different glyphs share one entry and the wrong one is drawn. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct GlyphKey { pub font: u64, pub glyph: u32, - /// Font size in 1/16 px, so sizes that round to the same pixels share a - /// raster instead of filling the atlas with near-duplicates. pub size: u32, - /// Horizontal subpixel phase, in 1/4 px. pub subpixel: u8, - /// Hash of the variation coordinates; a variable font at two weights is two - /// different sets of pixels from one glyph id. pub coords: u64, } @@ -47,13 +22,11 @@ pub struct GlyphKey { pub struct GlyphEntry { pub uv_min: [f32; 2], pub uv_max: [f32; 2], - /// Offset from the glyph's pen position to the top-left of its pixels. pub left: i32, pub top: i32, pub width: u32, pub height: u32, pub is_color: bool, - /// The atlas array layer this glyph's page occupies. pub layer: u32, } @@ -71,12 +44,7 @@ struct Page { #[derive(Default)] pub struct GlyphAtlas { pages: Vec, - /// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs - /// from an earlier atlas can tell that its coordinates are stale -- - /// see that method's doc for what goes wrong without it. generation: u64, - /// `None` for a glyph that rasterised to nothing -- a space, say. Cached - /// too, so it is not re-rasterised on every layout. entries: HashMap>, } @@ -138,7 +106,6 @@ impl GlyphAtlas { Some(entry) } - /// A free `w`x`h` spot, opening a shelf or a page as needed. fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) { let need_w = w + PAD; let need_h = h + PAD; @@ -165,14 +132,10 @@ impl GlyphAtlas { (self.pages.len() - 1, PAD, PAD) } - /// Record that a glyph has no pixels, so it is not re-rasterised. pub fn insert_empty(&mut self, key: GlyphKey) { self.entries.insert(key, None); } - /// Which atlas the entries handed out right now belong to. A - /// [`crate::RenderedText`] records this when it is built and is only - /// reusable while it still matches. pub fn generation(&self) -> u64 { self.generation } @@ -185,30 +148,6 @@ impl GlyphAtlas { self.entries.len() } - /// Forget every page and every rasterised entry -- what a genuinely new - /// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s - /// "not already live" branch, e.g. after backgrounding): the pages this - /// atlas remembers are `TextureHandle`s into the *old* device's - /// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/ - /// `uv_max`/`layer` point into them. Without this, a glyph already - /// cached here is treated as "already placed" and never re-inserted - /// into the fresh (empty) atlas the new renderer actually has -- - /// exactly the "rectangles stay, glyphs disappear" bug the resize path - /// (`AndroidRenderer::resize`) was built to avoid for the reuse case; - /// this is its counterpart for the case where the renderer really is - /// new. Dropping `pages` also drops its `TextureHandle`s, which send a - /// free message back through their `Textures`; see `Textures::reset`'s - /// doc for why that is harmless here. - /// Bumping `generation` here is the other half of the same - /// invalidation: emptying this atlas does nothing about the - /// `RenderedText`s widgets are *already holding* - /// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry - /// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown - /// away. Those redraw perfectly happily and sample whatever now sits at - /// those coordinates -- the fragments-of-other-glyphs Iris photographed - /// after resuming the app on 2026-09-06. One counter, checked where the - /// cache is read, is what makes a cached render un-reusable across a - /// renderer rebuild. pub fn clear(&mut self) { self.pages.clear(); self.entries.clear(); @@ -217,15 +156,10 @@ impl GlyphAtlas { } fn fits(page: &Page, need_w: u32, need_h: u32) -> bool { - // On the current shelf, or on a new one above it. (page.x + need_w <= PAGE && page.y + need_h <= PAGE) || (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE) } -/// Copy one rasterised glyph into the page image at `(x, y)`. -/// -/// A mask glyph keeps its coverage in alpha with the colour left to the shader, -/// so one raster serves text of any colour; a colour glyph carries its own. fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { let w = image.placement.width; let h = image.placement.height; @@ -253,10 +187,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { } } Content::SubpixelMask => { - // Not asked for: `Format::Alpha` is what the renderer requests, so - // reaching here means the request changed and this needs writing. - // Drawn as a plain mask from the green channel rather than dropped, - // so the text is readable rather than absent. for row in 0..h { for col in 0..w { let i = ((row * w + col) * 4) as usize; @@ -268,14 +198,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) { } } -/// Where a glyph goes on screen, in pixels relative to the text's origin. -/// -/// `color` is per-glyph (read from the parley run's own `Brush`, since -/// `UiColor` is parley's brush type here) rather than a single colour for -/// the whole `RenderedText`, so that a span pushed with its own -/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour -/// inside one wrapped paragraph) actually renders in that colour instead of -/// the buffer's base one. #[derive(Clone, Copy)] pub struct PlacedGlyph { pub entry: GlyphEntry, diff --git a/core/src/render/data.rs b/core/src/render/data.rs index 6583efd..e4eb759 100644 --- a/core/src/render/data.rs +++ b/core/src/render/data.rs @@ -8,15 +8,6 @@ pub struct WindowUniform { pub height: f32, } -/// One primitive's placement and what to draw there, in the one arena -/// every layer shares (`Primitives`). Read from a storage buffer by -/// **both** shader stages: the vertex stage for the corners of the -/// primitive it is drawing, the fragment stage for the corners of a -/// *mask's* primitive, which is generally a different one and often in -/// another layer. A layer's vertex buffer carries only the slot -/// ([`instance_slot_layout`]), so there is exactly one copy of a -/// placement and a mask cannot disagree with what was drawn. See -/// LAYOUT.md's "Masks with a shape". #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PrimitiveInstance { @@ -27,11 +18,6 @@ pub struct PrimitiveInstance { pub move_idx: MoveIdx, } -/// The vertex layout of a layer's draw order: one `u32` slot into the -/// global instance arena per instance, stepped per instance. Everything a -/// primitive is made of used to be here as eight vertex attributes; it -/// moved into the storage buffer above so the fragment stage can read it -/// too. pub fn instance_slot_layout() -> VertexBufferLayout<'static> { const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32]; VertexBufferLayout { @@ -49,38 +35,9 @@ impl MaskIdx { pub type MoveIdx = Id; -/// A clip, as a reference to a primitive already written plus the mask it -/// nests inside. The fragment stage evaluates that primitive's coverage -/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage` -/// from the same SDF the rect itself is drawn with -- and multiplies it -/// into the pixel's alpha, so a rounded container's corner and its -/// children's clipped corner are the same arithmetic and cannot disagree. -/// See LAYOUT.md's "Masks with a shape". -/// -/// **No `kind` and no `flags`**, which the design sketched: the referenced -/// instance already carries its own `binding`, and a copy of it here is a -/// second thing to keep in step; alpha-only is the only mode there is, so -/// there is nothing to select. Both are a field away if a second mode -/// appears. #[repr(C)] #[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct Mask { - /// The slot in `UiRenderState::primitives` of the primitive whose - /// coverage this mask is. Today always a `RectPrimitive`: a glyph or - /// a standalone image would need, respectively, a CPU-side alpha - /// plane for the hit test to agree with the shader, and a bind-group - /// switch the fragment stage cannot make -- `Painter::set_mask` - /// rejects both by name rather than leaving the shader to read a rect - /// that is not there. - /// - /// Who owns it depends on which way the mask was set. A plain - /// `.masked()` writes its own undrawn rect, so the primitive is in - /// the masking widget's `ActiveData::primitives` and lives exactly as - /// long as the mask. `.masked_by(shape)` points at a *child's* - /// primitive, which that child can free on any redraw of its own -- - /// so `UiRenderState::remask_shape_users` marks the mask's owner for - /// redraw whenever a referenced slot is freed, since that widget's - /// own `set_mask` is the only thing that resolves the slot again. pub primitive: u32, /// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so /// clipping nests: the fragment stage walks the chain and multiplies @@ -90,20 +47,9 @@ pub struct Mask { /// fence inside a transcript row carries the row's scroll, the list's /// own box does not, and one region resolved when the fence was last /// drawn gets the second of those wrong as soon as the row moves. - /// - /// A child holds one ref on its parent's slot (`Painter::set_mask`), - /// released when the child's own slot goes - /// (`UiRenderState::remove`), so the chain cannot outlive what it - /// points at. pub parent: MaskIdx, } -/// One widget's cumulative on-screen translation, and the slot of the -/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A -/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for -/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are -/// translations of an already-drawn subtree. See LAYOUT.md section 2. -/// /// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is /// a `vec2`, which gives the struct an 8-byte alignment and rounds its /// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 -- diff --git a/core/src/render/frame_report.rs b/core/src/render/frame_report.rs index 6813b5d..84ed719 100644 --- a/core/src/render/frame_report.rs +++ b/core/src/render/frame_report.rs @@ -9,58 +9,20 @@ use std::time::{Duration, Instant}; /// "late" that merely met its own, faster budget. pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667); -/// Enough frames for several minutes of scrolling before the oldest ones -/// start being overwritten -- the same "diagnostic, not a log" sizing -/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently -/// here since a `Duration` is smaller than the six `Long` arrays it keeps. -/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+ -/// keyboard run is ~6,500+ frames on the Compose side, comfortably under -/// this so `phase_stats` never has to report a phase as partially evicted. const RING_CAPACITY: usize = 16384; -/// How many measurable frame-to-frame gaps [`FrameReport:: -/// sustained_frame_hz`] needs before it will answer at all. A tenth of a -/// second's worth at any plausible rate -- enough for a rate to mean -/// something, and little enough that any real phase has it. const MIN_CADENCE_SAMPLES: usize = 12; -/// One `mark_phase` call: the wall-clock instant and the (0-based, -/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at -/// which a phase began -- `phase_stats` slices `index_ring` against this to -/// find which recorded samples belong to which phase, since the ring -/// itself only keeps the most recent `RING_CAPACITY` samples' *values*, -/// not which phase they were in. struct PhaseMark { name: String, start_index: u64, start_at: Instant, } -/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's -/// "per-phase blocks in `FrameReport`... frames, late count/percent... -/// p50/p90/p99, worst, duration". `Display` matches the shape -/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so -/// the two apps' reports read the same way side by side. pub struct PhaseStats { pub name: String, - /// How many frames were recorded during this phase in total -- may - /// exceed `late + (samples counted)` if some of this phase's frames - /// have since been evicted from the ring by a very long run; that - /// case is named in the `Display` rather than silently under-counted. pub frames: u64, pub duration: Duration, - /// Frames whose **work** exceeded the budget -- `total` minus the - /// swapchain wait, since a frame held back by the display was ready - /// on time and the display was not. - /// - /// Judging the total instead is what this did until 2026-09-09, and - /// it does not survive the app being *well* paced: a loop that draws - /// in 0.4ms and then waits its turn measures one whole refresh period - /// per frame, so every frame sits exactly on the budget and `late` - /// becomes a coin toss on noise. See [`Self::missed`] for the - /// question "did a frame fail to arrive", which is the one a reader - /// actually sees. - /// /// 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 @@ -81,25 +43,10 @@ pub struct PhaseStats { /// say that. /// Vsyncs that went by with no frame produced for them, counted from /// the gap between consecutive frames rather than from their cost. - /// - /// **`late` and this are different questions and the second is the - /// one a reader sees.** A frame can be over budget and still be shown - /// on the next vsync; a frame that is never produced leaves the - /// previous one on screen for two refreshes, which is the stutter. - /// Nothing in a report could say this before 2026-09-09 -- the two - /// were folded together under `late`, so "we drew every frame, some - /// slowly" and "we skipped 1 frame in 8" read identically. - /// - /// Zero on the first frame of a run, whose gap is unknowable. pub missed: u64, pub build_p50: Duration, pub acquire_p50: Duration, pub submit_p50: Duration, - /// `false` if this phase's frame count exceeds how many samples of it - /// are still in the ring -- the percentiles above are then computed - /// over whatever survived, not the whole phase. UI_RULES.md: this is - /// the "we don't fully know" state, named rather than folded silently - /// into a number that looks exact. pub complete: bool, } @@ -142,29 +89,10 @@ impl std::fmt::Display for PhaseStats { /// The parts one frame's wall time divides into, measured rather than /// inferred: what a caller hands [`FrameReport::record`]. -/// -/// The three are consecutive and together they are `total`, so whatever -/// is left after `acquire` and `submit` is the frame's own work -- laying -/// out, shaping text, building primitives and recording the render pass. -/// That leftover is what a report calls `build`. -/// -/// **`acquire` is the one that is not work.** It is the wait inside -/// `Surface::get_current_texture` for a swapchain image to come free, -/// which is the display pacing the app: an app that draws faster than the -/// screen refreshes spends *most* of every frame there, and that is the -/// healthy state rather than a slow one. It was inside the CPU half until -/// 2026-09-09, which made a fling's frames read as several milliseconds -/// of iris being slow when they were milliseconds of iris waiting its -/// turn -- UI_RULES.md's rule against presenting an inferred value as a -/// measured one, arriving in a diagnostic. #[derive(Clone, Copy, Default, Debug)] pub struct FrameParts { - /// Redraw start to after `present()` was called -- the span the whole - /// report is about. pub total: Duration, - /// The wait for a swapchain image (`get_current_texture`). pub acquire: Duration, - /// `queue.submit` plus `present()`. pub submit: Duration, } @@ -201,24 +129,11 @@ impl FrameParts { .saturating_sub(self.submit) } - /// Everything that was not waiting for the display's permission to - /// draw -- `build` plus `submit`. What a frame had to finish before - /// it could be shown, and so what a budget is meaningfully compared - /// against; see [`PhaseStats::late`]. pub fn work(&self) -> Duration { self.total.saturating_sub(self.acquire) } } -/// A per-frame wall-time report iris keeps of itself, because `dumpsys -/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all -/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's -/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered -/// `SurfaceView` bypasses entirely. `record` is meant to be called once per -/// frame, wrapping the same span Compose's own render report and `gfxinfo` -/// count -- from the frame's redraw/update start to after the frame is -/// handed to the platform to present. -/// /// **What this does not measure**: wgpu's `present()` call queues the frame /// with the compositor and returns; it is not fenced against the GPU /// actually finishing the frame or the compositor actually showing it, the @@ -228,17 +143,8 @@ impl FrameParts { /// [`FrameStats`]'s own `Display` line rather than presented as the latter, /// per the standing rule against showing an inferred number as a measured /// one where the two differ. -/// -/// Fixed-size ring, no allocation on the hot path -- `report()` is the only -/// place that allocates (a sort over the current ring), and it is only -/// ever called from a button tap, not once per frame. pub struct FrameReport { ring: Box<[Duration; RING_CAPACITY]>, - /// The `submit_to_present` half of each sample in `ring`, same index, - /// same lifetime -- kept as a second ring rather than a ring of pairs so - /// the existing `ring`/percentile code above is untouched (RUST.md's I5 - /// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05). - /// See [`FrameParts`] for how the three rings divide a frame up. 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 @@ -247,47 +153,16 @@ pub struct FrameReport { /// 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. - /// - /// **`Duration::ZERO` means "no cadence information", not "no gap".** - /// Two frames say nothing about the display's rhythm unless the app - /// was actually trying to draw between them: the first frame after a - /// `reset` has nothing before it, and a frame that follows an *idle* - /// one is separated by however long nobody wanted anything drawn. - /// Counting those was this counter's first version, and it reported - /// a bench's own deliberate pauses as stutter -- 276 "missed" frames - /// for sixteen 300ms rests between flings, and 2410 for twelve - /// hundred 50ms gaps between keystrokes (Iris's phone, 2026-09-09). gap_ring: Box<[Duration; RING_CAPACITY]>, - /// When the last recorded frame was and whether it had asked for - /// another -- `None` until the first frame since a `reset`. The flag - /// is what makes the next frame's gap a measurement rather than a - /// record of how long the app sat idle. last_frame: Option<(Instant, bool)>, - /// The absolute (0-based, since the last `reset`) frame index each - /// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats` - /// slices against `PhaseMark::start_index` to tell which recorded - /// frames fall in which phase. index_ring: Box<[u64; RING_CAPACITY]>, - /// How many of `ring`'s slots hold a real sample -- saturates at - /// `RING_CAPACITY`, unlike `total_frames` below which keeps counting. len: usize, pos: usize, - /// All frames recorded since the last `reset`, even past `RING_CAPACITY` - /// -- what `janky_percent` divides by, so a long run's percentage stays - /// correct even once the ring itself only holds the most recent frames. total_frames: u64, janky_frames: u64, - /// `mark_phase` calls since the last `reset`, oldest first -- see - /// `phase_stats`. Empty on an ordinary run that never calls - /// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller - /// prints no "per phase:" section at all, matching RUST.md's "empty/ - /// absent on an ordinary 'Copy' press, which never marks a phase." phases: Vec, } -/// One resolved reading. `Display` is the log line both the "Frame report" -/// button and `transcript-bench.sh`-style scripts read, grep-able on -/// `"iris frame report"`. pub struct FrameStats { pub total_frames: u64, pub janky_percent: f64, @@ -295,25 +170,8 @@ pub struct FrameStats { pub p90: Duration, pub p99: Duration, pub worst: Duration, - /// Median of [`FrameParts::build`] -- iris's own CPU work per frame: - /// laying out, shaping text, building primitives and recording the - /// render pass. RUST.md's I5 "Where iris's frame time goes" split, - /// added 2026-09-05 to answer "CPU or GPU?" with a number rather than - /// a guess, and corrected on 2026-09-09 to stop counting the - /// swapchain wait below as iris's own work. pub cpu_p50: Duration, - /// Median of [`FrameParts::acquire`]: the wait for a swapchain image. - /// **Not work** -- see that field's doc. A large number here beside a - /// small `cpu_p50` is an app comfortably ahead of the display, which - /// is what it should look like. pub acquire_p50: Duration, - /// Median of `submit_to_present` -- the `queue.submit` call itself plus - /// `present()`, i.e. wherever the driver/GPU/compositor wait actually - /// happens. Same caveat as the type's own doc: `present()` is not - /// fenced against the GPU actually finishing, so this is "how long the - /// CPU was blocked handing the frame off", not the frame's true GPU - /// time -- still enough to separate "iris is slow building the frame" - /// from "iris is slow handing it to the driver". pub gpu_wait_p50: Duration, } @@ -359,8 +217,6 @@ impl FrameReport { } } - /// Record one frame, split into [`FrameParts`]. O(1), no allocation. - /// /// 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 @@ -368,8 +224,6 @@ impl FrameReport { 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), - // Nothing was moving, so the distance to this frame is idle - // time rather than cadence -- see `gap_ring`'s own doc. Some((_, false)) | None => Duration::ZERO, }; self.last_frame = Some((at, animating)); @@ -385,12 +239,6 @@ impl FrameReport { } } - /// Clears every counter and every sample -- what the "Reset frame - /// report" control calls, so a report covers only what was scrolled - /// after the button was pressed (the same reason `FrameStats.kt`'s - /// `reset()` exists on the Compose side). Also clears every phase - /// mark, so a fresh run starts with no "per phase:" section until it - /// marks one of its own. pub fn reset(&mut self) { self.len = 0; self.pos = 0; @@ -400,8 +248,6 @@ impl FrameReport { self.phases.clear(); } - /// One recorded slot's three parts, back as the type they were - /// recorded in. fn parts(&self, slot: usize) -> FrameParts { FrameParts { total: self.ring[slot], @@ -410,16 +256,7 @@ impl FrameReport { } } - /// Marks the start of a named phase at the current moment -- every - /// frame recorded from here until the next `mark_phase` (or `reset`) - /// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls - /// this once per phase (fling/stream/type/keyboard) so `phase_stats` - /// can slice one whole run's frames by what was happening during each. pub fn mark_phase(&mut self, name: &str) { - // `phase_stats`'s slicing (`idx >= phase.start_index && idx < - // end_index`) silently produces an empty or nonsensical slice for - // a phase pushed out of order rather than surfacing the misuse - // (review, 2026-09-06). debug_assert!( self.phases .last() @@ -432,12 +269,6 @@ impl FrameReport { }); } - /// One [`PhaseStats`] per `mark_phase` call since the last `reset`, - /// oldest first. `now` closes the last phase's wall-clock span (there - /// is no "next phase" instant to use for it); `refresh_hz` is what - /// each phase's own `late`/`late_percent` is judged against, read from - /// the display rather than assumed -- RUST.md's "Benchmark v2": "late - /// count/% against the display's refresh rate." pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec { if self.phases.is_empty() || refresh_hz <= 0.0 { return Vec::new(); @@ -478,18 +309,11 @@ impl FrameReport { complete, }; } - // Each part gets its own sort: medians do not distribute - // over subtraction, so `build`'s median is not `total`'s - // minus the other two. 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] }; - // A gap of more than one and a half budgets means at - // least one vsync came and went unanswered; the count is - // how many, so a frame arriving three periods late says 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 @@ -532,10 +356,6 @@ impl FrameReport { .collect() } - /// The rate frames were actually **sustained** at, in Hz, over the - /// stretches where the app was animating -- measurable gaps divided - /// into their own total, so idle time is excluded by construction. - /// /// **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 @@ -571,9 +391,6 @@ impl FrameReport { samples.sort_unstable(); let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)]; - // Separate arrays rather than subtracting the two medians above: - // medians do not distribute over subtraction, and each needs its - // own sort. let submit_samples: Vec = self.submit_ring[..self.len].to_vec(); let acquire_samples: Vec = self.acquire_ring[..self.len].to_vec(); let cpu_samples: Vec = (0..self.len).map(|j| self.parts(j).build()).collect(); @@ -595,24 +412,11 @@ impl FrameReport { }) } - /// `(late count, late percent)` over every sample still in the ring, - /// judged against `refresh_hz`'s own frame budget rather than the - /// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late - /// count/% against the display's refresh rate... print 'at N Hz (X ms - /// budget)' like Compose does." A separate method from `report()` - /// rather than a parameter on it, so `report()`'s own `janky_percent` - /// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is - /// unaffected for every existing caller that never measured a real - /// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive - /// `refresh_hz`. pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) { if self.len == 0 || refresh_hz <= 0.0 { return (0, 0.0); } let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64); - // The frame's work, not its total -- the same rule and the same - // reason as `PhaseStats::late`, which this is the run-wide half - // of. let late = (0..self.len) .filter(|&j| self.parts(j).work() > budget) .count() as u64; @@ -654,8 +458,6 @@ mod tests { #[test] fn percentiles_and_worst_over_a_known_set() { let mut r = FrameReport::new(); - // 100 samples, 1ms..=100ms, fed out of order so the ring's own - // order is not what gives the right answer -- the sort has to. for ms in (1..=100).rev() { r.record( Instant::now(), @@ -690,8 +492,6 @@ mod tests { #[test] fn janky_percent_is_over_all_time_frames_not_just_the_ring() { - // Fewer than RING_CAPACITY frames, all janky, then a fresh reset -- - // the percentage must reset to 0, not divide by a stale count. let mut r = FrameReport::new(); for _ in 0..10 { r.record( @@ -713,9 +513,6 @@ mod tests { #[test] fn record_without_a_split_reports_the_whole_frame_as_cpu() { - // A caller that never measured the split (plain `record`) should - // not fabricate a GPU-wait number -- it reads as zero, and the CPU - // half reads as the whole frame. let mut r = FrameReport::new(); r.record( Instant::now(), @@ -730,10 +527,6 @@ mod tests { #[test] fn each_part_reports_its_own_median_and_build_excludes_the_wait() { let mut r = FrameReport::new(); - // Three frames of the same 30ms total, with the split moving: - // each part needs its own sort, and `build` is what is left after - // both waits -- not the total, which is the bug this replaced - // (the swapchain wait used to be counted as iris's own work). for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] { r.record( Instant::now(), @@ -749,21 +542,15 @@ mod tests { 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)); - // 30-5-2=23, 30-10-3=17, 30-20-4=6 -> median 17. assert_eq!(stats.cpu_p50, Duration::from_millis(17)); } #[test] fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() { - // Cost and cadence are separate questions: every frame here is - // well inside its budget, so `late` is zero, and the run still - // skipped three vsyncs -- which is what a reader sees as a - // stutter and what nothing in a report could say before. let mut r = FrameReport::new(); let base = Instant::now(); let budget = Duration::from_nanos(16_666_667); r.mark_phase("fling"); - // Frames at 0, 1, 2, 4 (one skipped), 5, 8 (two skipped) budgets. for step in [0u32, 1, 2, 4, 5, 8] { r.record( base + budget * step, @@ -778,18 +565,10 @@ mod tests { #[test] fn an_idle_gap_is_not_a_missed_frame() { - // What the first version of this counter got wrong on Iris's - // phone: a bench rests 300ms between flings and types one - // character per 50ms, and every one of those gaps was reported as - // stutter (276 and 2410 "missed" frames, which is exactly the - // rests). A frame that did not ask for another one is idle, and - // the distance to whatever comes next says nothing. let mut r = FrameReport::new(); let base = Instant::now(); let budget = Duration::from_nanos(16_666_667); r.mark_phase("fling"); - // Two frames of real animation, then one that stops animating, - // then a long rest before the next burst. 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); @@ -807,9 +586,6 @@ mod tests { #[test] fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() { - // Iris's phone, 2026-09-09: the display reported 60Hz for a run - // that drew at 120, so every phase was judged against twice the - // budget it should have been. let mut r = FrameReport::new(); let base = Instant::now(); let period = Duration::from_nanos(8_333_333); @@ -826,19 +602,10 @@ mod tests { #[test] fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() { - // The other direction, and the one the first version of this got - // wrong: this repo's emulator draws about 51fps on a 60Hz - // display, and taking the fastest tenth of the gaps reported - // 88Hz -- a budget no frame there could meet, invented out of the - // app's best moments. A sustained rate cannot do that, which is - // what makes a caller's `max` against the platform's own answer - // safe in both directions. let mut r = FrameReport::new(); let base = Instant::now(); let mut at = base; for step in 0..120u32 { - // Mostly slow with an occasional quick pair -- the shape that - // fooled the percentile. at += if step % 10 == 0 { Duration::from_millis(8) } else { @@ -855,10 +622,6 @@ mod tests { #[test] fn a_frame_held_back_by_the_display_is_not_late() { - // The signature of a well-paced loop: 0.4ms of work and the rest - // of the refresh period spent waiting its turn. Judging the total - // calls every one of those frames late; judging the work calls - // none of them late, which is what they are. let mut r = FrameReport::new(); let base = Instant::now(); let period = Duration::from_nanos(8_333_333); @@ -881,10 +644,6 @@ mod tests { #[test] fn a_phase_does_not_inherit_the_pause_before_it() { - // The half the fix above had no reason to touch: a bench rests - // between phases, and that rest reaches the next phase's first - // frame as its gap. Charging it there would open every phase with - // a large invented `missed`. let mut r = FrameReport::new(); let base = Instant::now(); let budget = Duration::from_nanos(16_666_667); @@ -896,7 +655,6 @@ mod tests { true, ); } - // A second of rest, then the next phase starts clean. let after = base + Duration::from_secs(1); r.mark_phase("type"); for step in [0u32, 1, 2] { @@ -925,11 +683,7 @@ mod tests { ); } let stats = r.report().unwrap(); - // total_frames keeps the full count even once the ring has wrapped. assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64); - // but every sample the ring can report on is still one of the five - // values fed in, since a wrap can only overwrite with more of the - // same pattern here. assert!(stats.worst <= Duration::from_millis(5)); } @@ -1011,7 +765,6 @@ mod tests { #[test] fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() { let mut r = FrameReport::new(); - // 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one. r.record( Instant::now(), FrameParts::whole(Duration::from_millis(10)), diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 04e4242..3285c68 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -28,42 +28,8 @@ pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD}; pub use primitive::*; pub use sdf::{distance_from_rect, rounded_rect_coverage}; -/// The one shader every primitive is drawn with. Public so a test can run -/// a function out of it against the CPU transliteration in [`sdf`] -- -/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns -/// on: a masked corner that cannot be tapped and a masked corner that is -/// not drawn are only the same corner while the two agree. pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl"); -/// The `wgpu::Limits` both platform backends (`android::render:: -/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask -/// `Adapter::request_device` for -- shared so the two copies cannot drift, -/// per AGENTS.md's "write the logic once." -/// -/// Built from `Limits::default()`, **not** a downlevel variant: the shader -/// (`shader.wgsl`) reads four `var` buffers (rects, glyphs, masks, -/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()` -/// zeroes `max_storage_buffers_per_shader_stage` along with the compute -/// limits below -- switching to it would trade one `request_device` crash -/// for a bind-group-layout one on the same downlevel hardware this is meant -/// to support. `max_buffer_size` is raised for the growing instance/atlas -/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s -/// desktop-tier value, unchanged. -/// -/// The six `max_compute_*` fields are zeroed because nothing in this crate -/// creates a `ComputePipeline` or writes a `@compute` shader stage -- -/// grepped for both across `iris`/`iris-core` before writing this, found -/// none. `Limits::default()` requests desktop-tier compute limits -/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even -/// though nothing asks a device to actually support compute, which is what -/// crashed `request_device` on the Android emulator's software GL path -/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as -/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit -/// is 0 and the unconditional request fails outright -/// (`RUST.md`'s "Software mode ... crashes for a third, different reason"). -/// The same would happen on a real GLES-3.0-only Android device. If a -/// future change adds a compute pass, request the specific limits it needs -/// here rather than reverting to the desktop-tier default for everything. pub fn device_limits() -> Limits { Limits { max_buffer_size: 1 << 30, @@ -77,31 +43,11 @@ pub fn device_limits() -> Limits { } } -/// A capped log of wgpu's *uncaptured* errors -- everything that reaches -/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s -/// own error scopes, i.e. every wgpu error raised outside device/pipeline -/// creation: a validation failure during an ordinary frame's `update`/ -/// `draw`, for instance. wgpu's default handler for these is `panic!` with -/// no caller able to intervene -- exactly what aborted the P0 bench APK -/// once already (this file's `UiRenderNode::new` doc comment) -- so both -/// platform backends install a handler here instead of leaving the default -/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it -/// must never panic in release"). -/// -/// Cheap to `Clone` (an `Arc` around the real storage) rather than a -/// process-wide static, so a caller builds one alongside its `Device`, -/// hands one clone to `on_uncaptured_error`'s closure and keeps the other -/// for the Diagnostics page to read -- context passed explicitly, per -/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a -/// `OnceLock`. #[derive(Clone)] pub struct WgpuErrorLog { errors: std::sync::Arc>>, } -/// How many uncaptured errors the log keeps -- old ones drop off the front -/// rather than being trimmed on read, so a build spraying errors every -/// frame doesn't grow this without bound. const WGPU_ERROR_LOG_CAP: usize = 20; impl Default for WgpuErrorLog { @@ -131,9 +77,6 @@ impl WgpuErrorLog { pub struct UiRenderNode { uniform_group: BindGroup, primitive_layout: BindGroupLayout, - /// Group 1: `rects` and `glyphs`. Global and bound once per frame, - /// not per layer -- a mask referencing a rect drawn in another layer - /// has to be able to read it (see `Primitives`). primitives: PrimitiveBuffers, primitive_group: BindGroup, rsc_layout: BindGroupLayout, @@ -145,28 +88,13 @@ pub struct UiRenderNode { active: Vec, window_buffer: Buffer, textures: GpuTextures, - /// Every primitive's placement, read by the vertex stage for the - /// primitive being drawn and by the fragment stage for a mask's. instances: ArrBuf, masks: ArrBuf, move_offsets: ArrBuf, - /// Group 3: the masks and move-offsets storage buffers, on their own -- - /// see IRIS_TODO.md's "Appending one image ... rebuilds every other - /// image's bind group". These used to live in group 2 alongside each - /// standalone image's own texture view, so an image's bind group named - /// the masks/move_offsets buffer directly; the moment either buffer - /// resized (which a widget getting its *first* move slot can trigger, - /// unrelated to any image), `ArrBuf::update` handed back a new `Buffer` - /// identity and every image's bind group -- one per live image -- had - /// to be rebuilt to reference it. Pulling both buffers into their own - /// group, bound once per frame rather than once per draw call, means a - /// buffer resize now rebuilds exactly this one group instead of N. masks_layout: BindGroupLayout, masks_group: BindGroup, } -/// One layer's vertex buffers: the slots it draws, in order. The -/// primitives themselves are in `UiRenderNode::instances`. struct RenderLayer { order: ArrBuf, /// A standalone image's slots, kept apart from `order` because each @@ -182,14 +110,7 @@ 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, &[]); - // Group 1 is global now, so it is set here rather than per layer. pass.set_bind_group(1, &self.primitive_group, &[]); - // Set once, not per layer or per image: masks/move_offsets are read - // by every primitive and every standalone image alike, and living - // in their own group (rather than folded into group 2 alongside the - // per-image texture view) is what keeps an image's own bind group - // from naming a buffer that changes size on an unrelated widget's - // first draw -- see the comment on `masks_group` below. pass.set_bind_group(3, &self.masks_group, &[]); for i in &self.active { let layer = &self.layers[i]; @@ -307,31 +228,12 @@ impl UiRenderNode { queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice)); } - /// Builds every bind group layout, the pipeline, and the two storage - /// buffers this needs -- fallibly, since this is exactly the call that - /// aborted the process on Iris's phone in a release build with no - /// message beyond "wgpu error: Validation Error" (RUST.md's P0 box, - /// "iris bench crash on the phone, 2026-09-06"). wgpu's own default - /// behaviour for an uncaptured error is `panic!` with no caller able to - /// intervene, so every `create_bind_group_layout`/`create_render_pipeline` - /// call below runs inside three nested error scopes (one per - /// `ErrorFilter`) instead: whichever scope catches something, its - /// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output - /// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic - /// would have printed before Android's crash reporter truncated it) and - /// becomes this function's `Err`. Both callers - /// (`android::render::AndroidRenderer::new`, `default::render:: - /// UiRenderer::new`) already call `Device`-creation with - /// `pollster::block_on`, so returning a plain `Result` here rather than - /// making this `async fn` keeps that same synchronous shape. pub fn new( device: &Device, queue: &Queue, config: &SurfaceConfiguration, window_size: impl Into, ) -> Result { - // Popped in reverse of this order, once every creation call below - // has run -- `Device::push_error_scope`'s own contract. let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory); let validation_scope = device.push_error_scope(ErrorFilter::Validation); let internal_scope = device.push_error_scope(ErrorFilter::Internal); @@ -341,29 +243,6 @@ impl UiRenderNode { source: ShaderSource::Wgsl(SHAPE_SHADER.into()), }); - // Seeded from the caller's own reported size, not - // `WindowUniform::default()` (0, 0): the vertex shader divides by - // `window.dim` to reach clip space, so a window this buffer - // disagrees with means every primitive's position is NaN/Inf and is - // dropped before rasterization -- the clear colour still reaches - // the screen (the pass runs regardless) while nothing drawn on top - // of it ever does. winit's backend gets away with the old default - // because winit fires an initial `WindowEvent::Resized` that calls - // `resize()` before the first frame; android-view has no such - // automatic event, so `AndroidRenderer::new` built a node whose - // window buffer was never corrected -- this is I2's "nothing draws" - // bug (RUST.md). - // - // **Deliberately not `config.width`/`config.height`**: those are - // the surface's *physical* pixel size, which the swapchain needs, - // but everything downstream of this uniform (layout, hit-testing, - // glyph/rect positions) works in the caller's own units -- on - // Android that's *logical* (physical / density) since RUST.md's P0 - // box ("text is far too small"), on desktop it's whatever - // `default::render::UiRenderer::new` already divides by - // `window.scale_factor()`. Passing it in explicitly, rather than - // deriving it from `config` here, is what keeps this crate from - // needing to know either platform's notion of density at all. let window_uniform = { let size = window_size.into(); WindowUniform { @@ -610,12 +489,6 @@ impl UiRenderNode { }) } - /// Group 3: the masks and move_offsets storage buffers, shared by the - /// main draw and every standalone image alike (see the field comment on - /// `masks_group`). Bound once per frame in `draw()` rather than folded - /// into group 2, so a resize of either buffer -- which an unrelated - /// widget's first move slot can trigger -- rebuilds this one group - /// instead of every image's. fn masks_layout(device: &Device) -> BindGroupLayout { device.create_bind_group_layout(&BindGroupLayoutDescriptor { entries: &[ @@ -685,20 +558,10 @@ impl UiRenderNode { self.textures.view_count() } - /// Standalone-image bind groups built since the last call -- see - /// `GpuTextures::take_bind_group_creates`. Call once per frame before - /// `update()` to measure exactly that frame. pub fn take_image_bind_group_creates(&mut self) -> u64 { self.textures.take_bind_group_creates() } - /// Atlas-array `grow_array` calls since the last call -- same calling - /// convention as `take_image_bind_group_creates` (call once per frame, - /// before `update()`, to read exactly the previous frame's tally). Part - /// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the - /// first input frame" investigation): if a report ever shows a grow - /// landing on the same frame the glyphs vanished, that is the - /// coincidence to chase first. pub fn take_atlas_pages_grown(&mut self) -> u64 { self.textures.take_pages_grown() } diff --git a/core/src/render/primitive.rs b/core/src/render/primitive.rs index ebe66f4..daf2185 100644 --- a/core/src/render/primitive.rs +++ b/core/src/render/primitive.rs @@ -21,9 +21,6 @@ pub const IMAGE_BINDING: u32 = 1; pub trait Primitive: Pod { const BINDING: u32; fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec; - /// The read-only half of [`Self::vec`], for a caller that wants to - /// look one entry up rather than write one -- a mask reading the - /// radius of the rect it clips to ([`Primitives::data`]). fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec; } @@ -63,13 +60,6 @@ macro_rules! primitives { impl PrimitiveBuffers { pub const LEN: usize = primitives!(@count $($name)*); - /// The group-1 binding number each primitive's storage buffer - /// sits at, in declaration order. Not `0..LEN`: a primitive's - /// `BINDING` also tags its instances for the shader's dispatch - /// switch, and a removed primitive (as `TEXTURE` was, once - /// images stopped needing a per-instance storage entry) can - /// leave a gap, so the pipeline layout has to ask for these - /// exact numbers rather than assuming they are contiguous. pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*]; pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] { [ @@ -126,59 +116,14 @@ macro_rules! primitives { (@count $t:tt) => { 1 }; } -/// Every primitive instance in the tree, in one arena that all layers -/// share, plus the per-primitive data (`rects`, `glyphs`) they index. -/// -/// **Why one arena rather than one per layer**, which is what this was: -/// the fragment stage evaluates a *mask's* primitive at the masked pixel -/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is -/// routinely in a different layer from the content it clips -- a rounded -/// container in one layer, a `Stack`'s child content in the layer below. -/// A per-layer buffer cannot answer that lookup at all: only one layer's -/// group is bound at a time, so the mask would silently read another -/// layer's rect. Both buffers are therefore global and bound once per -/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]). -/// -/// Slots are stable for a primitive's whole life: nothing here is -/// compacted, so a `Mask` can hold a slot across frames. pub struct Primitives { instances: Vec, - /// The value a slot held before its first rewrite since the last - /// upload. Layout may place a widget provisionally and restore it in - /// the same frame; remembering the pre-frame value lets `set_instance` - /// clear that dirty bit instead of uploading a change the GPU never - /// needs to observe. Entries are overwritten on the next clean-to-dirty - /// transition, so no separate end-of-frame sweep is needed. original_instances: Vec>, assoc: Vec, - /// Where each slot's [`PrimitiveHandle`] sits in its owner's - /// `ActiveData::primitives` -- the index that makes - /// `UiRenderState::apply_free` O(1) per renumbered primitive instead - /// of a scan of everything the owner drew. Written by - /// [`Self::set_handle_index`] from the one place a handle is taken - /// into that vec (`Painter::own`), and dead alongside its `assoc` - /// entry, which is what keeps the two in step. - /// - /// Without it a text widget that is freed and redrawn in one frame - /// costs O(glyphs^2): every one of its glyphs is renumbered, and each - /// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a - /// 51,200-glyph block on this machine, against 20ms for the shaping - /// and rasterising of the same text. handle_idx: Vec, - /// Slots freed since the last [`Self::apply_free`]. Deliberately not - /// reusable yet: the layer that drew one still names it in its draw - /// order until that call compacts the order, so handing it out again - /// first would draw the new primitive twice -- once through the stale - /// order entry and once through the new one. freed: Vec, - /// Slots [`Self::apply_free`] released, which is what [`Self::alloc`] - /// hands out. reusable: Vec, data: PrimitiveData, - /// Which instance slots have changed since the last upload. Was a - /// single `bool` covering the instances **and** the per-primitive - /// data until 2026-09-09, so rewriting one rect's region re-uploaded - /// every glyph as well; each array carries its own now. pub dirty: Dirty, } @@ -198,9 +143,6 @@ impl Default for Primitives { } impl Primitives { - /// A slot whose handle has not been recorded yet -- see - /// [`Self::handle_idx`]. No owner draws four billion primitives, so - /// the sentinel cannot collide with a real index. const NO_HANDLE: u32 = u32::MAX; /// Writes a primitive into the arena and hands back its slot and its @@ -232,10 +174,6 @@ impl Primitives { (slot, data_idx) } - /// A standalone image, which has no `PrimitiveData` entry to allocate - /// -- its bind group already picks the texture, so `texture_idx` rides - /// in the otherwise-unused `idx` field and names the bind group the - /// draw call selects. pub fn alloc_image( &mut self, id: WidgetId, @@ -312,9 +250,6 @@ impl Primitives { ); } - /// The image half of [`Self::recycle`] -- no `PrimitiveData` entry, so - /// `texture_idx` rides in `idx` exactly as [`Self::alloc_image`] puts - /// it there. pub fn recycle_image( &mut self, h: &PrimitiveHandle, @@ -378,23 +313,14 @@ impl Primitives { self.instances[slot].mask_idx } - /// Hands this frame's freed slots back for reuse. Called once per - /// frame from `UiRenderState::update`, **after** every layer has - /// compacted its draw order, since that order is the only thing still - /// naming them. pub fn release_freed(&mut self) { self.reusable.append(&mut self.freed); } - /// Which widget drew the primitive in `slot` -- how a draw-order - /// change finds the handle it has to renumber. pub fn owner(&self, slot: u32) -> WidgetId { self.assoc[slot as usize] } - /// Records that `slot`'s handle is `idx` entries into its owner's - /// `ActiveData::primitives`. Called once per primitive, by the one - /// place that puts a handle into that vec. pub fn set_handle_index(&mut self, slot: u32, idx: u32) { self.handle_idx[slot as usize] = idx; } @@ -420,18 +346,10 @@ impl Primitives { self.data.clear(); } - /// How many instances are still live -- the O(1) half of the orphan - /// check, so the O(primitives) walk below only runs on a frame that - /// already looks wrong. See - /// [`crate::UiRenderState::orphaned_primitives`]. pub fn live_count(&self) -> usize { self.instances.len() - self.freed.len() - self.reusable.len() } - /// Every live instance as `(slot, owner, is_image)` -- everything - /// except the freed and the reusable. Only - /// [`crate::UiRenderState::orphaned_primitives`] uses this, to check - /// that every live primitive still belongs to a live widget. pub fn live_instances(&self) -> impl Iterator + '_ { let dead: HashSet = self.freed.iter().chain(&self.reusable).copied().collect(); (0..self.instances.len()) @@ -453,8 +371,6 @@ impl Primitives { &self.instances } - /// The instance arena and its dirty set together -- see - /// [`PrimitiveVec::for_upload`]. pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) { (&self.instances, &mut self.dirty) } @@ -465,8 +381,6 @@ impl Primitives { &mut self.data } - /// Whether anything at all needs uploading -- the instances or any of - /// the per-primitive arrays. pub fn needs_upload(&self) -> bool { !self.dirty.is_clean() || self.data.needs_upload() } @@ -496,9 +410,6 @@ impl Primitives { } } -/// One layer's draw order: the slots of the global arena it draws, in the -/// order they were written. The vertex buffer of a layer is exactly this. -/// /// Both lists free with `swap_remove`, so a layer's draw order was already /// undefined before this split: nothing here may assume one primitive /// stays adjacent to another once anything in the layer has been freed. @@ -529,9 +440,6 @@ impl LayerOrder { list.len() - 1 } - /// Marks a position for removal. Deferred to [`Self::apply_free`] like - /// the arena's own, so that a position is only renumbered once per - /// frame however many were dropped. pub fn free(&mut self, pos: usize, is_image: bool) { self.updated = true; if is_image { @@ -541,8 +449,6 @@ impl LayerOrder { } } - /// Compacts both lists, answering every primitive whose position - /// moved so its handle can be corrected. pub fn apply_free(&mut self) -> Vec { let mut changes = Self::apply_free_list( &mut self.free, @@ -559,8 +465,6 @@ impl LayerOrder { changes } - /// The draw order and its dirty set together -- see - /// [`PrimitiveVec::for_upload`]. pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) { (&self.order, &mut self.order_dirty) } @@ -575,8 +479,6 @@ impl LayerOrder { dirty: &mut Dirty, is_image: bool, ) -> Vec { - // Descending, so removing a contiguous tail costs no renumbering - // at all -- which is what freeing one widget's primitives is. free.sort_by(|a, b| b.cmp(a)); free.drain(..) .filter_map(|pos| { @@ -584,9 +486,6 @@ impl LayerOrder { if pos == list.len() { return None; } - // `swap_remove` moved the tail entry here; nothing else in - // the list changed, which is why compacting an order is - // two dirty entries rather than the whole buffer. dirty.mark(pos); Some(OrderChange { slot: list[pos], @@ -606,14 +505,8 @@ impl LayerOrder { } } -/// A primitive whose position in a layer's draw order moved when -/// something before it was freed -- `slot` names which primitive, so its -/// owner's handle can be found and pointed at `pos`. pub struct OrderChange { pub slot: u32, - /// Which of the layer's two lists moved: their positions are - /// independent index spaces, so a handle matching on position alone - /// could take an image's renumbering for a rect's. pub is_image: bool, pub pos: usize, } @@ -628,13 +521,8 @@ pub enum Drawn { No, } -/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so -/// there is no position to renumber or free. pub const NOT_DRAWN: usize = usize::MAX; -/// Where one primitive lives: its stable slot in the global arena, and -/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is -/// only referenced). #[derive(Debug)] pub struct PrimitiveHandle { pub layer: usize, @@ -683,11 +571,6 @@ impl RectPrimitive { } } -/// One glyph, drawn as a sub-rectangle of the glyph atlas array. -/// -/// `color` is the text colour and is multiplied by the atlas's alpha for an -/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and -/// takes the atlas texel unchanged, which is what `IS_COLOR` selects. #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct GlyphPrimitive { @@ -699,10 +582,6 @@ pub struct GlyphPrimitive { pub layer: u32, pub color: Color, pub flags: u32, - /// Pads this struct's Rust size to match WGSL's storage-buffer layout for - /// `GlyphInfo`: two `vec2` members give the struct an 8-byte - /// alignment, which rounds the WGSL size up to 32 bytes even though the - /// fields above only total 28. `bytemuck` does not check this for us. _pad: u32, } @@ -759,20 +638,6 @@ impl PrimitiveVec { self.dirty.mark(i); i } - /// Overwrites an entry already allocated -- the recycle path - /// ([`Primitives::recycle`]) -- and marks it dirty **only if the - /// value actually differs**. - /// - /// That check is not an optimisation of the comparison; it is what - /// makes the dirty set mean "changed" rather than "written". A row - /// that moves, or is re-laid-out at a new width, rewrites every glyph - /// it owns with the same `uv`, `layer`, `colour` and `flags` -- what - /// moved is the *instance's* region, which is a different array. Over - /// the bench fixture's streamed reply the glyph array was being - /// marked at 73% per frame against 0.6% genuinely changed, a 122x - /// over-upload, entirely from this (`scripts/rigs/ui-profile`'s - /// `arena_churn`, which prints both numbers side by side so the gap - /// cannot reopen unnoticed). pub fn set(&mut self, i: usize, t: T) where T: Pod, @@ -845,8 +710,6 @@ mod tests { "the GPU never observes the provisional position" ); - // A subsequent frame takes its baseline from the value currently in - // the arena, rather than reusing the now-stale original above. primitives.set_instance(0, moved, owner); assert!(!primitives.dirty.is_clean()); primitives.dirty.clear(); diff --git a/core/src/render/sdf.rs b/core/src/render/sdf.rs index 64a11ce..fde8284 100644 --- a/core/src/render/sdf.rs +++ b/core/src/render/sdf.rs @@ -1,28 +1,7 @@ -//! The rounded-rect coverage function, on the CPU. -//! -//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a -//! transliteration of these two, line for line, and -//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two -//! at a grid of points against values the shader itself produced. They are -//! kept together here, in the crate both a renderer and a hit test can -//! reach, because LAYOUT.md's "Masks with a shape" turns on the two -//! agreeing: a masked corner that cannot be tapped and a masked corner -//! that is not drawn have to be the same corner, and they are only the -//! same corner while one function decides both. -//! -//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion` -//! units, which the shader has already resolved by the time it evaluates -//! this. - use crate::util::Vec2; -/// The signed distance from `pos` to a rounded rect given by its centre, -/// its corner offset (half its size) and its corner `radius`. Negative -/// inside. pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 { - // vec from center to pixel let p = pos - center; - // vec from inner rect corner to pixel let q = Vec2::new( p.x.abs() - (corner.x - radius), p.y.abs() - (corner.y - radius), @@ -31,12 +10,6 @@ pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> (clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius } -/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over -/// the half-pixel either side of its edge: 1 well inside, 0 well outside. -/// -/// The half-pixel feather is why a hit test asks for **more than a half** -/// rather than "any coverage at all": half is where the geometric edge is, -/// so the two answer the same question the drawn shape does. pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 { let edge: f32 = 0.5; let corner = (bot_right - top_left) / 2.0; diff --git a/core/src/render/shader.wgsl b/core/src/render/shader.wgsl index 334d920..57aa63a 100644 --- a/core/src/render/shader.wgsl +++ b/core/src/render/shader.wgsl @@ -1,7 +1,5 @@ const RECT: u32 = 0u; -// TEXTURE has no entry in group 1: a standalone image draws with its own -// bind group (see UiRenderNode::draw), so there is nothing per-instance left -// to look up here -- the bind group already picked the texture. +// Standalone images select their texture through their own bind group. const TEXTURE: u32 = 1u; const GLYPH: u32 = 2u; @@ -22,24 +20,19 @@ struct Rect { struct GlyphInfo { uv_min: vec2, uv_max: vec2, - // Layer of the shared atlas array texture, not a view or bind-group - // index -- a page never gets its own bind group. See TEXTURES.md's - // "Recommended shape". + // A layer in the shared atlas array, not a bind-group index. layer: u32, color: u32, flags: u32, } -/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage -/// clips this mask's subtree, and the mask it nests inside -/// (`4294967295u` at the top). +/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root. struct Mask { primitive: u32, parent: u32, } -/// One widget's cumulative on-screen translation and the slot of the -/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs. +/// Mirrors `MoveOffset` in data.rs. struct MoveOffset { delta: vec2, parent: u32, @@ -55,50 +48,27 @@ struct UiScalar { abs: f32, } -// The shared glyph atlas: every page is one layer. Growing it recreates this -// texture with headroom and copies the old layers across -- see -// GpuTextures::grow_array -- rather than the binding_array> -// this replaced, which needed VK_EXT_descriptor_indexing and does not survive -// a real share of Android GPUs (see TEXTURES.md). +// One array texture avoids descriptor indexing, which is not universal on Android. @group(2) @binding(0) var atlas: texture_2d_array; -// One standalone image's texture. The main draw (rects and glyphs) binds a -// 1x1 null texture here, since neither samples it; each image draw call -// binds its own -- see UiRenderNode::draw. +// 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; -// Their own group, bound once per frame rather than folded into group 2: see -// UiRenderNode::masks_layout for why an image's own bind group must not name -// either buffer. +// 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; -// Every primitive's placement, in one arena all layers share. The vertex -// stage reads the primitive it is drawing (its slot arrives as the only -// vertex attribute); the fragment stage reads a *mask's* primitive, which -// is generally a different one in a different layer. See LAYOUT.md's -// "Masks with a shape" and `Primitives` in primitive.rs. +// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape. @group(3) @binding(2) var instances: array; -// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in -// render_state.rs, which walks the identical chain on the CPU side for -// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot -// hang the GPU -- not a claim about how deep a real tree gets. It was 16 -// and that was too small: the transcript screen's composer field sits 17 -// slots below the root, measured 2026-09-07 on this checkout's emulator -// by tapping it (the CPU walk's own debug assert names the chain now). -// Past the bound both walks simply stop summing, so the widget draws and -// hit-tests short by whatever the outer slots held, with nothing on -// screen to say so. +// 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; -/// Sums the pixel delta along the parent chain starting at `idx`, shared by -/// the vertex stage (a primitive's own corners) and the fragment stage (its -/// mask's corners) so the walk is written once. See LAYOUT.md section 2b. fn resolve_move(idx: u32) -> vec2 { var total = vec2(0.0, 0.0); var i = idx; @@ -117,8 +87,7 @@ struct WindowUniform { dim: vec2, }; -/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to -/// draw there. `x`/`y` are the `UiRegion`'s two spans. +/// Mirrors `PrimitiveInstance` in data.rs. struct PrimitiveInstance { x: UiSpan, y: UiSpan, @@ -128,7 +97,6 @@ struct PrimitiveInstance { move_idx: u32, } -/// A layer's draw order: one slot into `instances` per instance drawn. struct InstanceInput { @location(0) slot: u32, } @@ -137,8 +105,7 @@ struct VertexOutput { @location(0) top_left: vec2, @location(1) bot_right: vec2, @location(2) uv: vec2, - // `flat` is the only interpolation an integer can have, and naga - // (wgpu 30) now requires saying so rather than inferring it. + // 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, @@ -152,10 +119,7 @@ struct Region { bot_right: vec2, } -/// One primitive's on-screen corners in window pixels. Written once and -/// used by both stages: the vertex stage for the primitive it is drawing, -/// the fragment stage for a mask's -- so the shape a mask clips to and the -/// shape that was drawn cannot be computed two different ways. +/// Shared by drawing and mask coverage so their geometry cannot diverge. struct Corners { top_left: vec2, bot_right: vec2, @@ -224,10 +188,7 @@ fn fs_main( color = vec4(1.0, 0.0, 1.0, 1.0); } } - // Every mask on the chain, not just the innermost: a widget that set - // its own mask inside another is clipped by both, and the coverages - // multiply -- so a pixel inside two feathered corners is dimmed by - // both, which is what a compositor does (`Mask::parent` in data.rs). + // 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 { @@ -240,17 +201,11 @@ fn fs_main( return color; } -/// How much of `pos` one mask lets through: the referenced primitive's -/// own coverage at that pixel, from the same SDF the primitive is drawn -/// with. Nothing about the shape is copied into the mask, so a rounded -/// container's corner and its children's clipped corner are the same -/// arithmetic. +/// 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 { - // Unreachable: `Painter::set_mask` rejects a glyph or an image - // shape by name (see `Mask::primitive`). Letting the pixel - // through rather than reading a `rects` entry that is not there. + // Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks. return 1.0; } let c = corners_of(inst); @@ -272,11 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4 { return color; } -/// The anti-aliased coverage of a rounded rect at one pixel -- the one -/// function both a drawn rect and a mask go through, and the -/// transliteration of `iris_core::rounded_rect_coverage` on the CPU, -/// which the hit test uses so a corner that cannot be tapped and a corner -/// that is not drawn are the same corner. +/// Keep synchronized with the CPU hit-test implementation in render::sdf. fn rounded_rect_coverage( pos: vec2, top_left: vec2, @@ -309,10 +260,7 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4 { } fn distance_from_rect(pixel_pos: vec2, rect_center: vec2, rect_corner: vec2, radius: f32) -> f32 { - // vec from center to pixel let p = pixel_pos - rect_center; - // vec from inner rect corner to pixel let q = abs(p) - (rect_corner - radius); return length(max(q, vec2(0.0))) - radius; } - diff --git a/core/src/render/texture.rs b/core/src/render/texture.rs index 9fac2e4..7730ebf 100644 --- a/core/src/render/texture.rs +++ b/core/src/render/texture.rs @@ -9,12 +9,7 @@ use super::atlas::PAGE; /// one, for the GLES reason written on `create_array_texture`. const MIN_ARRAY_LAYERS: u32 = 2; -/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot -/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the -/// same thing on both sides without a second map to keep in sync. enum Slot { - /// A slot that was freed, or pushed and freed within the same batch - /// before ever reaching here. Empty, Image(ImageGpu), /// The array layer a page occupies. Pages are never freed (see @@ -23,16 +18,12 @@ enum Slot { } struct ImageGpu { - /// Kept alive alongside `view`/`bind_group`, which borrow from it only in - /// the sense that dropping this drops the GPU resource they point to. #[allow(dead_code)] texture: Texture, view: TextureView, bind_group: BindGroup, } -/// Owns the two kinds of texture iris draws: -/// /// - **The glyph atlas**, one `texture_2d_array` whose layers are pages /// (`Slot::Page`), grown by recreating the array with headroom and /// `copy_texture_to_texture`-ing the old layers across. No feature beyond @@ -41,11 +32,6 @@ struct ImageGpu { /// - **Standalone images** (`Slot::Image`), each its own `Texture` and /// `BindGroup`, drawn one `draw()` call at a time with that bind group /// bound -- see `UiRenderNode::draw`. -/// -/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's -/// "iris's binding array does not survive real Android hardware" for what -/// this replaced (one giant `binding_array>` needing -/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack). pub struct GpuTextures { device: Device, queue: Queue, @@ -55,7 +41,6 @@ pub struct GpuTextures { array_texture: Texture, array_view: TextureView, array_capacity: u32, - /// Layers actually written. Only grows -- see `Slot::Page`. page_count: u32, sampler: Sampler, @@ -64,18 +49,7 @@ pub struct GpuTextures { /// but the layout requires something bound regardless. null_view: TextureView, - /// Standalone-image bind groups actually built (`create_image`'s own - /// build, or one per slot touched by `rebuild_image_bind_groups`) since - /// the last `take_bind_group_creates`. IRIS_TODO.md's "many images" - /// benchmark reads this to prove the steady-state cost of an - /// unchanging image list is zero, the same way `UiRenderState`'s - /// `draw_count`/`region_mut_count` prove the layout side. bind_group_creates: u64, - /// `grow_array` calls since the last `take_pages_grown` -- the - /// Diagnostics page's per-frame report (RUST.md's P0 box, "the first - /// input frame" investigation) reads this alongside `bind_group_creates` - /// to say whether *this* frame's glyph disappearance, if any, coincided - /// with the atlas array being recreated. pages_grown: u64, } @@ -161,7 +135,6 @@ impl GpuTextures { if let Some(slot) = self.slots.get_mut(i as usize) { *slot = Slot::Empty; } - // A page's layer is not reclaimed here either -- see `Slot::Page`. } fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) { @@ -171,9 +144,6 @@ impl GpuTextures { if rect.width == 0 || rect.height == 0 { return; } - // Cropped rather than written straight from the atlas, because - // write_texture wants tightly packed rows and the atlas rows are as - // wide as the atlas. A glyph is small, so the copy is too. let sub = image .view(rect.x, rect.y, rect.width, rect.height) .to_image(); @@ -231,10 +201,6 @@ impl GpuTextures { ); } - /// Doubles the array's layer capacity (headroom, so this is rare) and - /// copies the old layers across GPU-side -- no readback. Recreates the - /// array's view, which invalidates every bind group that referenced it, - /// so this also rebuilds all of them before returning. fn grow_array(&mut self, rsc_layout: &BindGroupLayout) { self.pages_grown += 1; let new_capacity = self.array_capacity * 2; @@ -275,10 +241,6 @@ impl GpuTextures { self.rebuild_image_bind_groups(rsc_layout); } - /// Called only from `grow_array`: the atlas array's view identity is the - /// one thing an image's bind group (group 2) still names that can - /// change out from under it. Masks/move_offsets resizing no longer - /// reaches here at all -- see `UiRenderNode::masks_group`. fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) { for slot in &mut self.slots { if let Slot::Image(gpu) = slot { @@ -332,11 +294,6 @@ impl GpuTextures { } } - /// Builds group 2 for one standalone image: the shared atlas array, this - /// image's own view and the shared sampler -- the same layout the main - /// draw uses with a null view in the image slot. Deliberately does not - /// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see - /// that field's comment for why folding them in here was the bug. fn make_image_bind_group( device: &Device, rsc_layout: &BindGroupLayout, @@ -364,20 +321,6 @@ impl GpuTextures { }) } - /// The atlas is sampled as a `texture_2d_array`, and **a one-layer - /// array is not one on the GLES backend**: wgpu-hal picks the GL - /// texture target from the descriptor alone - /// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`), - /// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the - /// shader's `sampler2DArray`. GL then treats that unit as incomplete - /// and every `textureSample` returns (0, 0, 0, 1) -- which, through - /// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid - /// filled box. That was iris's appearance on the emulator's GLES for - /// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and - /// it is a real defect on any device whose adapter is GL rather than - /// Vulkan, not an emulator artifact. So the array never has fewer than - /// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of - /// texture memory and is used by the next atlas page anyway. fn create_array_texture(device: &Device, capacity: u32) -> Texture { debug_assert!( capacity >= MIN_ARRAY_LAYERS, @@ -426,15 +369,10 @@ impl GpuTextures { } } - /// Reads and zeroes the standalone-image bind-group creation counter -- - /// call once per frame before `update()`, mirroring - /// `UiRenderState::take_counters`. pub fn take_bind_group_creates(&mut self) -> u64 { std::mem::take(&mut self.bind_group_creates) } - /// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s - /// field comment. pub fn take_pages_grown(&mut self) -> u64 { std::mem::take(&mut self.pages_grown) } diff --git a/core/src/render/util/mod.rs b/core/src/render/util/mod.rs index 18e289a..c87ed7f 100644 --- a/core/src/render/util/mod.rs +++ b/core/src/render/util/mod.rs @@ -4,8 +4,6 @@ use crate::util::Dirty; use bytemuck::Pod; use wgpu::*; -/// A GPU array whose `Buffer` outlives the data in it. -/// /// **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* @@ -13,23 +11,11 @@ use wgpu::*; /// 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. -/// -/// It reallocated on every length change until 2026-09-09, which made the -/// streaming path pay a full rewrite of every arena on nearly every -/// frame -- adding one glyph changes a length. Measured over the bench -/// fixture's 401 streamed deltas (`scripts/rigs/ui-profile`'s -/// `arena_churn`): the glyph buffer's *changed* bytes were 3.0% of its -/// size, but 95% of it had to be re-uploaded anyway because the buffer -/// underneath had just been replaced. pub struct ArrBuf { label: &'static str, usage: BufferUsages, pub buffer: Buffer, - /// Entries the caller last wrote -- what a draw call reads. len: usize, - /// Entries the buffer has room for. Grows geometrically and never - /// shrinks, so a list that oscillates in length (every frame of a - /// fling adds and drops rows) settles on one allocation. capacity: usize, _pd: PhantomData, } @@ -52,11 +38,6 @@ impl ArrBuf { } } - /// Grows to hold `len` entries if it does not already, answering - /// whether that meant a new `Buffer`. Doubling rather than exact, so a - /// buffer that grows by one entry per frame -- which is what a - /// streamed reply does to the glyph arena -- reallocates a logarithmic - /// number of times rather than every frame. pub fn reserve(&mut self, device: &Device, len: usize) -> bool { if len <= self.capacity { return false; @@ -76,9 +57,6 @@ impl ArrBuf { usage: BufferUsages, label: &'static str, ) -> Buffer { - // A storage binding of size 0 is a validation error, and an empty - // arena is the ordinary state of a buffer nothing has drawn into - // yet. let size = (entries.max(1) * std::mem::size_of::()) as u64; device.create_buffer(&BufferDescriptor { label: Some(label), @@ -123,10 +101,6 @@ impl ArrBuf { reallocated } - /// How far apart two dirty runs may be and still be uploaded as one - /// -- in entries, so a wider entry merges across fewer of them and - /// the *byte* cost of merging is the same either way. See - /// [`Dirty::ranges`] for the measurement behind 1 KiB. const MERGE_GAP: usize = 1024 / std::mem::size_of::(); #[allow(clippy::len_without_is_empty)] diff --git a/core/src/ui/access.rs b/core/src/ui/access.rs index 485598f..c742d0e 100644 --- a/core/src/ui/access.rs +++ b/core/src/ui/access.rs @@ -1,23 +1,3 @@ -//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree, -//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s -//! through `accesskit_android::Adapter`, `default/mod.rs` through -//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry -//! is: `Widgets::named()` is a side set populated only by `.label()`, so a -//! widget nobody named is never visited here at all, not even to decide it -//! has no name. -//! -//! The tree itself is deliberately flat -- one synthetic `Role::Window` -//! root with every named widget as a direct child, in no particular order. -//! iris's actual widget nesting (a label three `Span`s deep inside a -//! `ScrollArea`) carries no accessibility meaning of its own here: nothing -//! upstream of a named leaf needs a node, since a screen reader's own -//! traversal (and uiautomator's tap-by-name, the pass condition this was -//! built for) works from each node's on-screen bounds rather than from -//! tree structure. Mirroring the real widget tree exactly would also mean -//! rebuilding intermediate nodes whenever *any* container above a named -//! widget resizes, which is most frames -- the flat shape is what keeps -//! rebuilds tied to "a name, a role or a position actually changed". - use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap}; use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate}; @@ -57,11 +37,6 @@ fn entry_node(entry: &Entry) -> Node { #[derive(Default)] pub struct AccessTree { known: HashMap, - /// `TreeUpdate`s actually produced since the last `take_rebuilds` -- - /// the AccessKit-tree twin of `UiRenderState::take_counters`. Should - /// stay at 0 across an unchanged frame and move by exactly 1 when a - /// named widget's position, name or role changes, however many other - /// widgets are on screen; see `iris/src/access_tests.rs`. rebuilds: u64, } @@ -128,8 +103,6 @@ impl AccessTree { build_update(&Self::collect(widgets, render, rsc)) } - /// Reads and zeroes the rebuild counter, the same call shape as - /// `UiRenderState::take_counters`. pub fn take_rebuilds(&mut self) -> u64 { std::mem::take(&mut self.rebuilds) } diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index d90d6c7..9704e78 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -2,7 +2,6 @@ use crate::{ LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2, }; -/// important non rendering data for retained drawing #[derive(Debug)] pub struct ActiveData { pub id: WidgetId, @@ -11,23 +10,13 @@ pub struct ActiveData { pub textures: Vec, pub primitives: Vec, pub children: Vec, - /// Direct children whose reported size this widget used during its - /// latest draw. Dirtiness propagates across these edges before layout - /// starts, so the resulting draw still travels only parent to child. pub size_dependencies: Vec, - /// The inherited mask, not `own_mask`. pub mask: MaskIdx, /// The widget's retained mask slot, or `MaskIdx::NONE`. pub own_mask: MaskIdx, pub layer: LayerId, - /// The size recorded by the last `Widget::draw` through its painter. pub size: Size, - /// Retained so descendants' parent links stay valid across redraws. pub move_slot: MoveIdx, - /// The optional coordinate boundary between this widget and its direct - /// children. Descendants retain links to it across redraws, just as they - /// do to `move_slot`. pub child_move_slot: Option, - /// The part of this widget's move delta already folded into `region`. pub move_applied: Vec2, } diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index ee5d340..2ad061a 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -18,20 +18,7 @@ pub struct UiData { pub textures: Textures, pub text: TextData, pub masks: TrackedArena, - /// One entry per widget ever drawn, plus optional child-coordinate - /// boundaries owned by containers. Together they form the parent-linked - /// chain `resolve_move` walks in both shader stages. A widget's ordinary - /// entry is allocated once on its first draw and reused for every later - /// redraw of the same id, so a retained descendant's `parent` index never - /// goes stale -- see LAYOUT.md section 2. pub move_offsets: TrackedArena, - /// Every widget whose [`crate::Widget::tick`] should run before the - /// next frame -- today, a `LazySpan` coasting through a fling. Added by - /// [`Self::animate`] when the animation starts and removed by - /// [`Self::tick_animations`] the frame its `tick` answers `false`, so - /// a stopped animation costs nothing and a dropped widget cannot be - /// ticked (`get_dyn_mut` answers `None` and it is dropped the same - /// way). animating: Vec, } @@ -46,15 +33,7 @@ impl UiData { } } - /// Tick every registered widget to `now`, drop the ones that finished, - /// and say whether any is still going -- which is a backend's cue to - /// ask for another frame. Called once per frame *before* the draw, so - /// what the frame draws is this instant's position rather than the - /// previous one's. pub fn tick_animations(&mut self, now: std::time::Instant) -> bool { - // Taken out and put back rather than iterated in place: `tick` - // needs `&mut` on the widget arena this list lives beside, and a - // widget is free to register another one while ticking. let mut registered = std::mem::take(&mut self.animating); registered.retain(|&id| match self.widgets.get_dyn_mut(id) { Some(widget) => widget.tick(now), diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 1aee453..d98b691 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -18,11 +18,9 @@ pub struct Painter<'a> { pub(super) mask: MaskIdx, pub(super) move_slot: MoveIdx, pub(super) child_move_slot: Option, - /// This widget's retained mask slot. pub(super) own_mask: MaskIdx, pub(super) textures: Vec, pub(super) primitives: Vec, - /// Previous handles, consumed in draw order and freed if left over. pub(super) recycle: std::iter::Peekable>, pub(super) children: Vec, pub(super) size_dependencies: Vec, @@ -37,16 +35,12 @@ pub struct Painter<'a> { pub(super) id: WidgetId, } -/// A child draw whose size has not necessarily been observed by its parent. -/// Holding this value keeps the painter borrowed, so `.size()` can only name -/// the child from the immediately preceding draw. pub struct DrawResult<'p, 'a> { painter: &'p mut Painter<'a>, child: WidgetId, } impl DrawResult<'_, '_> { - /// Return the child's reported size and record the layout dependency. pub fn size(self) -> Size { if !self.painter.size_dependencies.contains(&self.child) { self.painter.size_dependencies.push(self.child); @@ -56,8 +50,6 @@ impl DrawResult<'_, '_> { } impl<'a> Painter<'a> { - /// Record the size this widget used. Every `Widget::draw` calls this - /// exactly once; parents observe it through [`DrawResult::size`]. pub fn set_size(&mut self, size: Size) { assert!( self.size.replace(size).is_none(), @@ -69,10 +61,6 @@ impl<'a> Painter<'a> { self.write_primitive(primitive, region, Drawn::Yes); } - /// The next handle from the previous draw, if it can hold what is - /// about to be written: same kind of primitive, same layer, and the - /// same answer to "does a layer's draw order name it". - /// /// **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 @@ -90,8 +78,6 @@ impl<'a> Painter<'a> { self.recycle.next() } - /// The one path every primitive this widget owns goes through -- - /// drawn or, for a mask's shape, only referenced. fn write_primitive( &mut self, primitive: P, @@ -121,13 +107,6 @@ impl<'a> Painter<'a> { } /// Take ownership of a handle this widget just wrote. - /// - /// The one place a `PrimitiveHandle` enters `self.primitives`, and so - /// the one place that can keep `Primitives::handle_index` in step with - /// where it lands -- which is what `UiRenderState::apply_free` reads - /// instead of scanning this vec. Anything that writes a primitive - /// without coming through here leaves that index unset, and its - /// position in a layer's draw order stops being renumbered. fn own(&mut self, h: PrimitiveHandle) { self.state .primitives @@ -135,7 +114,6 @@ impl<'a> Painter<'a> { self.primitives.push(h); } - /// Writes a primitive to be rendered pub fn primitive(&mut self, primitive: P) { self.primitive_at(primitive, self.region) } @@ -144,18 +122,6 @@ impl<'a> Painter<'a> { self.primitive_at(primitive, region.within(&self.region)); } - /// Clip everything this widget draws, itself and its descendants, to - /// `region`. One call per widget; a widget drawn inside another - /// widget's mask nests instead -- the new mask chains to the inherited - /// one (`Mask::parent`) and the fragment stage multiplies both - /// coverages, which is what lets a transcript row's code fence clip - /// to itself *and* to the list it scrolls inside. - /// - /// The clip is a **primitive**, not a rectangle copied into the mask: - /// this writes an undrawn `RectPrimitive` at `region` and points the - /// mask at it, so the fragment stage evaluates the same rounded-rect - /// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape". - /// /// The slot is allocated once and **rewritten in place** on every /// later draw rather than pushed again, because a descendant whose own /// region did not change is not redrawn (`draw_inner`'s fast path) and @@ -184,24 +150,12 @@ impl<'a> Painter<'a> { self.set_mask_to(slot); } - /// Points this widget's mask at a primitive that has already been - /// written -- the shared half of [`Self::set_mask`]. fn set_mask_to(&mut self, shape: u32) { - // `assert!`, not `debug_assert!`: one comparison per widget draw, - // and the second call silently *replacing* the first is a widget - // drawn unclipped -- which reaches the screen and nothing says so. - // Every build anybody runs here is release - // (review, 2026-09-07). assert!( self.own_mask == MaskIdx::NONE || self.mask != self.own_mask, "set_mask called twice while drawing one widget: the second would replace the first \ rather than nest inside it", ); - // A glyph would need a CPU-side alpha plane for the hit test to - // agree with the shader, and a standalone image a bind-group - // switch the fragment stage cannot make -- see `Mask::primitive`. - // Named here rather than left to the shader, which would read a - // rect that is not there and clip to nothing. let binding = self.state.primitives.instance(shape).binding; assert_eq!( binding, @@ -215,9 +169,6 @@ impl<'a> Painter<'a> { }; let old_parent = if self.own_mask == MaskIdx::NONE { let slot = self.rsc.ui_mut().masks.push(mask); - // The one ref this widget holds on its own slot, so the slot - // outlives any single frame's primitives; released in - // `UiRenderState::remove`'s `undraw` branch. self.rsc.ui_mut().masks.push_ref(slot); self.own_mask = slot; MaskIdx::NONE @@ -226,10 +177,6 @@ impl<'a> Painter<'a> { *self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask; old }; - // The chain link's own ref, taken before the old one is dropped so - // that re-chaining to the same slot cannot free it in between. - // Released here when the link changes, and in - // `UiRenderState::remove` when this widget's slot goes. if old_parent != parent { if parent != MaskIdx::NONE { self.rsc.ui_mut().masks.push_ref(parent); @@ -241,14 +188,10 @@ impl<'a> Painter<'a> { self.mask = self.own_mask; } - /// Draw a widget within this widget's region. Reading the result's size - /// records that this widget's layout depends on the child. pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget) -> DrawResult<'p, 'a> { self.widget_at(id, self.region) } - /// Draws a widget somewhere within this one. - /// Useful for drawing child widgets in select areas. pub fn widget_within<'p, W: ?Sized>( &'p mut self, id: &StrongWidget, @@ -263,11 +206,6 @@ impl<'a> Painter<'a> { /// 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. - /// - /// This offsets the child coordinate space, not this widget: its own - /// primitives and hit region remain fixed. Once allocated, the boundary - /// stays in the chain across redraws; set it to zero to return children to - /// their unshifted positions. pub fn set_child_offset(&mut self, offset: Vec2) { let slot = match self.child_move_slot { Some(slot) => slot, @@ -322,13 +260,6 @@ impl<'a> Painter<'a> { region: UiRegion, ) -> DrawResult<'p, 'a> { self.children.push(id.id()); - // Passed directly rather than looked up from `self.active`: this - // widget's own `ActiveData` (which would carry its `move_slot`) is - // not inserted there until *after* its own `Widget::draw` returns, - // so a lookup here -- for a child drawn partway through that same - // call -- would always find nothing. `self.move_slot` is this - // widget's own slot, already known, and always correct regardless - // of insertion order. See `UiRenderState::move_parent_of`. let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot); self.state.draw_inner( self.layer, @@ -346,7 +277,6 @@ impl<'a> Painter<'a> { } } - /// Place an already-drawn child's used area, redrawing only if its size changes. pub fn place<'p, W: ?Sized>( &'p mut self, id: &StrongWidget, @@ -436,9 +366,6 @@ impl<'a> Painter<'a> { self.write_image(handle.image_index(), region); } - /// A standalone image draws with its own bind group rather than sharing - /// the layer's one instanced draw, so it goes through - /// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`. fn write_image(&mut self, texture_idx: u32, region: UiRegion) { let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) { Some(h) => { @@ -474,27 +401,16 @@ impl<'a> Painter<'a> { width: Option, ) -> RenderedText { let density = self.state.density; - // Counted here rather than in `TextView::render`, which returns - // its memoized layout without reaching this -- so this counts - // shapes, not requests. `UiRenderState::take_counters`. self.state.shape_count += 1; let ui = self.rsc.ui_mut(); ui.text .render(buffer, attrs, width, &mut ui.textures, density) } - /// Which glyph atlas the glyphs handed out right now belong to -- - /// what a widget caching a [`RenderedText`] across frames has to - /// compare against before re-emitting it (`GlyphAtlas::clear`). pub fn atlas_generation(&mut self) -> u64 { self.rsc.ui_mut().text.atlas.generation() } - /// Draw a laid-out string: one quad per glyph, all sampling the atlas. - /// - /// `origin` is where the text's top-left goes; every glyph is placed at an - /// absolute pixel offset from it, so re-drawing after a resize is this loop - /// and nothing else. pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) { // A caller re-emitting quads placed against an atlas that has since // been cleared draws every glyph from coordinates now holding diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index a0521a2..9d43b68 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -11,27 +11,16 @@ use crate::{ util::{HashMap, HashSet, Id, Vec2}, }; -/// What [`UiRenderState::update`] did on its last call -- read back by the -/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris` -/// crate) so a report can tell a full relayout from a frame that only -/// redrew a handful of dirty widgets from one that drew nothing at all. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RedrawKind { - /// Neither the root nor any widget changed -- `update` did nothing. None, - /// [`UiRenderState::redraw_all`]: a new root, or a resize. All, - /// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw` - /// named. Updates, } pub struct UiRenderState { pub active: HashMap, - /// Every primitive in the tree, in one arena -- see [`Primitives`] for - /// why it is not per layer. pub primitives: Primitives, - /// What each layer draws, in order: slots into `primitives`. pub layers: PrimitiveLayers, pub(super) output_size: Vec2, /// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an @@ -43,14 +32,6 @@ pub struct UiRenderState { old_root: Option, resized: bool, - /// The widgets whose `Widget::draw` is on the stack right now -- so - /// [`Self::redraw`] can tell "this widget needs drawing again" from - /// "an ancestor is drawing it at this very moment", where a second - /// draw would leave the first one's primitives behind with nothing - /// owning them. An id is inserted immediately before `draw` is called - /// and removed the moment it returns (both in `draw_inner`), so this - /// is empty between frames -- asserted at the end of `update`. - /// /// It used to only ever be inserted into, and `redraw` removed the id /// *before* testing for it, which made the test constant `false`: the /// guard could never fire and the set grew by one entry per widget @@ -65,14 +46,8 @@ pub struct UiRenderState { draw_count: u64, region_mut_count: u64, mov_count: u64, - /// Text layouts actually computed -- bumped by `Painter::render_text`, - /// which `TextView::render` only reaches on a cache miss. pub(super) shape_count: u64, - /// `Instant::now()` at construction -- the zero every `iris::frame` line - /// dates itself from, so a report's `now=` is comparable to a harness's - /// own `t_ms` (`Harness::new` builds its `base` the same way, in the - /// same constructor call) without either side needing the wall clock. epoch: Instant, /// How many times [`Self::update`] has run -- the `iris::frame` line's /// frame number. Counts every call, including one that found nothing to @@ -80,9 +55,6 @@ pub struct UiRenderState { /// was never asked to run at all (a stalled event loop), not one that /// ran and did nothing. frame_no: u64, - /// How long the redraw phase of the last [`Self::update`] took -- - /// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or - /// zero if neither did. Read back by `iris::diagnostics::log_frame`. last_layout: Duration, last_redraw_kind: RedrawKind, /// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris` @@ -94,7 +66,6 @@ pub struct UiRenderState { last_input_at: Mutex>, } -/// State retained while replacing one draw with another. pub(crate) struct Retained { pub region: Option, pub children: Vec, @@ -117,21 +88,6 @@ impl Default for Retained { } } -/// The bound on the parent walk -- see `resolve_move` in shader.wgsl, -/// which walks the identical chain and must be kept in step with this -/// constant. It exists so a cyclic `parent` link cannot hang either walk, -/// not as a statement about how deep a real tree gets: it was 16, and the -/// transcript screen's composer field turned out to sit **17** slots below -/// the root (measured 2026-09-07 on this checkout's emulator, by tapping -/// the composer in a debug build -- the assert in `resolve_move_chain` -/// prints the chain). A chain past the bound is not reported anywhere at -/// run time; both walks just stop summing, so the widget is drawn and hit -/// tested short by whatever the outer slots held. -/// -/// Named for the walk rather than for one of its two subjects: it bounds -/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in -/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first -/// (review, 2026-09-07). pub const PARENT_CHAIN_LIMIT: usize = 64; impl UiRenderState { @@ -157,15 +113,6 @@ impl UiRenderState { } } - /// Reads and zeroes the (draws, region_mut rewrites, move_offsets - /// writes, text shapes) counters -- call once per frame before - /// `update()` to measure exactly that frame, per LAYOUT.md section 8. - /// - /// The fourth is the one a draw count cannot stand in for: a widget - /// can be redrawn without re-shaping (`TextView::render` memoizes by - /// width) and re-shaped without any extra draw, and it is re-shaping - /// that the per-block transcript row exists to avoid -- see - /// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`. pub fn take_counters(&mut self) -> (u64, u64, u64, u64) { ( std::mem::take(&mut self.draw_count), @@ -179,8 +126,6 @@ impl UiRenderState { self.mov_count += 1; } - /// Writes a primitive into the arena and, unless it is - /// [`Drawn::No`], into `layer`'s draw order. pub(super) fn write_primitive( &mut self, layer: usize, @@ -201,8 +146,6 @@ impl UiRenderState { } } - /// A standalone image, which draws with its own bind group rather - /// than sharing the layer's one instanced draw. pub(super) fn write_image( &mut self, layer: usize, @@ -225,23 +168,9 @@ impl UiRenderState { } } - /// Compacts every layer's draw order around the primitives freed - /// this frame, corrects the handles that moved, and only then hands - /// the arena slots back for reuse -- that order is the whole reason - /// `Primitives::freed` exists. Once per frame, at the end of - /// [`Self::update`], so the harness (which has no renderer) applies - /// it exactly as a real backend does. fn apply_free(&mut self) { for (layer, order) in self.layers.iter_mut() { for change in order.apply_free() { - // Straight to the handle, never a scan of everything the - // owner drew: a widget freed and redrawn in one frame has - // *every* one of its primitives renumbered here, so a scan - // makes this pass quadratic in that widget's primitive - // count -- 1.37s for one 51,200-glyph text block, against - // 20ms to shape and rasterise the same text (measured - // 2026-09-08). `Primitives::handle_index` is written where - // the handle is taken, in `Painter::own`. let owner = self.primitives.owner(change.slot); let Some(idx) = self.primitives.handle_index(change.slot) else { continue; @@ -274,12 +203,6 @@ impl UiRenderState { /// different triggers (a surface resize on every rotation or keyboard /// open; a density change only if the app follows the display to a /// different screen, which Android surfaces separately). - /// - /// Marks the tree for a full redraw when the value actually changes: - /// every `Len::dp` already resolved and every glyph already shaped - /// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs - /// to the old one, and nothing else would ask for them again - /// (review, 2026-09-07). pub fn set_density(&mut self, density: f32) { if density != self.density { self.resized = true; @@ -334,30 +257,23 @@ impl UiRenderState { self.last_layout = layout_start.elapsed(); self.last_redraw_kind = kind; self.frame_no += 1; - // After the redraw and before anything reads the frame: every - // slot freed above is still named by its layer's draw order until - // this runs. self.apply_free(); #[cfg(debug_assertions)] debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),); } - /// `Instant::now()` at construction -- see the field's own doc. pub fn epoch(&self) -> Instant { self.epoch } - /// How many times [`Self::update`] has run, counting from 1. pub fn frame_number(&self) -> u64 { self.frame_no } - /// How long the last [`Self::update`]'s redraw phase took. pub fn last_layout_duration(&self) -> Duration { self.last_layout } - /// What the last [`Self::update`] did -- see [`RedrawKind`]. pub fn last_redraw_kind(&self) -> RedrawKind { self.last_redraw_kind } @@ -384,17 +300,7 @@ impl UiRenderState { at.map(|at| now.saturating_duration_since(at)) } - /// Primitive instances every currently-active widget owns, summed -- - /// what `iris::frame`'s `primitives=` reports. Not a per-frame delta: - /// `redraw_updates` only rewrites what changed, so this is "how much is - /// on screen", which is what a report reads as "did this frame have - /// more to draw than the last one", not "how much work did this frame - /// do" (`take_counters` answers that). - /// - /// A mask's shape does not count: it is a [`Drawn::No`] primitive - /// that is never rasterized, so including it would put one extra on - /// the line for every masked widget and make a number Iris reads off - /// a phone report disagree with what is drawn. + /// Excludes undrawn mask shapes so diagnostics match rasterized primitives. pub fn active_primitive_count(&self) -> usize { self.active .values() @@ -404,7 +310,6 @@ impl UiRenderState { fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) { self.clear(rsc); - // free all resources & cache if let Some(id) = root { self.draw_inner( 0, @@ -419,16 +324,6 @@ impl UiRenderState { } } - /// The slot an *already-active* widget's `move_offsets` entry chains - /// to, read back from `self.active`. Only valid where the parent is - /// guaranteed to already be in `self.active` -- true for `redraw()`, - /// which targets a widget that was fully drawn on some earlier update, - /// but **not** for a widget being drawn as part of its own parent's - /// `Widget::draw` call: that parent's `ActiveData` is not inserted - /// until its `draw` returns (below), so a child drawn partway through - /// it would always read back "no parent" here. `Painter::widget_at` - /// avoids that trap by passing its own already-known `move_slot` - /// straight through instead of asking `self.active` to look it up. fn move_parent_of(&self, parent: Option) -> u32 { parent .and_then(|p| self.active.get(&p)) @@ -535,9 +430,6 @@ impl UiRenderState { let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc); let inherited_mask = mask; - // `Painter::layer` is a cursor widgets advance while assigning - // layers to their children. Retain the layer this widget itself was - // entered on, not wherever that cursor finishes after `draw`. let inherited_layer = layer; let reuse_child_sizes = old_region.map_or([false; 2], |old| { [ @@ -616,16 +508,10 @@ impl UiRenderState { id, } = painter; - // Whatever the draw did not claim is genuinely gone: this draw - // wrote fewer primitives than the last one, or stopped matching - // part way. Freeing it here rather than in `remove` is what lets - // the draw in between reuse the slots -- see - // `Primitives::recycle`. for h in recycle { self.free_primitive(&h); } - // add to active let active = ActiveData { id, region, @@ -643,7 +529,6 @@ impl UiRenderState { move_applied: Vec2::ZERO, }; - // remove old children that weren't kept for c in &old_children { if !active.children.contains(c) { self.remove_rec(*c, rsc); @@ -655,9 +540,6 @@ impl UiRenderState { size } - /// This widget's slot in `move_offsets`: the one it already had if it - /// is being redrawn, or a fresh one linked to its parent's. - /// /// 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 @@ -687,11 +569,6 @@ impl UiRenderState { } } - /// O(1): write the delta for this widget's own slot in - /// `move_offsets`. No primitive is touched and there is no recursion -- - /// every descendant's primitive references this slot transitively - /// through the parent chain the shader walks (`resolve_move`), so it - /// picks the new delta up for free. See LAYOUT.md section 2. fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) { let Some(active) = self.active.get_mut(&id) else { return; @@ -772,22 +649,11 @@ impl UiRenderState { Some(size) } - /// Retires `id`'s primitives (unless `keep_primitives`, in which case - /// they come back in the returned `ActiveData` for the redraw about to - /// happen to recycle -- see `Painter::take_recycled`), drops the mask - /// refs they held, and takes the widget out of `active`. - /// /// 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. - /// - /// The mask refs are dropped either way: a recycled slot is rewritten - /// with whatever mask the *new* draw is under, and that draw takes its - /// own ref (`Painter::write_primitive`). - /// - /// NOTE: instance textures are cleared and self.textures freed fn remove( &mut self, id: WidgetId, @@ -862,11 +728,6 @@ impl UiRenderState { active } - /// Retires one primitive: its arena slot and, if a layer's draw order - /// names it, its position there. The two go together -- a slot handed - /// out again while its old order entry still names it would be drawn - /// twice -- which is why this is one function rather than two lines - /// repeated at each call site. fn free_primitive(&mut self, h: &PrimitiveHandle) { self.primitives.free(h); if h.pos != NOT_DRAWN { @@ -874,26 +735,6 @@ impl UiRenderState { } } - /// A mask whose shape primitive was just freed clips to a slot that - /// now holds something else, so the widget that owns it is marked for - /// redraw -- its own `set_mask` is the only thing that resolves the - /// slot, and it is the same mechanism a dirty widget already goes - /// through. - /// - /// `own` is the mask belonging to the widget being removed and is - /// skipped: this runs in the middle of that widget's own redraw, - /// which sets its mask again on the way out, and a mark left on - /// itself would redraw it every frame from then on. Skipping it is - /// also what keeps the O(active) scan off the ordinary path -- a - /// plain `.masked()` frees exactly its own shape, so `stale` is empty - /// and this returns before touching `active`. - /// - /// Both `Vec`s start empty and stay unallocated in that case, and - /// membership is a linear scan of two lists that are a handful long - /// (a widget's own primitives, and the live masks): this runs once - /// per widget removed, which is once per dirty widget per frame, and - /// a set built there would be an allocation on the phone's frame - /// path in exchange for nothing at these sizes. fn remask_shape_users( active: &HashMap, id: WidgetId, @@ -944,15 +785,9 @@ impl UiRenderState { pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) { while rsc.widgets().has_updates() { - // Expand size dependencies before drawing anything. The parent - // links are the retained widget tree already used by hit testing - // and removal; only the direct-child dependency list is new. let pending: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect(); for mut child in pending { for _ in 0..PARENT_CHAIN_LIMIT { - // An exact hint is the child's current answer without a - // draw. If both axes still match the retained size, no - // parent can observe a size change from this mutation. if self.size_matches_hints(child, rsc) { break; } @@ -972,8 +807,6 @@ impl UiRenderState { } } - // A dirty ancestor draws its dirty descendants on the way down; - // starting those descendants separately would duplicate work. let dirty: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect(); let mut roots = Vec::new(); for id in dirty { @@ -1041,20 +874,6 @@ impl UiRenderState { self.active.len() } - /// Primitive instances still bound for the GPU whose owner is no - /// longer in `active`, or whose owner's `ActiveData` no longer names - /// them: a copy nothing can move, clip, resize or free, redrawn every - /// frame at whatever position it last had. `(slot, owner)` each -- - /// the arena knows which primitive, not which layer's draw order still - /// names it. - /// - /// Asserted empty at the end of every [`Self::update`], because this - /// is exactly the shape of the duplicated transcript row on Iris's - /// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting - /// `active` alone cannot see it, since the orphan's owner is very - /// much alive -- it is the *earlier* set of primitives that got - /// stranded when the widget was drawn a second time without the first - /// draw being freed. O(primitives), debug builds only. pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> { let mut orphans = Vec::new(); for (slot, owner, _) in self.primitives.live_instances() { @@ -1069,13 +888,6 @@ impl UiRenderState { orphans } - /// Whether every primitive still bound for the GPU is owned by a live - /// widget, decided by counting rather than by walking: an orphan is a - /// live instance no `ActiveData` names, so it can only ever make the - /// live count exceed the owned one. O(active widgets) -- a few dozen -- - /// against [`Self::orphaned_primitives`]'s O(primitives), which on a - /// transcript is tens of thousands and made a debug build on a phone - /// too slow to finish a benchmark run. #[cfg(debug_assertions)] fn primitive_counts_agree(&self) -> bool { let live: usize = self.primitives.live_count(); @@ -1083,9 +895,6 @@ impl UiRenderState { live == owned } - /// The message [`Self::update`]'s orphan assert prints -- built here - /// rather than inline so the (allocating, O(primitives)) work only - /// happens on the failing path. #[cfg(debug_assertions)] fn orphan_report(&self, rsc: &dyn UiRsc) -> String { let orphans = self.orphaned_primitives(); @@ -1130,26 +939,12 @@ impl UiRenderState { } } - /// `active[id].region`, corrected by every `move_offsets` delta between - /// `id` and the root -- the CPU-side twin of the vertex shader's chain - /// walk, over the same arena, so the two cannot disagree about where a - /// widget is. O(chain depth), not O(primitives). See LAYOUT.md - /// section 2b. pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option { let active = self.active.get(&id.id())?; - // The chain sum is what the shader adds to this widget's - // *primitives*, which were written before any of those moves. - // `region`, unlike them, has already been shifted by whatever - // part of this widget's own slot `mov` put there -- see - // `ActiveData::move_applied`, which is exactly that part. let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied; Some(active.region.offset(UiVec2::abs(delta))) } - /// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the - /// pixel delta along the parent chain starting at `slot`. Both walks - /// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree - /// about where the chain ends. fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 { let offsets = &rsc.ui().move_offsets; let mut delta = Vec2::ZERO; @@ -1162,10 +957,6 @@ impl UiRenderState { return delta; } at = Id::preset(entry.parent); - // The chain itself, not just the fact that it was too long: a - // cycle and a tree genuinely nested deeper than the shader can - // follow are different faults with different fixes, and the - // slot numbers are the only thing that tells them apart. debug_assert!( i + 1 < PARENT_CHAIN_LIMIT, "move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \ @@ -1178,10 +969,6 @@ impl UiRenderState { delta } - /// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice - /// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number - /// rather than as a chain that merely stops. Only ever called from the - /// failed assertion above. fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String { let mut parts = Vec::new(); let mut at = slot; @@ -1201,14 +988,6 @@ impl UiRenderState { parts.join(" -> ") } - /// One primitive's corners in window pixels -- the transliteration of - /// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is - /// the whole reason this is not `region.to_px()`: the shader floors - /// each half separately before adding the move delta, and a hit test - /// that skipped it would disagree with the pixels by up to one along - /// each edge -- invisible in every test written against a whole-pixel - /// layout and wrong on the phone, whose 2.55 density makes nothing - /// land on a whole pixel. pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion { let inst = self.primitives.instance(slot); let delta = self.resolve_move_chain(inst.move_idx, rsc); @@ -1220,27 +999,10 @@ impl UiRenderState { } } - /// Where a mask's clip actually is on screen: the box of the - /// primitive it references. Its *shape* within that box is - /// [`Self::mask_coverage`]'s -- this is the bounding box, which is - /// what a test asking "is the clip over the right part of the screen" - /// wants and all a square-cornered mask has ever had. pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion { self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc) } - /// How much of the pixel at `pos` (window pixels) survives `mask` and - /// every mask it nests inside: the referenced primitives' own - /// coverage, multiplied along the chain. The CPU half of - /// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same - /// `rounded_rect_coverage` -- so a corner that cannot be tapped and a - /// corner that is not drawn are the same corner (LAYOUT.md's "Masks - /// with a shape", point 4). - /// - /// A mask whose shape is not a rect covers everything, exactly as the - /// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects - /// those by name, so this is the unreachable half of the same - /// agreement rather than a second policy. pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 { let mut coverage = 1.0; let mut at = mask; @@ -1264,18 +1026,10 @@ impl UiRenderState { coverage } - /// Whether `pos` is inside `mask` at all -- more than half covered, - /// which is where the drawn edge is (`rounded_rect_coverage`'s doc). - /// What a hit test asks. pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool { self.mask_coverage(mask, pos, rsc) > 0.5 } - /// The first primitive `id`'s subtree wrote this frame, depth first - /// in draw order -- what a mask pointed at a widget clips to - /// (`Painter::set_mask_to_widget`). A widget that draws more than one - /// (a bordered rect is one primitive; a card with a stripe is two) - /// gives its first; a widget that wants another names it. pub fn first_primitive(&self, id: WidgetId) -> Option { let active = self.active.get(&id)?; if let Some(h) = active.primitives.first() { @@ -1292,13 +1046,8 @@ impl UiRenderState { Some(region.to_px(self.output_size)) } - /// redraws a widget that's currently active (drawn) pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { rsc.widgets_mut().needs_redraw.remove(&id); - // An ancestor is drawing this widget right now, and that draw is - // about to write fresh primitives for it. Drawing it a second time - // here would leave one of the two copies on screen with nothing - // owning it -- see `draw_started`'s own doc. if self.draw_started.contains(&id) { return; } diff --git a/core/src/util/arena.rs b/core/src/util/arena.rs index 4a9dd6b..76f1751 100644 --- a/core/src/util/arena.rs +++ b/core/src/util/arena.rs @@ -45,8 +45,6 @@ impl Default for Arena { pub struct TrackedArena { inner: Arena, refs: Vec, - /// Which entries changed since the last upload. Was a `bool`, so one - /// widget getting a move offset re-uploaded every other widget's. pub dirty: Dirty, } @@ -73,17 +71,11 @@ impl TrackedArena { self.refs[i.idx()] += 1; } - /// Mutable access to an existing entry, for the rare case (the move - /// offset chain) where an already-allocated slot is updated in place - /// rather than replaced. Marks the arena changed so the GPU copy is - /// re-uploaded. pub fn get_mut(&mut self, id: Id) -> &mut T { self.dirty.mark(id.idx()); &mut self.inner.data[id.idx()] } - /// The entries and the dirty set together -- see - /// `PrimitiveVec::for_upload`. pub fn for_upload(&mut self) -> (&[T], &mut Dirty) { (&self.inner.data, &mut self.dirty) } diff --git a/core/src/util/dirty.rs b/core/src/util/dirty.rs index 54150dd..1beda01 100644 --- a/core/src/util/dirty.rs +++ b/core/src/util/dirty.rs @@ -1,19 +1,5 @@ -//! Which entries of a GPU-bound array changed since the last upload. - use std::ops::Range; -/// A bitset of dirty entries, coalesced into a handful of ranges when it -/// is time to upload. -/// -/// **Why a bitset** rather than the two obvious alternatives, both of -/// which were measured against the bench fixture before this was written -/// (`scripts/rigs/ui-profile`'s `arena_churn`). A `min..max` span is far -/// too coarse: a frame's changes land in 5-20 runs scattered across the -/// whole arena, so the span is very nearly the whole buffer. A `Vec` of -/// touched indices is too expensive to *write*: a streaming frame marks -/// several thousand entries, which would mean an allocation and a sort -/// per frame. Marking a bit is O(1), allocation-free and idempotent, and -/// the scan that reads it back is one word per 64 entries. #[derive(Default)] pub struct Dirty { words: Vec, @@ -26,7 +12,6 @@ pub struct Dirty { } impl Dirty { - /// Nothing uploaded yet, so nothing may be assumed about the buffer. pub fn new_all() -> Self { Self { words: Vec::new(), @@ -76,14 +61,6 @@ impl Dirty { !self.all && self.words.iter().all(|w| *w == 0) } - /// The ranges to upload, in ascending order, merging two runs - /// separated by a gap of fewer than `gap` entries. - /// - /// Merging trades bytes for `write_buffer` calls, and the fixture - /// says the trade is very cheap in one direction: over a fling, a - /// 1 KiB gap costs 0.1% more bytes than merging nothing at all and - /// halves the worst-case call count (23 to 13). Past that it stops - /// paying -- 4 KiB is +2% bytes for two fewer calls. pub fn ranges(&self, len: usize, gap: usize) -> Vec> { if self.all { return Vec::from_iter((len > 0).then_some(0..len)); @@ -93,15 +70,12 @@ impl Dirty { let mut bits = *word; while bits != 0 { let start = w * 64 + bits.trailing_zeros() as usize; - // The run of set bits starting here, within this word. let run = (bits >> (start - w * 64)).trailing_ones() as usize; let end = (start + run).min(len); if start >= len { break; } match ranges.last_mut() { - // `start - last.end` is the gap; equal ends means - // adjacent, which always merges. Some(last) if start - last.end <= gap => last.end = end, _ => ranges.push(start..end), } @@ -158,8 +132,6 @@ mod tests { #[test] fn ranges_stop_at_the_length() { - // Entries marked and then dropped by a shrink must not be - // uploaded past the end of what the caller is writing. assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]); } diff --git a/core/src/util/id.rs b/core/src/util/id.rs index 815d635..0cec955 100644 --- a/core/src/util/id.rs +++ b/core/src/util/id.rs @@ -27,8 +27,6 @@ impl IdTracker { impl Id { #[allow(dead_code)] - /// for debug purposes; should this be exposed? - /// generally you want to use labels with widgets pub(crate) fn raw(id: I) -> Self { Self(id) } diff --git a/core/src/util/math.rs b/core/src/util/math.rs index 4bf4f3a..7b3d520 100644 --- a/core/src/util/math.rs +++ b/core/src/util/math.rs @@ -20,12 +20,9 @@ const impl< T: const Add + const Sub + const Mul + const DivOr + Copy, > LerpUtil for T { - /// linear interpolation - /// from * (1.0 - self) + to * self fn lerp(self, from: Self, to: Self) -> Self { from + (to - from) * self } - /// inverse of lerp fn lerp_inv(self, from: Self, to: Self) -> Self { (self - from).div_or(to - from, from) } diff --git a/core/src/util/typemap.rs b/core/src/util/typemap.rs index 3a24e8b..8463472 100644 --- a/core/src/util/typemap.rs +++ b/core/src/util/typemap.rs @@ -28,7 +28,6 @@ impl TypeMap { } fn convert_mut>(entry: &mut Box) -> &mut T { - // allegedly this is just what Any does... unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) } } } diff --git a/core/src/util/vec2.rs b/core/src/util/vec2.rs index ca67c68..6efdd34 100644 --- a/core/src/util/vec2.rs +++ b/core/src/util/vec2.rs @@ -61,7 +61,6 @@ impl Vec2 { } } -// this version looks kinda cool... is it more readable? more annoying to copy and change though impl_op!(impl Add for Vec2: add x y); impl_op!(Vec2 Sub sub; x y); impl_op!(Vec2 Mul mul; x y); diff --git a/core/src/widget/data.rs b/core/src/widget/data.rs index 8d693e7..b0fe4dd 100644 --- a/core/src/widget/data.rs +++ b/core/src/widget/data.rs @@ -3,7 +3,6 @@ use crate::Widget; pub struct WidgetData { pub widget: Box, pub label: String, - /// dynamic borrow checking pub borrowed: bool, } diff --git a/core/src/widget/handle.rs b/core/src/widget/handle.rs index 7b52275..3a2c434 100644 --- a/core/src/widget/handle.rs +++ b/core/src/widget/handle.rs @@ -7,11 +7,6 @@ use crate::{ pub type WidgetId = SlotId; -/// An identifier for a widget that can index a UI or event ctx to get it. -/// This is a strong handle that does not impl Clone, and when it is dropped, -/// a signal is sent to the owning UI to clean up the resources. -/// -/// TODO: ergonomic clones when they get put in rust-analyzer & don't cause ICEs? pub struct StrongWidget { pub(super) id: WidgetId, counter: RefCounter, @@ -19,8 +14,6 @@ pub struct StrongWidget { ty: *const W, } -/// A weak handle to a widget. -/// Will not keep it alive, but can still be used for indexing like WidgetHandle. pub struct WeakWidget { pub(super) id: WidgetId, #[allow(unused)] diff --git a/core/src/widget/like.rs b/core/src/widget/like.rs index 45512ae..5dec9f2 100644 --- a/core/src/widget/like.rs +++ b/core/src/widget/like.rs @@ -43,7 +43,6 @@ impl WidgetArrLike for WidgetArr { } } -// variadic generics please save us macro_rules! impl_widget_arr { ($n:expr;$($W:ident)*) => { impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*); diff --git a/core/src/widget/mod.rs b/core/src/widget/mod.rs index 56dbd0b..bac28c0 100644 --- a/core/src/widget/mod.rs +++ b/core/src/widget/mod.rs @@ -18,12 +18,10 @@ pub use widgets::*; pub trait Widget: Any { fn draw(&mut self, painter: &mut Painter); - /// An exact, context-free length known without drawing or inspecting children. fn size_hint(&self, _axis: Axis) -> Option { None } - /// Whether the draw result is independent of the offered region. fn is_size_independent(&self) -> bool { false } @@ -32,12 +30,10 @@ pub trait Widget: Any { false } - /// The AccessKit role for a labelled widget. fn access_role(&self) -> accesskit::Role { accesskit::Role::Unknown } - /// Advance an animation and report whether it needs another frame. #[allow(unused_variables)] fn tick(&mut self, now: std::time::Instant) -> bool { false @@ -68,9 +64,6 @@ impl dyn Widget { } } -/// A function that returns a widget given a UI. -/// Useful for defining trait functions on widgets that create a parent widget so that the children -/// don't need to be IDs yet pub trait WidgetFn: FnOnce(&mut State) -> W {} impl W> WidgetFn for F {} diff --git a/core/src/widget/widgets.rs b/core/src/widget/widgets.rs index 8ea9c2e..9b09006 100644 --- a/core/src/widget/widgets.rs +++ b/core/src/widget/widgets.rs @@ -11,10 +11,6 @@ pub struct Widgets { send: Sender, recv: Receiver, pub(crate) waiting: HashSet, - /// Every widget that has ever been given an explicit `.label()` -- - /// `ui::access::AccessTree` walks exactly this set, not the whole - /// arena, so a widget nobody named costs it nothing. Symmetric with - /// `free_next` below, which is this set's one removal path. named: HashSet, } @@ -44,8 +40,6 @@ impl Widgets { Some(self.vec.get_mut(id)?.widget.as_mut()) } - /// get_dyn but dynamic borrow checking of widgets - /// lets you do recursive (tree) operations, like the painter does 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 @@ -101,18 +95,12 @@ impl Widgets { &self.data(id.id()).unwrap().label } - /// Also the one place a widget opts into `ui::access`'s AccessKit tree - /// (RUST.md's I4) -- see `named`'s doc comment. pub fn set_label(&mut self, id: impl IdLike, label: String) { let id = id.id(); self.data_mut(id).unwrap().label = label; self.named.insert(id); } - /// Every widget with an explicit name, for `ui::access::AccessTree` to - /// walk. Order is unspecified; `AccessTree` doesn't need one; a screen - /// reader's own traversal is worked out by uiautomator from each - /// node's on-screen bounds instead. pub fn named(&self) -> impl Iterator + '_ { self.named.iter().copied() } diff --git a/examples/bench_images.rs b/examples/bench_images.rs index 0ab403f..4ece2c6 100644 --- a/examples/bench_images.rs +++ b/examples/bench_images.rs @@ -1,27 +1,3 @@ -//! (d) of IRIS_TODO.md's "Benchmarks" item: 1,000 image rows, checking that -//! standalone-image bind-group *creation* -- a real `wgpu` resource, unlike -//! the counters in `benches/message_lazy_span.rs` -- goes to zero once every -//! image has loaded. This needs an actual `wgpu` device (`GpuTextures`, -//! `UiRenderNode`), so unlike the rest of the suite it cannot run as a -//! plain binary; run it through `iris/run-headless.sh bench_images`, which -//! gives it a real (headless, GPU-accelerated) compositor and surface. See -//! `run-bench.sh` for the wrapper that greps its output into one line. -//! -//! Each `RedrawRequested` prints the frame number and -//! `UiRenderNode::take_image_bind_group_creates()` for that frame, then -//! requests another redraw (nothing else marks the scene dirty, so without -//! this the app would only ever draw once). The first frame is expected to -//! report 1,000 (one create per image, on first load); the steady state -//! IRIS_TODO.md asks this scenario to prove is every frame after settling -//! down to 0. -//! -//! After `SETTLE_FRAMES` it appends one *new* image row (a transcript -//! receiving one more message) and keeps counting -- a chat transcript's -//! real access pattern is "one more image arrives," not "reload the whole -//! list," so the steady-state question that actually matters is the -//! *incremental* cost of that one append, not just whether an untouched -//! scene costs zero. It exits after `FRAMES`. - use iris::prelude::*; const ROWS: usize = 1000; diff --git a/examples/message_list.rs b/examples/message_list.rs index bc14e86..da7f0e7 100644 --- a/examples/message_list.rs +++ b/examples/message_list.rs @@ -1,21 +1,3 @@ -//! RUST.md's I3: `iris::widget::LazySpan` with 800 rows of varied-length -//! wrapped text, one in twelve carrying a small image, scrollable with the -//! mouse wheel. Run headless with `iris/run-headless.sh message_list --shot -//! /tmp/message_list.png` -- there is no display on this machine, so that -//! is the only way to see it rendered; `scripts/run-tests.sh`/`cargo test` never -//! touch this file. -//! -//! Rows alternate two background tints so a screenshot can show the -//! boundary between adjacent rows even where the text itself wraps to a -//! different number of lines -- exactly the "variable-height rows" I3 -//! asks for, and the thing a virtualised list gets wrong first if it is -//! wrong at all (a gap, an overlap, a row the wrong colour). This example -//! is also what found `LazySpan::place`'s oversized-background bug (see -//! lazy_span.rs's module doc and its `a_fill_shaped_background_is_not_left_ -//! oversized` test) -- a plain unit test could have (and now does) catch -//! it directly, but it was this screenshot rendering as a single blank -//! tinted rectangle that pointed at it first. - use iris::prelude::*; use winit::{dpi::LogicalSize, window::WindowAttributes}; @@ -31,10 +13,6 @@ struct State { const ROWS: usize = 800; const IMAGE_EVERY: usize = 12; -/// Repeats a short sentence a varying number of times per row so real -/// wrapping happens at every row height from one line to several, rather -/// than every row being identically tall (which would render correctly -/// even with a broken height measurement). fn row_text(i: usize) -> String { const SENTENCE: &str = "Iris lays out this row once and moves it on scroll, never re-laying it out. "; @@ -42,9 +20,6 @@ fn row_text(i: usize) -> String { format!("Message {i}: {}", SENTENCE.repeat(repeats)) } -/// A small solid-colour square standing in for a real decoded image -- -/// what matters for I3 is that a row can carry an `Image` widget at all, -/// not what the picture shows. fn row_image(i: usize) -> image::DynamicImage { let hue = ((i * 47) % 255) as u8; image::RgbaImage::from_pixel(48, 48, image::Rgba([hue, 128, 255 - hue, 255])).into() @@ -84,11 +59,6 @@ fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { } impl DefaultAppState for State { - // A phone-plausible portrait shape (the transcript screen this is - // standing in for). The tiling headless compositor `run-headless.sh` - // uses ignores this and fills its own 1920x1200 output regardless, but - // it's a correct hint for any other backend (a real window manager, or - // android-view) and costs nothing to state. fn window_attributes() -> WindowAttributes { WindowAttributes::default().with_inner_size(LogicalSize::new(420.0, 900.0)) } @@ -104,12 +74,6 @@ impl DefaultAppState for State { list.push_back(LazyItem::new(i as u64, row)); } - // `.scrollable()`, like anything else that scrolls -- here the - // span's own inherent one, which registers the wheel and the drag - // against the controller it already owns rather than wrapping it - // in a `ScrollArea`. Masked outside it, since a `LazySpan` draws - // the row straddling each edge in full and asserts something clips - // it. let root = list .scrollable() .masked() diff --git a/examples/tabs/main.rs b/examples/tabs/main.rs index 1860264..5b2058b 100644 --- a/examples/tabs/main.rs +++ b/examples/tabs/main.rs @@ -5,10 +5,6 @@ fn main() { DefaultApp::::run(); } -/// The tabs example: five demo panes plus a message composer, built by -/// `tabs_ui::build` and driven here through the winit backend. The same -/// widget tree also runs on the android-view backend, through -/// `iris-android-app` -- see RUST.md's I2. #[derive(DefaultUiState)] pub struct Client { ui_state: DefaultUiState, diff --git a/macro/src/lib.rs b/macro/src/lib.rs index 6263369..d48908c 100644 --- a/macro/src/lib.rs +++ b/macro/src/lib.rs @@ -18,11 +18,6 @@ struct Input { } struct InputFn { - /// Everything written above the `fn` -- in practice a `///` doc - /// comment, which is why this exists: `masked_by` and its siblings - /// are public API and rustdoc is where their contract is read, so a - /// macro that silently rejected `///` sent the explanation into an - /// ordinary `//` comment nobody generating docs ever sees. attrs: Vec, sig: Signature, body: Block, @@ -66,8 +61,6 @@ pub fn widget_trait(input: TokenStream) -> TokenStream { fns, } = parse_macro_input!(input as Input); - // The attributes go on the trait's own signature, which is the one - // rustdoc renders; the impl gets the bare `fn`. let sigs: Vec<_> = fns .iter() .map(|InputFn { attrs, sig, .. }| quote! { #(#attrs)* #sig }) diff --git a/rig-input/Cargo.toml b/rig-input/Cargo.toml index d63ab26..715580d 100644 --- a/rig-input/Cargo.toml +++ b/rig-input/Cargo.toml @@ -3,30 +3,14 @@ name = "rig-input" version.workspace = true edition.workspace = true -# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one -# of the `.touch` files the headless tests use into whatever window is -# under a Wayland compositor, so the *same recording* drives the -# assertion layer and the layer a person looks at. -# -# It exists because this machine's compositor has no pointer to move. -# `run-headless.sh` starts sway on the headless backend with no input -# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`), -# so `swaymsg seat - cursor press` reports success and nothing reaches -# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots -# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would -# be ignored by a compositor that is not reading libinput. The -# virtual-pointer protocol is what is left, and it is a client protocol, -# so it needs no devices and no root. - -# Named for what it does rather than for the crate, since the crate may -# grow a keyboard replay beside it. +# 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] -# `TouchScript` -- the same parser the harness uses, so a file that -# replays here and one that replays headless can never disagree. +# 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/rig-input/src/main.rs b/rig-input/src/main.rs index a200b3f..cf5918a 100644 --- a/rig-input/src/main.rs +++ b/rig-input/src/main.rs @@ -1,19 +1,3 @@ -//! Replays a `.touch` file into the compositor as a left-button drag -- -//! see this crate's `Cargo.toml` for why it exists rather than -//! `swaymsg seat - cursor`. -//! -//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE -//! -//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual -//! pointer protocol positions absolutely against an extent rather than -//! in pixels; passing the output size makes a script's coordinates mean -//! the same pixels they mean in the headless tests. -//! -//! Replayed in real time (the sleeps between samples are the gaps in the -//! file), because winit has no timestamp on a pointer event and dates -//! each one when it arrives -- so a 20ms flick has to actually take -//! 20ms here, unlike layer 1 where the sample carries its own time. - use iris::harness::{TouchAction, TouchScript}; use std::time::Duration; use wayland_client::protocol::wl_pointer::ButtonState; @@ -24,12 +8,8 @@ use wayland_protocols_wlr::virtual_pointer::v1::client::{ zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, }; -/// `linux/input-event-codes.h`. The protocol takes the kernel's own -/// button code, not a wayland enum. const BTN_LEFT: u32 = 0x110; -/// How long the pointer sits at the gesture's first position before the -/// script starts -- see the comment at the pre-step in `main`. const SETTLE: Duration = Duration::from_millis(200); #[derive(Default)] @@ -126,9 +106,6 @@ fn main() { previous = sample.t_ms; let t = sample.t_ms as u32; pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height); - // One frame per sample, so the compositor delivers them as - // separate pointer frames rather than coalescing the whole - // gesture -- the shape the file recorded is the point. pointer.frame(); // The button goes in a frame of its own, *after* the motion has // been committed. Sent in the same frame as the motion that diff --git a/run-headless.sh b/run-headless.sh index 9243de2..59d8e99 100755 --- a/run-headless.sh +++ b/run-headless.sh @@ -51,7 +51,6 @@ set -eu here=$(cd "$(dirname "$0")" && pwd) -# The workspace `--dir` selects; see the header. `$here` is iris itself. workdir="$here" run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" seconds=3 @@ -103,8 +102,6 @@ if ! swaymsg -t get_version >/dev/null 2>&1; then } fi -# Asked of the compositor rather than guessed: sway takes the first free -# wayland-N, and this machine may already have one. rm -f "$run/display" swaymsg exec -- "sh -c 'printf %s \"\$WAYLAND_DISPLAY\" > $run/display'" >/dev/null i=0 @@ -165,7 +162,6 @@ while [ $i -lt 40 ]; do i=$((i + 1)); sleep 0.25 done -# Then settle, for whatever the example does after its first frame. i=0 while [ $i -lt "$((seconds * 2))" ]; do kill -0 "$pid" 2>/dev/null || break diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7fd6ab2..303e39c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,11 +1,4 @@ -# iris needs nightly (see the #![feature] list in core/src/lib.rs and src/lib.rs). -# The pin is dated rather than "nightly" because the const-traits feature set -# changes shape between nightlies: on 2026-09-04 the vendored January tree would -# not parse at all, because `impl const Trait for T` had become -# `const impl Trait for T`. A rolling channel turns that into a build that -# breaks unattended on whatever machine Dev Updater happens to build on. -# Advance this deliberately, with the feature list in RUST.md's I0b. [toolchain] -channel = "nightly-2026-09-03" +channel = "nightly" components = ["clippy", "rustfmt"] targets = ["aarch64-linux-android", "x86_64-linux-android"] diff --git a/src/access_tests.rs b/src/access_tests.rs index 6a16292..1cbc93b 100644 --- a/src/access_tests.rs +++ b/src/access_tests.rs @@ -1,8 +1,3 @@ -//! Pass conditions for RUST.md's I4, exercised the same way -//! `layout_tests.rs` exercises LAYOUT.md's: `AccessTree` only touches -//! `Widgets`/`UiRenderState`, neither of which needs a GPU or a window, so -//! it can be driven directly against `layout_tests::TestRsc`. - use crate::layout_tests::TestRsc; use crate::prelude::*; @@ -22,7 +17,6 @@ fn a_named_widget_reaches_the_tree_with_its_role_and_bounds() { .update(rsc.widgets(), &render, &rsc) .expect("a first draw with a named widget must produce a tree"); - // One node for the widget, one for the synthetic window root. assert_eq!(update.nodes.len(), 2); let (_, node) = update .nodes @@ -59,10 +53,6 @@ fn a_widget_with_no_label_never_reaches_the_tree() { ); } -/// LAYOUT.md's "a moved subtree" lesson applies here too: `resolved_region` -/// (which `window_region` sits on) walks the move-offset chain, so a -/// widget moved via `Offset` -- not redrawn from scratch -- must still -/// report where it actually ended up. #[test] fn bounds_follow_a_moved_widget_and_updates_stay_incremental() { let mut rsc = TestRsc { @@ -86,17 +76,10 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() { .expect("the first draw is always a change"); assert_eq!(access.take_rebuilds(), 1); - // Unchanged frame: nothing moved, nothing renamed -- `update` must - // report no change, and the rebuild counter (I4's twin of - // `take_counters`) must stay at 0. render.update(&root, &mut rsc); assert!(access.update(rsc.widgets(), &render, &rsc).is_none()); assert_eq!(access.take_rebuilds(), 0); - // Move the child via `Offset` (a move-offset write, not necessarily a - // full redraw of the leaf -- see `resolve_move_chain`) and confirm the - // reported bounds shifted by exactly that amount, in exactly one more - // rebuild. let before = render .window_region(&leaf, &rsc) .expect("active before the move"); @@ -110,10 +93,6 @@ fn bounds_follow_a_moved_widget_and_updates_stay_incremental() { let after = render .window_region(&leaf, &rsc) .expect("still active after the move"); - // Not asserting the exact delta: `Offset`'s own `amt` -> pixel mapping - // is that widget's business, not this tree's. What I4 owns is that - // `AccessTree` reports whatever `window_region` says *now* -- so the - // node must have moved, and in the direction the offset moved it. assert!( after.top_left.x > before.top_left.x, "the leaf's reported bounds must move right along with its offset" diff --git a/src/android/access.rs b/src/android/access.rs index 9b59269..a6922ab 100644 --- a/src/android/access.rs +++ b/src/android/access.rs @@ -1,16 +1,3 @@ -//! I4 (RUST.md): the Android half of the AccessKit push, over -//! `accesskit_android::Adapter` and android-view's -//! `AccessibilityNodeProvider`. Carries E1's mitigation for the adapter's -//! reproducible abort: `accesskit_android`'s `State` (0.4.0 and 0.8.0 -//! alike) never moves back to `Inactive` once a client attaches, so once -//! one has, every later `QueuedEvents::raise` reaches -//! `AccessibilityManager.sendAccessibilityEvent` -- which throws if -//! accessibility has since been switched off (or the client detached), -//! and android-view's `panic = "abort"` turns that Java exception into a -//! process kill. `raise_if_enabled` is the gate: ask -//! `AccessibilityManager.isEnabled()` immediately before every `raise` -//! and drop the events instead of calling it when the answer is no. See -//! RUST.md's E1 box for the full repro. use accesskit::{ActionHandler, ActionRequest, ActivationHandler, TreeUpdate}; use accesskit_android::QueuedEvents; use android_view::{ @@ -37,11 +24,6 @@ impl ActivationHandler for AndroidAccessSource<'_> { } } -/// Every AccessKit action request is inert here -- see this module's doc -/// comment and `default/access.rs`'s matching handler for why: a screen -/// reader's tap on a named node is a real touch delivered at that node's -/// bounds, which the ordinary pointer path already handles once the -/// bounds `AccessTree` reports are right. pub(super) struct NullActionHandler; impl ActionHandler for NullActionHandler { fn do_action(&mut self, _request: ActionRequest) {} diff --git a/src/android/ime.rs b/src/android/ime.rs index 7f9a3b6..92cb0f9 100644 --- a/src/android/ime.rs +++ b/src/android/ime.rs @@ -1,19 +1,3 @@ -//! `InputConnection`, implemented directly against a focused `TextEdit` -//! rather than against a stand-in editor the way android-view's own demo -//! does over its `parley::PlainEditor` -- I1 already put parley behind -//! `TextEdit`, so this is that same bridge, just wired to iris's widget -//! instead of a bespoke one. Follows `demo/src/lib.rs`'s -//! `impl InputConnection for DemoViewPeer`, which is where RUST.md's E1 -//! found the shape this needs (`text_before_cursor` is what gets Gboard's -//! suggestion strip to read real words out of the buffer). -//! -//! Two things the demo tracks that this does not, both noted rather than -//! silently dropped: a real "composing region" distinct from the -//! selection (`set_composing_region` here just moves the caret, since -//! `TextEdit` has no third range to hold one), and batch-edit coalescing -//! (`begin`/`end_batch_edit` are no-ops -- a redraw mid-batch costs a frame -//! it does not need to, not correctness). - use crate::prelude::*; use android_view::{ CAP_MODE_SENTENCES, CallbackCtx, EditorInfo, IME_FLAG_NO_ENTER_ACTION, IME_FLAG_NO_EXTRACT_UI, @@ -50,25 +34,6 @@ impl IrisViewPeer { self.state.android_state().focus } - /// Tell Gboard where the caret/selection and the composing region - /// actually are, via `InputMethodManager.updateSelection` -- every one - /// of android-view's own demo's `set_composing_text_internal`/`render` - /// calls this, and this bridge never did, which is what left Gboard's - /// own model of the field diverging from `TextEdit`'s real one after - /// the very first edit (RUST.md's P0 box, "doesn't enter it until I - /// hit space, and also doesn't move cursor forward" -- Gboard holds - /// its composing keystrokes back until it believes the app has caught - /// up, and without this call it never does). Called from - /// [`IrisViewPeer::after_input`], the one tail every touch/key/IME - /// callback already runs through, rather than duplicated at each of - /// this file's mutating methods. - /// - /// `candidates_start`/`candidates_end` report the composing region; - /// `-1, -1` when nothing is composing, matching `EditorInfo`'s own - /// convention. `compose_len` is tracked in `char`s (this module's doc - /// comment), so this reports it as that many UTF-16 units back from the - /// caret -- exact for the common BMP case, the same approximation - /// `set_composing_text` already makes. pub(super) fn update_ime_selection(&mut self, ctx: &mut CallbackCtx) { let Some(focus) = self.focus() else { return }; let text = &self.rsc[focus]; @@ -219,10 +184,6 @@ impl InputConnection for IrisViewPeer { before_length: i32, after_length: i32, ) -> bool { - // Approximated as UTF-16 units rather than Unicode scalar values -- - // the two differ only outside the Basic Multilingual Plane, which - // this widget tree does not exercise today. Worth revisiting if a - // field ever needs to edit emoji or other astral-plane text well. self.delete_surrounding_text(ctx, before_length, after_length) } @@ -235,11 +196,6 @@ impl InputConnection for IrisViewPeer { let Some(focus) = self.focus() else { return false; }; - // The IME re-sends its whole composition on every keystroke; - // `compose_len` (chars, not bytes -- `TextEditCtx::replace`'s unit) - // is what lets `replace` remove exactly what it inserted last time. - // The same shape as `default::DefaultApp`'s `Ime::Preedit` handling - // for winit. let compose_len = self.state.android_state().compose_len; focus.edit(&mut self.rsc).replace(compose_len, text); self.state.android_state_mut().compose_len = text.chars().count(); @@ -267,9 +223,6 @@ impl InputConnection for IrisViewPeer { }; let text = &self.rsc[focus]; let content = text.text(); - // Collapsed to `end`: `TextEditCtx` has no range-selection setter - // yet (nothing before I2 needed one), so an IME-driven selection - // lands the caret at its focus end rather than spanning both. let byte = utf16_to_byte(content, end.max(0) as usize); focus.edit(&mut self.rsc).set_cursor_byte(byte); let _ = start; @@ -278,8 +231,6 @@ impl InputConnection for IrisViewPeer { } fn perform_editor_action(&mut self, _ctx: &mut CallbackCtx, _editor_action: i32) -> bool { - // `IME_FLAG_NO_ENTER_ACTION` above asks the IME not to offer one; - // nothing here needs handling it yet. false } @@ -311,8 +262,6 @@ impl InputConnection for IrisViewPeer { } fn request_cursor_updates(&mut self, _ctx: &mut CallbackCtx, _cursor_update_mode: i32) -> bool { - // No cursor-anchor UI to feed -- see RUST.md's I2 notes on what - // this backend does not do yet. false } } diff --git a/src/android/insets.rs b/src/android/insets.rs index 95ae6cb..f68394e 100644 --- a/src/android/insets.rs +++ b/src/android/insets.rs @@ -1,25 +1,3 @@ -//! Window insets, fed in from outside `ViewPeer`. -//! -//! android-view's registered native methods (`view.rs` in that crate) cover -//! touch, keys, focus, the surface and the IME -- there is nothing for -//! `View.onApplyWindowInsets`, because android-view's own demo does not -//! need it. The back gesture needed no new plumbing at all: with no -//! `OnBackPressedCallback` registered, Android still delivers it as an -//! ordinary `KEYCODE_BACK` `KeyEvent` through the ordinary key path (see -//! `view.rs`'s `on_key_down`), which is the legacy behaviour every app gets -//! by default and is enough for "the back gesture as an event". Insets have -//! no such stand-in, so this module registers one more native method by -//! hand, on the app's own `View` subclass rather than on android-view's. -//! -//! The peer id android-view hands back from `register_view_peer` is opaque -//! outside that crate (`with_peer` is `pub(crate)` there), so there is no -//! way to reach an existing `IrisViewPeer` from a JNI entry point we define -//! ourselves. Instead of forking android-view to add a hook, `new_peer` -//! (`view.rs`) inserts the *same* id into this module's own map, pointing -//! at a plain `Rc>` cloned into `AndroidUiState` too -- -//! so writing here is reading there, with no dependency in either -//! direction on the other's internals. - use android_view::{ View, jni::{ @@ -45,37 +23,14 @@ pub struct Insets { pub top: i32, pub right: i32, pub bottom: i32, - /// The keyboard's own inset (`WindowInsets.Type.ime()`), in physical - /// pixels, separate from `bottom` (the system bars): a layout wants to - /// know about the keyboard specifically, since it usually means "make - /// room" rather than "stay clear of a corner". pub ime_bottom: i32, - /// `WindowInsets.isVisible(ime())` -- whether the keyboard is up, which - /// is **not** the same question as `ime_bottom > 0` and is why the two - /// are carried separately. They disagree for the frames the keyboard - /// spends sliding: visible, with a height still on its way to the full - /// one. Anything asking "make how much room" reads `ime_bottom`; - /// anything asking "is the keyboard up" reads this. See - /// `MainActivity.java`'s comment for the history -- the height used to - /// be sent *as* this boolean, which is what left the composer padded by - /// one pixel on Iris's phone. pub ime_visible: bool, } #[derive(Default)] pub struct Shared { pub insets: Insets, - /// How many times Java has called `applyWindowInsetsNative` for this - /// peer, whether or not the numbers changed. Deliberately **not** a - /// field of `Insets`, which is compared for equality each frame to - /// decide whether to re-run `on_insets_changed`; a counter in there - /// would make every dispatch look like a change. - /// - /// It exists because "the keyboard does not push anything up" has two - /// completely different causes that look identical on screen -- the - /// listener never fired, or it fired with a zero `ime_bottom` -- and - /// Iris has no logcat on her phone (docs/IRIS_TODO.md). This number is - /// in the `Diagnostics` overlay, so one screenshot separates them. + /// Exposed in diagnostics to distinguish missing callbacks from zero insets. pub updates: u64, } @@ -125,8 +80,6 @@ extern "system" fn apply_window_insets<'local>( }; shared.updates += 1; } - // Insets can change (the keyboard opening) with no resize and no - // touch, so nothing else here would otherwise ask for a frame. view.post_frame_callback(&mut env); } diff --git a/src/android/mod.rs b/src/android/mod.rs index 2bc7392..10965fa 100644 --- a/src/android/mod.rs +++ b/src/android/mod.rs @@ -1,17 +1,3 @@ -//! iris's second windowing backend: `android-view` (a `SurfaceView` plus a -//! JNI `ViewPeer`) instead of winit. See RUST.md's I2 for why this exists -//! as a second backend rather than winit's own (unfinished, and blocked on -//! `android-activity`'s backend-feature requirement) Android support, and -//! for the pass condition this was built against. -//! -//! Structured to mirror `default/` module for module: `view.rs` is that -//! module's `app.rs` + `state.rs` combined (android-view has one harness -//! type, `ViewPeer`, where winit splits `ApplicationHandler` from the -//! per-window state), `render.rs` is `render.rs`, `input.rs` is `input.rs`, -//! `attr.rs` is `attr.rs`. `ime.rs` and `insets.rs` have no winit -//! counterpart: winit cannot drive an IME beyond `Ime::Preedit`/`Commit` -//! (RUST.md's E1) and has no concept of Android's window insets at all. - mod access; mod attr; mod ime; diff --git a/src/android/platform.rs b/src/android/platform.rs index df5f867..08b796e 100644 --- a/src/android/platform.rs +++ b/src/android/platform.rs @@ -22,16 +22,10 @@ impl OpenUrl for T { } } -/// `startActivity(new Intent(ACTION_VIEW, Uri.parse(url)))` on the view's -/// own context. -/// /// `FLAG_ACTIVITY_NEW_TASK` because the context here is the view's, which /// may be an application context rather than the activity's -- Android /// throws `AndroidRuntimeException` for a non-activity context without it, /// and it is harmless when the context *is* an activity's. -/// -/// Every failure is logged with the URL and returns; there is nothing to -/// fall back to, and the reader will see that nothing happened. pub(super) fn open_url<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, url: &str) { match try_open_url(env, view, url) { Ok(()) => {} diff --git a/src/android/render.rs b/src/android/render.rs index 1e86f98..db38540 100644 --- a/src/android/render.rs +++ b/src/android/render.rs @@ -48,9 +48,6 @@ pub struct AndroidRenderer { config: SurfaceConfiguration, encoder: CommandEncoder, pub ui: UiRenderNode, - /// The adapter identity, kept past `new()` for the Diagnostics page -- - /// `Adapter` itself is not `Clone`, so the three fields the page shows - /// are copied out once here rather than holding the adapter. pub adapter_name: String, pub adapter_backend: Backend, pub adapter_driver: String, @@ -59,12 +56,6 @@ pub struct AndroidRenderer { /// `new()`, kept here so the Diagnostics page and the per-frame log in /// `update()` can both read it without a global. pub wgpu_errors: iris_core::WgpuErrorLog, - /// Frames drawn on this surface -- what gates the first-10-frames log - /// `update()` writes (RUST.md's P0 box, "the first input frame" - /// investigation): a fresh surface is exactly what Iris's own report - /// says renders correctly at first, so the frames that matter are the - /// first several after each `surface_changed`, not an arbitrary window - /// during a long-running session. frame_count: u64, /// Physical pixels per dp -- see `android::view::AndroidUiState:: /// content_scale`'s field comment for what this feeds. @@ -82,48 +73,17 @@ pub struct AndroidRenderer { pub struct FrameDiagnostics { pub masks_resized: bool, pub moves_resized: bool, - /// From the previous frame's `update()` -- see the struct doc. pub atlas_pages_grown_prev: u64, pub image_bind_group_creates_prev: u64, } impl AndroidRenderer { - /// `Err` holds a full, human-readable report for **every** way this - /// can fail -- no surface, no adapter, no device, or wgpu's own error - /// text (`UiRenderNode::new`'s doc comment) plus the adapter identity - /// and the limits/downlevel flags bind-group-layout validation checks - /// against -- rather than the panic wgpu's default error handler would - /// otherwise raise with no caller able to see it. This is what aborted - /// the P0 bench APK on Iris's phone with only "wgpu error: Validation - /// Error" surviving into the crash report (RUST.md's P0 box, "iris - /// bench crash on the phone, 2026-09-06"): `create_bind_group_layout` - /// validates against *this* adapter's downlevel capabilities and - /// limits, which a desktop GPU and the emulator's software renderers - /// never exercised. The caller (`android::view::IrisViewPeer:: - /// surface_changed`) logs this one-line-flattened and shows it on - /// screen instead of aborting the process. pub fn new( window: NativeWindow, width: u32, height: u32, content_scale: f32, ) -> Result { - // `force-gles` (RUST.md's I5 "Where iris's frame time goes") pins - // the build to GLES, to isolate whether the backend itself explains - // the frame time gap against Compose. `cfg!` rather than a runtime - // switch: there is no way to hand an env var to an already-launched - // Android process on this machine (see the feature's doc in - // Cargo.toml). - // - // Otherwise: **Vulkan where it has an adapter at all, GLES where it - // has none.** `Backends::PRIMARY` leaves `GL` out, so a device - // offering only a GLES adapter had no adapter at all and this - // function aborted the process -- this checkout's emulator, whose - // Vulkan ICD carries no adapter behind it (`NotFound { - // active_backends: VULKAN, no_adapter_backends: VULKAN, - // supported_backends: VULKAN | GL }`), and the crash loop in - // RUST.md's queue. - // // The choice is made *before any surface exists*, with an instance // that never touches the window, because **an Android window can be // connected to one graphics API only**. One instance carrying both @@ -182,10 +142,6 @@ impl AndroidRenderer { .block_on() .map_err(|error| format!("No usable GPU adapter for backends {backends:?}: {error}"))?; - // Same request as the winit backend's `UiRenderer::new` -- no - // binding-array features, see TEXTURES.md's "Recommended shape". - // `iris_core::device_limits()` is shared between the two backends; - // see its own doc for why it is not simply `Limits::default()`. let (device, queue) = adapter .request_device(&DeviceDescriptor { required_limits: iris_core::device_limits(), @@ -200,14 +156,6 @@ impl AndroidRenderer { ) })?; - // wgpu's default handler for an error raised outside `UiRenderNode:: - // new`'s own error scopes (i.e. everything past device creation -- - // an ordinary frame's `update`/`draw`) is `panic!`, unconditionally, - // with no caller able to intervene: the same mechanism that aborted - // the P0 bench APK once already, just at a different call site. Log - // and record instead of letting that default stand -- RUST.md's P0 - // box, "every wgpu uncaptured error ... it must never panic in - // release". let wgpu_errors = iris_core::WgpuErrorLog::default(); let wgpu_errors_for_handler = wgpu_errors.clone(); device.on_uncaptured_error(std::sync::Arc::new(move |error| { @@ -263,12 +211,6 @@ impl AndroidRenderer { surface.configure(&device, &config); let encoder = Self::create_encoder(&device); - // Physical pixels, matching the swapchain's own `width`/`height` - // exactly -- see `android::view::AndroidUiState::content_scale`'s - // field comment for why this is no longer divided into a separate - // logical space (that stopgap is what made text blurry, RUST.md's - // P0 box). `Len::dp` folds the density in at layout time instead, - // so nothing here needs to know it at all. let window_size = iris_core::util::Vec2::new(width as f32, height as f32); let ui = match UiRenderNode::new(&device, &queue, &config, window_size) { Ok(ui) => ui, @@ -333,16 +275,6 @@ impl AndroidRenderer { ) } - /// The Diagnostics page's whole report: adapter identity, font - /// resolution, the atlas's own view count, every uncaptured wgpu error - /// so far, and the frame report -- RUST.md's P0 box, "a named - /// `Diagnostics` control ... adapter info, limits, fonts found, atlas - /// format/pages, wgpu errors so far, frame report". One string rather - /// than a struct the caller formats, since the only consumer is a - /// plain `TextView` with a "copy this and send it to Iris" affordance, - /// the same shape `surface_changed`'s crash report already uses - /// (UI_RULES.md: a failure -- or here, a state worth reporting -- - /// carries enough to act on where it's shown). pub fn diagnostics_report( &self, font: &iris_core::FontDiagnostics, @@ -388,14 +320,6 @@ impl AndroidRenderer { }) } - /// Returns what changed this frame -- see `FrameDiagnostics`'s doc - /// comment for why two of its four fields describe the *previous* - /// frame rather than this one. `IrisViewPeer::render` logs this for - /// the first `DIAGNOSTIC_FRAMES` frames after each `surface_changed`, - /// per RUST.md's P0 box ("the first input frame" investigation): the - /// glyph-wipe Iris reported happens on the first tap or scroll after a - /// fresh surface, so that is exactly the window a report needs to - /// cover, not an arbitrary slice of a long session. pub fn update(&mut self, ui: &mut UiData, render: &mut UiRenderState) -> FrameDiagnostics { let atlas_pages_grown_prev = self.ui.take_atlas_pages_grown(); let image_bind_group_creates_prev = self.ui.take_image_bind_group_creates(); @@ -409,30 +333,10 @@ impl AndroidRenderer { } } - /// Frames drawn on this surface so far -- see `frame_count`'s field - /// comment. pub fn frame_count(&self) -> u64 { self.frame_count } - /// Draws and presents one frame, returning the two parts of it that - /// are waits rather than work: the `get_current_texture` acquire and - /// `queue.submit` + `present()`. The caller - /// (`android::view::render`) times the whole frame and fills in - /// `FrameParts::total`, so whatever is left over is iris's own work. - /// - /// **The acquire is why this returns two numbers and not one.** - /// `get_current_texture` blocks until the compositor hands back a - /// swapchain image, which on an app comfortably ahead of the display - /// is most of every frame -- so counting it as CPU work (which this - /// did until 2026-09-09) reports a fling as milliseconds of iris - /// being slow when they are milliseconds of iris waiting its turn. - /// - /// RUST.md's I5 "Where iris's frame time goes" diagnosis, added - /// 2026-09-05 -- see `iris_core::FrameParts`'s own doc for the caveat - /// the submit half shares: `present()` is not fenced against the GPU - /// actually finishing, so it is "how long the CPU was blocked handing - /// the frame off", not confirmed GPU time. pub fn draw(&mut self) -> FrameParts { let acquire_start = Instant::now(); let output = match self.surface.get_current_texture() { @@ -499,20 +403,6 @@ impl AndroidRenderer { /// tokio thread `Tasks::init` spawned, which is not attached to the JVM, so /// asking for a frame means attaching first. The global ref is what /// survives past the JNI call that handed the `View` to us. -/// -/// **Goes through `View::post_delayed`, not `post_frame_callback` -/// directly** -- found the hard way (RUST.md's I5 Android integration): -/// `post_frame_callback`'s Java side calls `Choreographer.getInstance()`, -/// which throws `IllegalStateException` unless the *calling* thread already -/// has a `Looper` (`Choreographer.getInstance()`'s own contract). A tokio -/// worker thread, even freshly attached to the JVM, has none -- the crash -/// was a `JavaException` inside `View::post_frame_callback`'s `.unwrap()`, -/// aborting the process on the second `redraw.request_redraw()` any -/// android transcript-screen fetch made. `View.postDelayed(Runnable, 0)` -/// is the ordinary Android answer to "queue work onto a View's own UI -/// thread from any thread" and needs no Looper of its own; `delayed_callback` -/// below is what that Runnable resolves to on the UI thread, where a real -/// `post_frame_callback` is safe again. pub struct AndroidRedrawHandle { vm: JavaVM, view: GlobalRef, diff --git a/src/android/view.rs b/src/android/view.rs index 17b8047..cddc95b 100644 --- a/src/android/view.rs +++ b/src/android/view.rs @@ -11,10 +11,6 @@ use android_view::{ }, ndk::event::{Axis, Keycode, MotionAction}, }; -// `marker::Sized` explicitly: `crate::prelude::*` below also brings in the -// `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified -// glob import shadows the language prelude -- `default/mod.rs` has the same -// explicit import for the same reason. use std::{ cell::RefCell, marker::{PhantomData, Sized}, @@ -62,43 +58,9 @@ pub struct AndroidUiState { /// path -- see `android/insets.rs` for why they need a registry of /// their own. shared: Rc>, - /// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by - /// the `AccessibilityNodeProvider` impl below; see `android/access.rs` - /// for the abort mitigation every `raise` on it goes through. pub access_adapter: AccessAdapter, - /// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc - /// comment. pub access: AccessTree, - /// iris's own frame-time report (RUST.md's I5 box, "Measurements - /// taken" (b)) -- `render()` below records into it once per frame, - /// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own - /// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc. pub frame_report: FrameReport, - /// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels - /// per dp on this device, read once at view construction and carried - /// on `UiRenderState::density` (`render.set_density`, `new_peer`) from - /// then on -- every `Len::dp` in the widget tree resolves against it at - /// layout time (`Len::dp`'s field doc, IRIS_TODO.md's - /// "density-independent length unit" item, 2026-09-06). - /// - /// **Everything else in this module is physical pixels, matching the - /// real wgpu surface/swapchain resolution** -- window size, touch - /// coordinates, insets. That is a correction from an earlier version - /// of this comment, which had `window_size`/`surface_changed`'s - /// `UiRenderState::resize` call divide by `content_scale` into a - /// *logical* coordinate space instead, as a global stopgap for - /// RUST.md's P0 box's phone report ("text is far too small"). That - /// stopgap fixed the size but not the *sharpness*: dividing to logical - /// units meant a `16.0`-sized glyph rasterised at 16 physical px and - /// then implicitly upscaled ~3x by the NDC mapping onto the real - /// physical framebuffer -- the exact "blurry ... glyphs drawn at - /// logical size and stretched by the scale" Iris reported next. - /// Resolving `dp` at layout time replaces it: a widget author writes - /// `dp(16)` for a size that should look the same physical size on any - /// density, and everything downstream (layout, hit-testing, the window - /// uniform, and the font size handed to the text shaper) works in the - /// display's own physical pixels throughout, so nothing is - /// rasterised at one resolution and displayed at another. pub content_scale: f32, /// The last insets `render()` saw -- compared each frame so /// `AndroidAppState::on_insets_changed` fires only when they actually @@ -131,13 +93,6 @@ impl AndroidUiState { self.shared.borrow().insets } - /// The insets state as one line for a diagnostics pane, including how - /// many times the platform has delivered any -- see - /// `insets::Shared::updates` for why the count is the load-bearing - /// part. `dispatches=0` says the listener has never run and the - /// numbers beside it are defaults rather than measurements, which is - /// the distinction a screenshot otherwise cannot make (UI_RULES.md, - /// "design the unknown state first"). pub fn insets_report(&self) -> String { let shared = self.shared.borrow(); let i = shared.insets; @@ -167,39 +122,12 @@ pub trait HasAndroidUiState: Sized + 'static { pub trait AndroidAppState: HasAndroidUiState { fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self; - /// The system back gesture/button. `true` means handled -- nothing - /// further happens; `false` lets the activity finish as it would with - /// no view at all. The default declines, since most screens have - /// nothing to intercept it for. #[allow(unused_variables)] fn back_pressed(&mut self, rsc: &mut AndroidRsc, render: &mut UiRenderState) -> bool { false } - /// Called once, right after `new`, with a fresh `JavaVM` handle and a - /// global reference to this app's own `View` -- for a caller that - /// needs to call into Java itself beyond what a [`RequestRedraw`] - /// handle already covers (P0's bench build calling - /// `BatteryManager`/`ClipboardManager` through the view's `Context`, - /// docs/RUST.md). Not folded into `new` itself: most implementors need - /// nothing here, and `new`'s job is building the widget tree, not - /// holding a platform handle -- the default does nothing. `vm`/`view` - /// are independent handles from the ones `new_peer` keeps for its own - /// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so - /// storing them has no effect on that mechanism. #[allow(unused_variables)] fn platform_ready(&mut self, rsc: &mut AndroidRsc, vm: JavaVM, view: GlobalRef) {} - /// Called from `render()` whenever `AndroidUiState::insets()` differs - /// from what it was last frame -- once at startup for the status bar - /// (RUST.md's P0 box: "the status-bar inset is not applied" reported - /// the two top buttons sitting under it, because nothing read `.top` - /// at all), and again on a rotation or the keyboard opening/closing. - /// `insets` is in the same physical-pixel units everything else in the - /// tree now uses (`AndroidUiState::content_scale`'s field comment), so - /// a widget can add it to a layout size directly -- `dp(...) + - /// abs(insets.top)` if the widget wants a density-independent size - /// plus the system bar's own (already-physical) height. The default - /// does nothing -- most screens have no chrome that sits under a - /// system bar. #[allow(unused_variables)] fn on_insets_changed(&mut self, rsc: &mut AndroidRsc, insets: WindowInsets) {} } @@ -238,11 +166,6 @@ impl WindowInsets { } } -/// The android-view analogue of `default::DefaultRsc` -- identical in -/// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState` -/// mention winit. Kept as a separate type rather than shared code because -/// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold -/// their harness state differently (see RUST.md's I2). pub struct AndroidRsc { pub ui: UiData, pub events: EventManager, @@ -379,11 +302,6 @@ impl IrisViewPeer { pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { self.run_input_frame(ctx); - // RUST.md's P0 box, "doesn't enter it until I hit space, and also - // doesn't move cursor forward": Gboard needs `updateSelection` - // after every edit to keep its own model of the field in sync, or - // it holds keystrokes back rather than trusting a screen it - // believes is stale. See `update_ime_selection`'s own doc. self.update_ime_selection(ctx); let ui_state = self.state.android_state_mut(); @@ -393,16 +311,6 @@ impl IrisViewPeer { } } - /// The view's one clock, anchoring it on `nanos` if nothing has yet - /// -- see the `device_clock` field. Either source may be the first to - /// arrive: a frame callback fires before any touch on an app that - /// animates at startup, and a touch arrives first on one that does - /// not. - /// - /// `oldest` is the earliest sample the anchoring event carries, which - /// matters only when this is the call that anchors -- see - /// `DeviceClock::anchored`. A frame time carries no batch, so it - /// passes its own time for both. fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock { *self .device_clock @@ -417,20 +325,6 @@ impl IrisViewPeer { } } - /// The `log::debug!` calls here are a live diagnostic for a still-open - /// finding (RUST.md's I2): layout runs and reports the right pixel - /// region for the root (confirmed via `window_region`, logged below), - /// and the clear colour reaches the screen (confirmed by swapping it to - /// magenta and screenshotting), but no primitive ever appears on top of - /// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave - /// these in until that is root-caused; removing them loses the exact - /// evidence a `logcat` capture needs to reproduce the state. Gated on - /// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's - /// review, D1): unconditional, they were two `debug!` lines every - /// rendered frame, and `client_core::log_ring`'s `RingLogger` records - /// every level the app's already-`Debug` install lets through - /// regardless of target, so they filled the whole ring in under ten - /// seconds at 120Hz and left `Copy report` nothing else to show. fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) { if self.state.android_state().renderer.is_none() { return; @@ -444,12 +338,6 @@ impl IrisViewPeer { let current_insets = ui_state.insets(); if current_insets != ui_state.last_insets { let physical = WindowInsets::from_physical(current_insets); - // One line per real insets change. Iris's phone is the only - // place several of these bugs reproduce and `adb logcat` is - // the only instrument there (this-machine-android: system - // tracing is broken on that device), so the numbers a layout - // is actually fed have to reach the log -- "the composer - // floats at launch" is unanswerable from a screenshot alone. log::info!( "iris insets: left={} top={} right={} bottom={} ime_bottom={} \ ime_visible={} window={:?}", @@ -465,14 +353,6 @@ impl IrisViewPeer { self.state.on_insets_changed(&mut self.rsc, physical); } - // Gated the same way `iris::frame`'s own line is (docs/RUST.md's - // "Phone logging" review, D1): a bare `log::debug!` reaches - // `client_core::log_ring`'s ring regardless of level, since - // `RingLogger::enabled` is unconditionally `true` and the app - // installs at `LevelFilter::Debug` -- two of these a rendered - // frame filled the 2000-line ring in under ten seconds at 120Hz, - // leaving `Copy report` nothing but frame spam. See - // `iris::diagnostics`'s module doc. if crate::diagnostics::trace_enabled() { let ui_state = self.state.android_state(); log::debug!( @@ -488,35 +368,8 @@ impl IrisViewPeer { self.window_size(), ); } - // iris's own frame-time report (RUST.md's I5 box, "Measurements - // taken" (b)): started here, at the same point a redraw request - // fires, and stopped after `renderer.draw()`'s `queue.submit` + - // `present()` -- the span Compose's render report and `gfxinfo` - // both count. See `iris_core::FrameReport`'s own doc for exactly - // what this does and does not measure. let frame_start = Instant::now(); - // Anything moving on its own -- today a `LazySpan` coasting - // through a fling -- is advanced here, before the draw. **On - // `now`, not on `frame_start`**: `now` is the vsync the - // `Choreographer` handed this callback, which is evenly spaced, - // while `frame_start` is whenever the callback actually got to - // run. The frames are *presented* on the even cadence either way, - // so sampling the animation on the uneven one moves the content by - // an uneven distance per frame -- a fling that shimmers with no - // frame late enough to show up in a report. See - // `UiData::tick_animations` and `sense::DeviceClock`; - // `default/mod.rs`'s `RedrawRequested` arm is the winit half. let animating = self.rsc.ui.tick_animations(now); - // **Asked for before the work, not after it.** A frame callback is - // one-shot, so an animation that wants another frame has to say so - // every frame -- and `Choreographer.postFrameCallback` schedules - // for the next vsync *after the call*. Asking at the end of this - // function meant any frame whose work ran past the vsync boundary - // (the swapchain acquire below alone can sit most of a frame) - // registered too late for the next one and got the one after -- - // so one frame over budget silently cost a second frame as well. - // Unlike `after_input`, which only has to ask when input dirtied - // something. if animating { ctx.view.post_frame_callback(&mut ctx.env); } @@ -527,14 +380,6 @@ impl IrisViewPeer { return; }; let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render); - // First `DIAGNOSTIC_FRAMES` frames after each `surface_changed` - // only -- RUST.md's P0 box, "the first input frame" investigation: - // the glyph-wipe Iris reported happens on the first tap or scroll - // after a fresh surface, so a report from that window is what - // would show whether an atlas grow, a masks/move_offsets resize, or - // a fresh wgpu error coincided with it. `frame_count()` was just - // incremented inside `update()`, so `<=` counts frame 1 through - // `DIAGNOSTIC_FRAMES` inclusive. if renderer.frame_count() <= DIAGNOSTIC_FRAMES { log::info!( "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ @@ -549,9 +394,6 @@ impl IrisViewPeer { } let mut parts = renderer.draw(); parts.total = frame_start.elapsed(); - // Dated on `now` -- the vsync this frame was for -- so the gap - // between consecutive frames is the display's own cadence and - // `PhaseStats::missed` counts vsyncs nothing was drawn for. self.state .android_state_mut() .frame_report @@ -570,12 +412,6 @@ impl IrisViewPeer { ); } - // I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues - // anything to raise -- when the named set actually changed this - // frame; see `AccessTree`'s doc comment. Deferred rather than - // raised inline so it runs after this callback releases whatever - // it's holding, matching android-view's own demo and `raise`'s own - // contract. let ui_state = self.state.android_state_mut(); if let Some(tree_update) = ui_state @@ -597,19 +433,6 @@ fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) { imm.show_soft_input(env, view, 0); } -/// Replaces the activity's content with a plain, selectable, scrollable -/// text view holding `report` -- the on-screen half of `surface_changed`'s -/// renderer-failure path (UI_RULES.md: "a failure is reported where it -/// happened, and says what to do next," here "copy this and send it"). -/// Goes through an ordinary instance method on the Java side -/// (`IrisView.showRendererError`) rather than a new `native` method: this -/// call is Rust reaching *into* Java, the opposite direction from every -/// `native fn` android-view/`IrisView` declare, and an ordinary virtual -/// call resolves against `ctx.view`'s real runtime class (`IrisView`) the -/// same way any other JNI method call here does. Silently does nothing on -/// any JNI failure -- there is no more-fallback screen to fall back to, -/// and the `log::error!` in `surface_changed` already reached logcat -/// first. fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) { let Ok(message) = env.new_string(report) else { return; @@ -703,18 +526,6 @@ impl ViewPeer for IrisViewPeer { let trace_input = crate::diagnostics::trace_enabled(); let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new(); - // **Historical samples first.** A flick on a 120Hz screen is - // delivered as one or two `MotionEvent`s with the intermediate - // positions batched inside them, so reading only `x()`/`y()` threw - // away every sample but the last: the velocity tracker saw one - // `Pan` for the whole gesture, `VelocityTracker::velocity` answers - // 0.0 below two samples, and the release therefore flung at zero -- - // Iris's phone, twice ("fling still doesn't work"), while a - // `ui-trace` swipe, which is many evenly-spaced events, flung fine. - // Replayed one at a time through the sensors rather than summarised, - // so the arbiter, the tracker and any other sensor all see the same - // motion the finger actually made; only the last sample ends the - // frame (`after_input`). if matches!(action, MotionAction::Move) { // Android documents the historical samples as oldest first and // the event's own sample as the newest of the batch; everything @@ -757,15 +568,6 @@ impl ViewPeer for IrisViewPeer { ui_state.cursor.pos = vec2(x, y); ui_state.cursor.buttons.left.update(false); } - // A cancel ends the press -- a release that never arrives - // leaves whichever widget took pointer capture holding it - // forever -- but it is **not** a release, and saying so is - // `CursorState::cancelled`. It used to take the `Up` arm, so - // the system's own swipe up from the bottom edge to leave the - // app (moves, then `ACTION_CANCEL`) reached iris as a flick - // released at speed, and the transcript flung while the app - // was in the background: Iris's 2026-09-08 "leaving and - // reopening the app also randomly moved the vertical scroll". MotionAction::Cancel => { ui_state.cursor.pos = vec2(x, y); ui_state.cursor.buttons.left.update(false); @@ -837,25 +639,6 @@ impl ViewPeer for IrisViewPeer { // in particular) mean something different from a `rest`-based one. self.render.resize((width as f32, height as f32)); - // **Reuse the existing renderer (device, atlas, buffers, bind - // groups) when one is already live -- only reconfigure the - // surface.** `surfaceChanged` fires on *every* size or format - // change, not only on a genuinely new `Surface`/window: showing - // the IME under `adjustResize` resizes the same `SurfaceView` and - // is reported through this exact callback. Rebuilding the whole - // `AndroidRenderer` here used to mean a fresh `UiRenderNode::new` - // -- a brand-new, empty glyph atlas and fresh GPU buffers -- while - // `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the - // atlas coordinates it had already handed out against the *old* - // atlas. Every glyph then drew from a UV rectangle that pointed - // into a texture that had just been recreated empty, so text - // vanished on the first keyboard open while rects (which never go - // through the atlas) kept drawing -- exactly the "rectangles stay, - // glyphs disappear" Iris reported. Confirmed by reading this path - // end to end (no fresh-atlas rebuild anywhere in `resize()` below, - // only in `AndroidRenderer::new`) before changing anything, per - // AGENTS.md's "verify before finishing". - // // `AndroidRenderer::resize` only reconfigures the wgpu surface and // rewrites the window uniform -- device, atlas, buffers and bind // groups are untouched, so the glyph cache's coordinates stay @@ -881,17 +664,6 @@ impl ViewPeer for IrisViewPeer { } let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env); - // `AndroidRenderer::new` used to panic here through wgpu's own - // default uncaptured-error handler on a bind-group-layout - // validation failure -- exactly what aborted the P0 bench APK on - // Iris's phone with the message truncated to "wgpu error: - // Validation Error" and nothing else recoverable from the crash - // report (RUST.md's P0 box, "iris bench crash on the phone, - // 2026-09-06"). It now returns the full diagnostic instead; this is - // the one place in the app that can turn it into something a - // person can read, since `ctx.view`/`ctx.env` (needed to reach the - // Java side) are only in scope inside a `ViewPeer` callback. - // // `content_scale` reaches `AndroidRenderer` only for the // Diagnostics page's report text now -- window size and the // shader's window uniform are physical pixels throughout (see the @@ -930,24 +702,7 @@ impl ViewPeer for IrisViewPeer { self.render(ctx, Instant::now()); } Err(report) => { - // One line for logcat (UI_RULES.md: "the full text for - // whoever can read the log" lives here), the multi-line - // original on screen -- `show_renderer_error` below. log::error!("iris renderer init failed: {}", report.replace('\n', " | ")); - // Deferred, not called directly: `Activity::setContentView` - // tears the old view hierarchy down synchronously, which - // fires `IrisView`'s own `onFocusChanged` before - // `setContentView` returns -- straight back into this same - // `IrisViewPeer` through `on_focus_changed` while - // `with_peer` (android-view's dispatch, `view.rs` upstream) - // still holds this peer's `RefCell` borrow for the - // `surface_changed` call in progress. Found by inducing a - // validation error and hitting `RefCell already borrowed` - // at exactly that reentrant call (RUST.md's P0 box). - // `push_dynamic_deferred_callback` runs after `with_peer` - // drops the borrow, which is what every other callback in - // this file that reaches into Java already relies on - // (`raise_if_enabled`, above). ctx.push_dynamic_deferred_callback(move |env, view| { show_renderer_error(env, view, &report); }); @@ -996,8 +751,6 @@ impl ViewPeer for IrisViewPeer { /// one. fn delayed_callback(&mut self, ctx: &mut CallbackCtx) { self.drain_tasks(); - // No vsync to date this one on -- it is a background task's - // "there is new state", not a frame the display asked for. self.render(ctx, Instant::now()); } @@ -1106,8 +859,6 @@ pub fn new_peer<'local, State: AndroidAppState>( state: Default::default(), _state: PhantomData, }; - // See `TextData::density`'s field doc for why this is set alongside - // `render.set_density` below rather than read from there. rsc.ui.text.density = content_scale; let shared = Rc::new(RefCell::new(Shared::default())); let ui_state = AndroidUiState::new(shared.clone(), content_scale); @@ -1116,8 +867,6 @@ pub fn new_peer<'local, State: AndroidAppState>( let platform_view = env.new_global_ref(&view.0).unwrap(); state.platform_ready(&mut rsc, platform_vm, platform_view); let mut render = UiRenderState::new(); - // Every `Len::dp` in the tree resolves against this from now on -- see - // `UiRenderState::density`'s field doc and `Len::dp`'s. render.set_density(content_scale); let peer = IrisViewPeer { rsc, diff --git a/src/attr.rs b/src/attr.rs index c925007..884f8cb 100644 --- a/src/attr.rs +++ b/src/attr.rs @@ -1,21 +1,7 @@ use crate::prelude::*; use std::time::{Duration, Instant}; -/// What focusing a text field takes from whichever backend is running -- -/// tracked here rather than duplicated per backend, since `Selector` and -/// `Selectable` (below) are the *only* thing that decides which `TextEdit` -/// is the IME's target, and both platforms need the same double-click -/// timing and the same "remember which one" bookkeeping. What differs is -/// what happens *after* the focus record is set: winit tells the -/// compositor an IME area (`focus_gained`, in `default/attr.rs`); on -/// android-view a keyboard has to be asked for explicitly, and only from a -/// JNI call this crate cannot make outside a view callback -- so -/// `focus_gained` there (`android/attr.rs`) just raises a flag the next -/// touch callback consumes. See RUST.md's I2. pub trait FocusHost { - /// True on a click close enough in time to the previous one to grow a - /// selection instead of starting a new one, updating the clock as a - /// side effect the way a real double-click timer does. fn recent_click(&mut self) -> bool; fn set_focus(&mut self, id: Option>); /// Called on every tap that should put the IME on `id`: the tap that @@ -27,17 +13,9 @@ pub trait FocusHost { /// lets the repeat tap be handled by the same call rather than by a /// second "re-show" entry point beside it. fn focus_gained(&mut self, region: Option); - /// Whether `id` is the current focus target -- what [`select`] uses to - /// tell a fresh press (which must wait to see whether it becomes a tap - /// or a drag before focusing/showing the IME, Iris 2026-09-06: "if I - /// swipe over the input bar it brings up the keyboard") from a drag - /// continuing inside a field that was already focused (an ordinary - /// drag-to-select, unaffected). fn is_focused(&self, id: WeakWidget) -> bool; } -/// Helper shared by every `FocusHost` impl, so the double-click window is -/// one constant rather than one per backend. pub fn recent_click(last_click: &mut Instant) -> bool { let now = Instant::now(); let recent = (now - *last_click) < Duration::from_millis(300); @@ -45,18 +23,6 @@ pub fn recent_click(last_click: &mut Instant) -> bool { recent } -/// `PressStart`/`Pressing`/`PressEnd`, all for the left button -- what -/// [`Selector`]/[`Selectable`] register instead of [`CursorSense:: -/// click_or_drag`], so their shared handler (`on_press`, below) sees every -/// frame of a gesture and can tell a completed tap from a drag itself, -/// rather than reacting to `PressStart` alone the way `click_or_drag`'s -/// consumer used to (Iris, 2026-09-06: "if I swipe over the input bar it -/// brings up the keyboard"). -/// `CursorSense::Cancel` is in the set for the same reason `DragGesture` -/// registers it: if a scroll area or a list takes the pointer mid-gesture, -/// this field sees no `PressEnd`, and a `press_origin` left set is then -/// compared against the *next* press -- a stray selection, or a keyboard -/// summoned by a tap somewhere else entirely. fn press_track() -> CursorSenses { CursorSense::click() | CursorSense::Pressing(CursorButton::Left) @@ -173,22 +139,6 @@ fn on_press( ctx.text.press_origin = None; } ctx.select(pos, size, true, false); - // A tap on a field that is *already* focused asks for the - // keyboard again (Iris's phone, 2026-09-06: "I can't reopen - // keyboard by tapping on message box after it already - // happened once"). Dismissing the IME -- back gesture, or - // its own hide button -- takes the keyboard away but leaves - // the field focused, so without this the one branch that - // requests it (the unfocused one below) never runs again - // and the field is permanently unable to summon it. - // Android's own `EditText` does exactly this: every tap on - // a focused field calls `showSoftInput`, which is a no-op - // when the keyboard is already up. - // - // Gated on the same tap-vs-drag test the unfocused branch - // uses, not on `PressEnd` alone, so a drag-to-select that - // happens to finish inside the field does not summon a - // keyboard the reader was not asking for. if ended && dx.abs() <= DRAG_SLOP && dy.abs() <= DRAG_SLOP { state.focus_gained(render.window_region(&id, &*rsc)); } @@ -208,9 +158,6 @@ fn on_press( if let Some(origin) = ctx.text.press_origin && ((pos.x - origin.x).abs() > DRAG_SLOP || (pos.y - origin.y).abs() > DRAG_SLOP) { - // Past the slop before release: this is a drag, not a tap - // -- give up the pending focus rather than granting it once - // the finger lifts wherever it happens to be by then. ctx.text.press_origin = None; } } diff --git a/src/default/access.rs b/src/default/access.rs index 8de405b..9909ddf 100644 --- a/src/default/access.rs +++ b/src/default/access.rs @@ -1,13 +1,3 @@ -//! I4 (RUST.md): the desktop half of the AccessKit push, over -//! `accesskit_winit`. `bench-lib.sh`'s tap-by-name goes through the -//! platform's real accessibility tree, so this crate only has to keep that -//! tree in sync with `ui::access::AccessTree`'s output -- nothing here -//! reacts to an AccessKit action request, which is why the three handlers -//! below are inert. See RUST.md's I4 box for why: on Android (and, by the -//! same platform convention, everywhere else) a screen reader's element tap -//! is a real touch delivered at the node's own bounds, not an action -//! request synthesised in-process -- so the ordinary pointer path already -//! handles it once the bounds are right. use accesskit::{ActionHandler, ActionRequest, ActivationHandler, DeactivationHandler, TreeUpdate}; pub struct NullActivationHandler; diff --git a/src/default/app.rs b/src/default/app.rs index 14ea8e5..4d9bc9b 100644 --- a/src/default/app.rs +++ b/src/default/app.rs @@ -27,9 +27,6 @@ pub struct App { impl App { pub fn run() { - // The desktop's `main` in everything but name -- see - // `super::logging`'s doc for why the logger goes here and what - // its absence hid. super::logging::install(log::LevelFilter::Info); let event_loop = EventLoop::with_user_event().build().unwrap(); let proxy = event_loop.create_proxy(); diff --git a/src/default/attr.rs b/src/default/attr.rs index 9d7484d..5ecfa1c 100644 --- a/src/default/attr.rs +++ b/src/default/attr.rs @@ -18,8 +18,6 @@ impl FocusHost for T { let state = self.default_state_mut(); let Some(region) = region else { return }; state.window.set_ime_allowed(true); - // Physical, like everything else this backend hands winit -- - // `default::content_scale`. state.window.set_ime_cursor_area( PhysicalPosition::::from(region.top_left.tuple()), PhysicalSize::::from(region.size().tuple()), diff --git a/src/default/logging.rs b/src/default/logging.rs index 7776151..75ee394 100644 --- a/src/default/logging.rs +++ b/src/default/logging.rs @@ -1,27 +1,3 @@ -//! A stderr logger for the desktop entry point. -//! -//! Without one, `log::` calls on this side go nowhere: `log`'s default is -//! a no-op logger, and nothing in `desktop-app` or the examples ever -//! installed a real one. That is how iris came to have a renderer that -//! silently fell back to GLES (and, on this VM, on to llvmpipe when the -//! host took its GPU away) with **no record anywhere of what -//! drew the frame** -- a layer-2 screenshot off llvmpipe and one off the -//! host GPU are the same PNG, and the difference is exactly what a -//! screenshot is being taken to judge. -//! -//! Installed by [`DefaultApp::run`](super::app::DefaultApp::run) rather -//! than by a library call somewhere, because that function already takes -//! over the process -- it owns the event loop and does not return -- so -//! it is the desktop's `main` in everything but name, and one install -//! there covers `desktop-app` and every example at once. `try_init` -//! rather than `init`: a binary that installed its own logger first keeps -//! it, and a second `DefaultApp::run` in one process is not an error. -//! -//! Deliberately not `env_logger`. All this owes the reader is a level and -//! a line, which is a page of code against a dependency plus its own -//! filter dialect; the Android side is `android_logger` for the same -//! reason -- one line per platform's own convention. - use std::io::Write; use log::{Level, LevelFilter, Log, Metadata, Record}; @@ -74,9 +50,6 @@ impl Log for StderrLogger { } } -/// Installs the stderr logger unless this process already has one. -/// Defaults to `info`, which is where the renderer says which adapter it -/// got; `RUST_LOG=debug` adds iris's own per-frame lines. pub fn install(default: LevelFilter) { let level = level_from_env(default); let logger = Box::leak(Box::new(StderrLogger { level })); diff --git a/src/default/mod.rs b/src/default/mod.rs index a5268c6..f0da693 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -39,12 +39,6 @@ pub type Proxy = EventLoopProxy; /// display whose scale factor is not 1.0, and it rasterised glyphs at /// one resolution to display them at another -- the blur the phone's own /// stopgap produced before `dp` existed. -/// -/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop -/// window runs the phone's density (`run-headless.sh --phone`, -/// docs/RUST.md's layer 2). An unparsable value is a typo in a command -/// somebody just typed, so it says so and uses the window's own answer -/// rather than silently laying out at the wrong density. pub fn content_scale(window: &Window) -> f32 { match std::env::var("IRIS_SCALE") { Err(_) => window.scale_factor() as f32, @@ -67,16 +61,7 @@ pub struct DefaultUiState { pub window: Arc, pub ime: usize, pub last_click: Instant, - /// I4 (RUST.md): pushed through in `DefaultApp::window_event`'s - /// `RedrawRequested` arm, from `access`'s output. Built in - /// `DefaultApp::new`, which is the only place with the - /// `&ActiveEventLoop` `accesskit_winit::Adapter::with_direct_handlers` - /// needs -- see that constructor's doc comment on why the window must - /// still be invisible when it is called. pub access_adapter: accesskit_winit::Adapter, - /// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc - /// comment for the flat shape and why it only rebuilds on a real - /// change. pub access: AccessTree, } @@ -228,12 +213,6 @@ impl AppState for DefaultApp { type Event = State::Event; fn new(event_loop: &ActiveEventLoop, proxy: EventLoopProxy) -> Self { - // `accesskit_winit::Adapter::with_direct_handlers` panics if the - // window is already visible when it's built, so the window is - // created hidden and only shown once the adapter exists -- the one - // extra step I4 (RUST.md) needs here. The three handlers are inert - // (see `access.rs`): a screen reader's tap is a real touch at the - // node's bounds, not an action request this process has to answer. let window = event_loop .create_window(State::window_attributes().with_visible(false)) .unwrap(); @@ -342,12 +321,6 @@ impl AppState for DefaultApp { let frame_start = std::time::Instant::now(); let animating = rsc.ui_mut().tick_animations(frame_start); let ui_state = state.default_state_mut(); - // Asked for before the work rather than after it, the same - // way `IrisViewPeer::render` does and for the same reason - // -- see the longer comment there. winit coalesces - // repeated requests, so the only thing the order changes - // is whether the request is in before this frame's draw - // can push it past a vsync boundary. if animating { ui_state.window.request_redraw(); } @@ -356,11 +329,6 @@ impl AppState for DefaultApp { let mut parts = ui_state.renderer.draw(); parts.total = frame_start.elapsed(); crate::diagnostics::log_frame(render, frame_start, parts, animating); - // I4 (RUST.md): only produces a `TreeUpdate` when the named - // set actually changed this frame -- see `AccessTree`'s doc - // comment. `render` reflects the draw that just happened, - // so `resolved_region`/`window_region` inside it report a - // moved subtree's *new* position, not last frame's. if let Some(tree_update) = ui_state.access.update(rsc.widgets(), render, rsc) { ui_state.access_adapter.update_if_active(|| tree_update); } @@ -369,16 +337,6 @@ impl AppState for DefaultApp { render.resize((size.width, size.height)); ui_state.renderer.resize(size) } - // Dragging the window to a display with a different scale. - // Both copies again, the pair `new` sets at startup -- read - // through `content_scale` rather than from the event, so - // `IRIS_SCALE` still pins the density it was given (the - // `--phone` window must not follow the monitor). winit sends - // the matching `Resized` separately. Before 2026-09-07 this - // event was unhandled, so every `dp` and every rasterised - // glyph stayed at the density the window opened on - // (review, 2026-09-07) -- invisible on this - // machine, where every display is 1.0. WindowEvent::ScaleFactorChanged { .. } => { let scale = content_scale(ui_state.window.as_ref()); rsc.ui.text.density = scale; diff --git a/src/default/platform.rs b/src/default/platform.rs index 75c2e7d..3ed1281 100644 --- a/src/default/platform.rs +++ b/src/default/platform.rs @@ -1,20 +1,11 @@ use crate::platform::OpenUrl; use crate::prelude::HasDefaultUiState; -/// The desktop's URL opener: the platform's own "open this with whatever -/// is registered for it" command, detached so a browser starting slowly -/// cannot stall the event loop. -/// -/// A command rather than a crate: `xdg-open`/`open`/`start` is what every -/// such crate shells out to anyway, and this is one call site. impl OpenUrl for T { fn open_url(&mut self, url: &str) { let (program, first): (&str, &[&str]) = if cfg!(target_os = "macos") { ("open", &[]) } else if cfg!(target_os = "windows") { - // `start` is a shell builtin, and its first argument is the - // window title -- an empty one, or a URL containing `&` ends - // up split. ("cmd", &["/C", "start", ""]) } else { ("xdg-open", &[]) @@ -25,8 +16,6 @@ impl OpenUrl for T { .spawn() { Ok(_) => {} - // Named with the command that failed and the link it was for, - // since neither is recoverable from the OS error alone. Err(e) => log::warn!("could not open {url} with {program}: {e}"), } } diff --git a/src/default/render.rs b/src/default/render.rs index 78deda1..cc393ec 100644 --- a/src/default/render.rs +++ b/src/default/render.rs @@ -68,12 +68,6 @@ impl UiRenderer { let submit_start = Instant::now(); self.queue.submit(std::iter::once(encoder.finish())); - // Immediately before presenting, so the windowing system can schedule - // the frame. On Wayland this is what ties the commit to the surface's - // frame callback; without it a frame drawn when nothing else follows - // could sit unpresented, and the window kept the layout it had before - // the compositor's first resize -- intermittently, on about a fifth of - // starts, with nothing left to flush it. self.window.pre_present_notify(); self.queue.present(output); FrameParts::waits(acquire, submit_start.elapsed()) @@ -83,7 +77,6 @@ impl UiRenderer { self.config.width = size.width; self.config.height = size.height; self.surface.configure(&self.device, &self.config); - // Physical, matching `new`'s own seed -- see the comment there. self.ui.resize( Vec2::new(size.width as f32, size.height as f32), &self.queue, @@ -117,16 +110,6 @@ impl UiRenderer { backends, ..InstanceDescriptor::new_with_display_handle(Box::new(window.clone())) }); - // The same fallback the Android backend grew in 85869d0, and for - // the same reason: a machine can advertise a Vulkan ICD with no - // device behind it, and refusing to draw at all because the only - // usable adapter is a GLES one is iris's bug rather than the - // machine's. On this VM the Vulkan device disappears whenever - // the host refuses a virtio-gpu context, so `run-headless.sh` -- - // layer 2 of the test rig -- aborted with `Could not get - // adapter!` while GL was sitting there working. Probed before the - // surface exists, matching Android, where an instance carrying - // both backends fails worse than one carrying the wrong one. if backends != Backends::GL && instance.enumerate_adapters(backends).block_on().is_empty() { log::warn!( "iris renderer: no {backends:?} adapter on this machine, falling back to GLES" @@ -154,14 +137,6 @@ impl UiRenderer { panic!("No usable GPU adapter for backends {backends:?}: {error}") }); - // Say which adapter won, in the same words the Android backend - // uses. Without it a layer-2 screenshot or frame time from this - // window carries no record of what drew it, and the two cases that - // matter look identical in the PNG: the host's real GPU, and - // llvmpipe after this VM lost its virtio-gpu contexts. That - // happened on 2026-09-08, and the only reason anyone noticed is - // that the fallback above did not exist yet and the app aborted - // instead. A silent fallback needs this line to stay honest. { let info = adapter.get_info(); log::info!( diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 7484ae0..44fd8e7 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -1,30 +1,3 @@ -//! The trace toggle for the `iris::input`/`iris::frame` diagnostics (Iris's -//! 2026-09-07 request: "add another button to copy input event info ... -//! instrument a lot of the code with timings"), and the one place both -//! call sites' `iris::frame` line is written from. -//! -//! **Why a crate-level flag instead of `log::log_enabled!`/ -//! `log::set_max_level`**: the app already installs its logger at -//! `LevelFilter::Debug` (`iris/android-app/src/lib.rs`'s `JNI_OnLoad`), so -//! a `log::Level::Debug` line reaches `client_core::log_ring`'s ring -//! regardless of what this instrument would prefer -- `RingLogger::enabled` -//! is unconditionally `true` by design (its own doc: "the ring wants -//! everything"). So the level alone cannot give these two targets a -//! default-off switch; the gate has to live on this side, checked before -//! `log::debug!` is even reached. -//! -//! **Why default off matters**: the ring is 2000 lines / 256 KiB -//! (`client_core::log_ring::DEFAULT_MAX_LINES`/`DEFAULT_MAX_BYTES`), and a -//! 120Hz session logging both a line per touch sample and a line per frame -//! fills that in seconds -- so a caller turns this on only for the length -//! of whatever is being investigated, and the report says so at its top -//! (a caller's job; see `iris::diagnostics::trace_enabled` used at the top -//! of whatever builds the report). -//! -//! **Not yet wired to a control**: the Diagnostics pane that would hold the -//! switch is in `iris/android-app/src/bench_client.rs`, which another agent -//! has open at the same time this was written. `set_trace` is the whole -//! surface a button needs; wiring one is a follow-up. use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Instant; @@ -32,17 +5,10 @@ use iris_core::{FrameParts, UiRenderState}; static TRACE: AtomicBool = AtomicBool::new(false); -/// Turns the `iris::input`/`iris::frame` `debug!` lines on or off. Off by -/// default -- see the module doc for why turning the level on alone would -/// not do it. pub fn set_trace(on: bool) { TRACE.store(on, Ordering::Relaxed); } -/// Whether the `iris::input`/`iris::frame` lines are enabled right now -- -/// what a report's header reads before deciding what to say about the -/// lines it does or doesn't hold (UI_RULES.md: "design the unknown state -/// first"). pub fn trace_enabled() -> bool { TRACE.load(Ordering::Relaxed) } diff --git a/src/event.rs b/src/event.rs index d79d32f..2a20e64 100644 --- a/src/event.rs +++ b/src/event.rs @@ -4,16 +4,10 @@ use std::sync::Arc; use crate::task::{TaskCtx, TaskUpdate, Tasks}; -/// A field's Enter key (without a shift, in a multi-line field). Backend -/// input handling raises it directly rather than through `on`, since a -/// field does not know ahead of time whether anything is listening. #[derive(Eq, PartialEq, Hash, Clone)] pub struct Submit; impl Event for Submit {} -/// A field's content changed as a result of input the backend applied -/// directly to it (a keystroke, an IME commit) rather than through a -/// widget event handler. #[derive(Eq, PartialEq, Hash, Clone)] pub struct Edited; impl Event for Edited {} @@ -44,13 +38,6 @@ impl, Rsc: HasEvents, Tag> Eventable for WL { widget_trait! { pub trait TaskEventable; - /// No `Data: Send` bound, deliberately: the registered handler below - /// takes `|_, rsc|` and the event's data never crosses into the - /// spawned future -- `AsyncEventIdCtx` carries the widget id and the - /// task handle and nothing else. The bound used to be here anyway, and - /// it was the whole reason `CursorData`'s pointer state was behind a - /// `Mutex` rather than owned by the input handler (Iris, 2026-09-08: - /// never reach for a lock first). fn task_on>( self, event: E, diff --git a/src/harness.rs b/src/harness.rs index e173e49..f75040d 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -1,33 +1,3 @@ -//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven -//! in-process with **no window, no compositor and no GPU**, on an -//! explicit clock and a replayed touch stream. -//! -//! `layout_tests.rs` and `sense_tests.rs` already build trees over -//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea -//! carried far enough to open a real app screen (`transcript-ui`'s, over -//! the bench fixture -- see the `transcript-fixture` crate) at the -//! phone's size and density, feed it a recorded flick, and assert on -//! where the list ended up. What it answers that the emulator cannot: -//! Android batches a 120Hz flick into one or two `MotionEvent`s -//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced -//! ones -- so the gesture shape a finger actually makes is only -//! reproducible from a *file* of timestamped samples. -//! -//! It is a third backend in the sense `default/` and `android/` are, and -//! deliberately the smallest one: the platform half of each of those -//! (a surface, an IME, a URL opener) becomes a recorded fact here -- -//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] -- -//! so a test can assert the platform *was asked*, which is the only -//! thing either backend does with those calls anyway. -//! -//! ```ignore -//! let mut h = Harness::new(phone_size(), PHONE_SCALE); -//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows); -//! h.frame(0); -//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?); -//! h.frames_until(20, 2_000, 8); -//! ``` - use crate::prelude::*; use std::marker::PhantomData; use std::sync::Arc; @@ -61,9 +31,6 @@ impl TouchAction { } } - /// The inverse of [`Self::parse`] -- what [`Harness::touch`] hands - /// [`crate::sense::log_input_event`], so an `iris::input` line and a - /// `.touch` file agree on one spelling of each action. pub fn word(self) -> &'static str { match self { Self::Down => "down", @@ -76,27 +43,16 @@ impl TouchAction { #[derive(Clone, Copy, Debug)] pub struct TouchSample { - /// Milliseconds since the start of the recording -- the sample's own - /// time, which becomes `CursorState::time`. See that field's doc for - /// why a replay may not date its samples by when the loop got to - /// them. pub t_ms: u64, pub action: TouchAction, pub pos: Vec2, } -/// A recorded gesture: one `t_ms action x y` line per sample, `#` and -/// blank lines ignored. Deliberately a plain text file rather than a -/// serialisation format -- it is written by hand as often as it is -/// recorded, and a diff of one has to be readable. pub struct TouchScript { pub samples: Vec, } impl TouchScript { - /// Parses a script, naming the line and what was wrong with it: these - /// are hand-written files, so a typo is the ordinary case and - /// "expected 4 fields" without a line number is not enough to fix it. pub fn parse(text: &str) -> Result { let mut samples: Vec = Vec::new(); for (i, line) in text.lines().enumerate() { @@ -134,16 +90,11 @@ impl TouchScript { Ok(Self { samples }) } - /// The last sample's time, i.e. how long the recording runs. pub fn end_ms(&self) -> u64 { self.samples.last().map(|s| s.t_ms).unwrap_or(0) } } -/// Counts the frames something asked for without drawing any -- the -/// harness's `RequestRedraw`. A `LazySpan` coasting through a fling asks for -/// the next frame through this (`UiData::animate` and `Widget::tick`), so a test can -/// tell "nothing moved" from "nothing was even asked to move". #[derive(Default)] pub struct RedrawCounter(AtomicUsize); @@ -159,8 +110,6 @@ impl RequestRedraw for RedrawCounter { } } -/// The harness's app state: what each real backend keeps for the platform -/// half, recorded instead of performed. pub struct HarnessState { pub root: Option, pub focus: Option>, @@ -171,7 +120,6 @@ pub struct HarnessState { /// available here, so this says what was *asked*, and a test must not /// read it as "the IME is up". pub keyboard_shown: usize, - /// Every URL a tapped link asked the platform to open, in order. pub opened_urls: Vec, } @@ -296,8 +244,6 @@ pub struct Harness { task_recv: TaskMsgReceiver, redraws: Arc, cursor: CursorState, - /// Time zero. Every `t_ms` in this harness is an offset from here, so - /// nothing reads the wall clock -- see [`Self::at`]. base: Instant, size: Vec2, } @@ -345,15 +291,10 @@ impl Harness { self.size } - /// How many frames were asked for so far -- see [`RedrawCounter`]. pub fn redraws(&self) -> usize { self.redraws.count() } - /// One frame at `t_ms`: drain finished tasks, advance anything - /// animating, lay out and "draw". The same three steps - /// `DefaultApp::window_event`'s `RedrawRequested` arm and - /// `IrisViewPeer::render` take, minus handing primitives to a GPU. pub fn frame(&mut self, t_ms: u64) { while let Ok(update) = self.task_recv.try_recv() { update(&mut self.state, &mut self.rsc); @@ -376,9 +317,6 @@ impl Harness { ); } - /// Frames every `step_ms` up to and including `end_ms` -- what a - /// fling needs, since it moves only while something ticks it - /// (`ScrollController::fling`'s doc). Returns the time of the last frame run. pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { debug_assert!(step_ms > 0, "a frame loop with no step never ends"); let mut t = from_ms; @@ -389,10 +327,6 @@ impl Harness { t - step_ms } - /// One pointer sample through the sensors, then the frame it belongs - /// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one - /// call. Each sample is its own input frame, dated by the sample - /// rather than by when this ran. pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) { self.cursor.time = self.at(t_ms); self.cursor.pos = pos; @@ -403,18 +337,11 @@ impl Harness { } TouchAction::Move => {} TouchAction::Up => self.cursor.buttons.left.update(false), - // The platform taking the gesture away, not the finger - // lifting -- see `CursorState::cancelled`. TouchAction::Cancel => { self.cursor.buttons.left.update(false); self.cursor.cancelled = true; } } - // Layer 1's half of `iris::input` (`sense::log_input_event`'s own - // doc): no batching happens here, so `historical` is always empty - // and `t_ms` is the script's own column, which is what makes this - // round-trip through `report_to_touch.py` back into an identical - // `TouchScript`. crate::sense::log_input_event(action.word(), pos.x, pos.y, t_ms, &[]); let cursor = self.cursor.clone(); self.render @@ -423,10 +350,6 @@ impl Harness { self.cursor.end_frame(); } - /// Replays a whole recorded gesture. Nothing is inserted between the - /// samples: a file with three lines produces three input frames, so - /// the batched shape a real flick arrives in is preserved exactly as - /// recorded rather than smoothed into evenly-spaced motion. pub fn replay(&mut self, script: &TouchScript) { for sample in &script.samples { self.touch(sample.action, sample.pos, sample.t_ms); diff --git a/src/layout_tests.rs b/src/layout_tests.rs index dfb8782..85ad7a4 100644 --- a/src/layout_tests.rs +++ b/src/layout_tests.rs @@ -1,16 +1,6 @@ -//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests -//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do -//! not touch a GPU or a window, so a tree can be built and driven directly. -//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the -//! CPU-side layout/move machinery LAYOUT.md is about. - use crate::prelude::*; use std::{cell::Cell, cell::RefCell, rc::Rc}; -/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the -/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so -/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a -/// second copy of the same harness. pub(crate) struct TestRsc { pub(crate) ui: UiData, } @@ -94,14 +84,6 @@ impl Widget for TracedParent { } } -/// Minimal reproduction for a container's child-layer cursor being retained -/// as though it were the layer on which the container itself was entered. -/// -/// `Stack` is drawn by `Sized` on layer 0. It advances its painter to layers -/// 1 and 2 for its two children. The retained `ActiveData` must still say the -/// stack itself is on layer 0; otherwise an ordinary redraw of `Sized` asks -/// for the stack on 0 again and turns the invented 2 -> 0 change into a full -/// redraw of the stack and both children. #[test] fn a_widget_retains_its_entry_layer_not_its_child_cursor() { let mut rsc = TestRsc { @@ -198,10 +180,6 @@ fn a_parent_that_ignores_child_size_is_not_invalidated_with_it() { assert_eq!(&*trace.borrow(), &["child"]); } -/// A content-sized vertical container grows vertically when one child grows, -/// but that does not invalidate another child's retained height. Its width is -/// the context that could change that height (for example through wrapping), -/// and that stayed fixed. #[test] fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() { let mut rsc = TestRsc { @@ -223,9 +201,6 @@ fn a_span_reuses_unchanged_sibling_sizes_when_only_its_along_extent_changes() { dir: Dir::DOWN, gap: Len::ZERO, }); - // `Sized` settles its child from the full offered box into the content - // height, reproducing the retained-region change a nested content-sized - // row sees when one of its children grows. let root = rsc .ui .widgets @@ -264,9 +239,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() { offset: vec2(0.0, 15.0), }); let parent_weak = parent.weak(); - // Keep the coordinate-owning widget below the root: a nested widget is - // normally redrawn when its parent visits it, which takes a different - // retained-state path from `UiRenderState::redraw` on the root itself. let outer = rsc.ui.widgets.add_strong(Sized { inner: parent.any(), x: None, @@ -285,8 +257,6 @@ fn a_child_coordinate_offset_moves_only_the_child_subtree() { assert!((child_before.top_left.y - 15.0).abs() < 0.01); rsc.ui.widgets.get_mut(&parent_weak).unwrap().offset.y = 35.0; - // Force the ordinary ancestor-redraw path rather than letting the - // renderer visit only the dirty descendant directly. rsc.ui.widgets.get_mut(&outer_weak).unwrap().x = None; render.update(&root, &mut rsc); let (draws, rewrites, moves, _shapes) = render.take_counters(); @@ -332,13 +302,6 @@ fn a_hinted_rest_draws_once_and_only_moves_the_fixed_child_after_it() { assert!((last.top_left.y - 260.0).abs() < 0.01, "{last:?}"); } -/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large -/// enough that an O(N) regression in the move path would show up as a -/// non-trivial counter rather than being lost in noise (LAYOUT.md section -/// 8, condition 3, using rects rather than glyphs to avoid pulling the font -/// stack into a plain unit test). Returns the scroll widget (weak, for -/// mutating it later), the erased root to draw, and the rows (weak, for -/// hit-testing one of them). fn scrolled_rects( rsc: &mut TestRsc, n: usize, @@ -348,9 +311,6 @@ fn scrolled_rects( for _ in 0..n { let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); rects.push(rect.weak()); - // Each row gets a fixed height so the span's total content is - // genuinely taller than the viewport -- rest-sized rows would just - // divide whatever space is offered and never need scrolling. let row = rsc.ui.widgets.add_strong(Sized { inner: rect.any(), x: None, @@ -362,14 +322,6 @@ fn scrolled_rects( let scroll = rsc .ui .widgets - // Anchored at the *start*: every test below scrolls down from - // the top and states its sign convention against that. An - // end-anchored area now sits at its end from its first drawn - // frame (`Scroll::draw` measures and places in the same frame), - // so `Pin::End` here would mean scrolling down from a - // position that is already the bottom -- a clamped no-op, which - // reads as "the move path is broken" rather than as the test - // starting somewhere it did not mean to. .add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start)); let weak = scroll.weak(); (weak, scroll.any(), rects) @@ -385,12 +337,6 @@ fn an_unchanged_frame_draws_and_rewrites_nothing() { render.resize((800.0, 20000.0)); render.update(&root, &mut rsc); - // Two, not one: the first offers `ScrollArea`'s content the container's - // own length as a placeholder (nothing has been measured yet) and - // `Scroll::draw` asks to be drawn again once it knows the real one, - // which the second update is. Only after that is the tree settled -- - // see `scrolling_moves_in_o1_without_a_redraw`'s own note on the - // same first draw. render.update(&root, &mut rsc); render.take_counters(); // discard the first, real draws @@ -408,35 +354,15 @@ fn scrolling_moves_in_o1_without_a_redraw() { let mut render = UiRenderState::new(); render.resize((800.0, 600.0)); - // The first draw offers `ScrollArea`'s content a zero-height region - // (nothing has been measured yet) and learns the real content length - // from what comes back; `update()` only redraws widgets actually - // marked dirty, so that corrected length is not reflected in the - // content's own *active* region until something -- here a no-op - // scroll tick -- actually asks `ScrollArea` to redraw again. Only after - // that warm-up does the content's offered size stop changing between - // draws, which is what makes a further, real scroll tick a same-size - // move instead of a resize. See scroll.rs. render.update(&root, &mut rsc); rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); render.update(&root, &mut rsc); render.take_counters(); - // Negative: `scroll`'s sign convention subtracts from `amt`, and - // `amt` starts at (and is clamped to) 0 at the top of the content, so - // a *positive* argument here would be scrolling further up (a no-op, - // already clamped) rather than actually moving anything. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0); render.update(&root, &mut rsc); let (draws, _rewrites, moves, _shapes) = render.take_counters(); - // The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and - // 1 move_offsets write, independent of how many rects are in the - // scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is - // marked dirty by `scroll()` and its own body is cheap arithmetic with - // no primitives of its own, so it is the one real `Widget::draw` this - // counts -- the 500 rects underneath move via the O(1) chain and are - // never revisited. assert_eq!(draws, 1, "only Scroll itself should redraw"); assert_eq!(moves, 1, "the scrolled subtree should move in one write"); } @@ -463,25 +389,12 @@ fn hit_testing_follows_a_scrolled_widget() { let before_px = before.to_px((800.0, 600.0).into()); let after_px = after.to_px((800.0, 600.0).into()); - // Scrolling by -37 moves `amt` from 0 to 37, sliding the content's - // top-left up by 37px -- `resolved_region` (the CPU twin of the vertex - // shader's chain walk) must reflect that immediately, not the - // pre-scroll position, or a tap routed through it would land on - // whatever is now at the old coordinates instead of this widget. assert!( (after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01, "before={before_px:?} after={after_px:?}" ); } -/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one -/// it set for itself -- `redraw` feeds it straight back in as the inherited -/// mask, so storing the set one hands a `Masked` its own mask the second -/// time round -- which `Painter::set_mask` asserts against, since a mask -/// that chains to itself is a clip loop. That was an abort the first time -/// the composer's new scroll area was redrawn on the emulator; a targeted -/// redraw of a `Masked` is what any real screen does whenever anything -/// inside it changes. #[test] fn redrawing_a_masked_widget_does_not_nest_its_own_mask() { let mut rsc = TestRsc { @@ -536,27 +449,10 @@ fn a_mask_stays_put_while_its_scrolled_content_moves() { let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot; let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta; - // `Masked` itself is never the target of a `mov`/`place` here -- - // only its scrolled child is -- so the slot its own mask references - // (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must - // still read zero after the scroll. The visible counterpart of this - // (the clipped edge follows the scroll while the viewport border does - // not) is `iris/run-headless.sh`'s job to catch in a real frame; this - // is the numeric half, on the same data the fragment shader's - // `resolve_move` reads. See LAYOUT.md section 2b. assert_eq!(mask_delta_before, [0.0, 0.0]); assert_eq!(mask_delta_after, [0.0, 0.0]); } -/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape -/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded, -/// `rest`-width `TextEdit`, itself the second child of an outer -/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/ -/// resource plumbing `composer.rs`'s builders need, to isolate whether the -/// bug Iris reported on 2026-09-06 ("text seems to not appear in box") -/// is this crate's layout engine or something specific to the real -/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain -/// insert exercises the exact redraw path a keystroke does. fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget) { let field = wtext("") .editable(EditMode::MultiLine) @@ -574,12 +470,6 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget) (field, tree) } -/// The reproduction itself. A window this tall stands in for the keyboard -/// closed; the second, shorter `resize` stands in for `adjustResize` -/// shrinking the surface when the IME opens -- exactly the sequence -/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing -/// happens both before and after, since Iris's report was specifically -/// that text typed *after* the keyboard was already up did not appear. #[test] fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { let mut rsc = TestRsc { @@ -590,11 +480,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { render.resize((1080.0, 2298.0)); render.update(&root, &mut rsc); - // Focusing a field is what places its caret on a real tap - // (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert - // with no caret is a routing bug rather than a state to simulate -- - // `insert_str`'s own `debug_assert!` says so, and caught this test - // typing into an unfocused field when it was added. field .edit(&mut rsc) .select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false); @@ -602,8 +487,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { render.update(&root, &mut rsc); let before_px = render.window_region(&field, &rsc).unwrap(); - // The field is one line plus 12dp of padding on a 2298-tall window -- - // nowhere near the whole window's height, and anchored at the bottom. assert!( before_px.bot_right.y - before_px.top_left.y < 200.0, "before a resize: {before_px:?}" @@ -613,10 +496,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { "expected the bar near the bottom before a resize: {before_px:?}" ); - // The keyboard opens: a real `surface_changed`/`resize` to a shorter - // window, then a further keystroke -- the redraw that must land in the - // bar's new (also short) region, not whatever region a provisional - // provisional placement used along the way. render.resize((1080.0, 1478.0)); render.update(&root, &mut rsc); field.edit(&mut rsc).insert("b"); @@ -633,13 +512,6 @@ fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() { ); } -/// `ScrollArea` used to be documented as resolving its own lengths against -/// `Painter::output_size` -- the window -- which read as if a scroll area -/// smaller than the screen could not work, and cost a session's -/// investigation before the composer was wired up (docs/RUST.md, -/// 2026-09-06). It measures `painter.px_size()` now, so this pins the -/// three numbers that follow from the offered box: what it reports -/// upward, what its capping parent reports, and how far it can pan. #[test] fn a_scroll_measures_the_box_it_was_offered_not_the_window() { let mut rsc = TestRsc { @@ -654,8 +526,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { let scroll = rsc .ui .widgets - // Start-anchored, so the `scroll(-37.0)` below has somewhere to - // go -- see `scrolled_rects`' note on the same choice. .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); let scroll_w = scroll.weak(); let scroll_id = scroll.id(); @@ -669,17 +539,10 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { let mut render = UiRenderState::new(); render.resize((800.0, 600.0)); - // Two passes: the first offers the content a zero-length region - // (nothing measured yet) and learns the real content length from what - // comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why - // that warm-up is deliberate rather than a bug. render.update(&root, &mut rsc); rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0); render.update(&root, &mut rsc); - // Reports the *content*, so the cap above it has something to cap; - // reporting the container instead would make the answer a function of - // itself, since the container is sized from this very number. assert_eq!( render.active.get(&scroll_id).unwrap().size.y, Len::abs(1000.0) @@ -690,10 +553,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { "the cap, not the content and not the window" ); - // Panning is bounded by content minus *container*: 900, not the 400 - // a 600px window would give. The draw is what spends the delta -- a - // controller banks it until the layout that knows where the content - // ends (`ScrollController::take_delta`). rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0); render.update(&root, &mut rsc); assert!( @@ -703,13 +562,6 @@ fn a_scroll_measures_the_box_it_was_offered_not_the_window() { ); } -/// The half `hit_testing_follows_a_scrolled_widget` could not see: it -/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own -/// `region` is stale and is corrected entirely by the move chain. The -/// moved widget itself had its `region` updated *and* the chain delta -/// added on top, so its hit box sat at twice the pan -- which is why a -/// finger pan of the composer left its field untappable. See -/// `ActiveData::move_applied`. #[test] fn a_panned_widgets_own_hit_box_moves_exactly_once() { let mut rsc = TestRsc { @@ -725,8 +577,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() { let scroll = rsc .ui .widgets - // Start-anchored, so the `scroll(-37.0)` below has somewhere to - // go -- see `scrolled_rects`' note on the same choice. .add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start)); let scroll_w = scroll.weak(); let root = scroll.any(); @@ -748,16 +598,6 @@ fn a_panned_widgets_own_hit_box_moves_exactly_once() { ); } -/// A `Masked` used to allocate a **new** mask slot on every draw, and -/// `draw_inner`'s unchanged-region fast path means its descendants are -/// mostly *not* redrawn with it -- so they went on referencing the slot -/// they were first drawn under, whose region had since stopped being the -/// widget's. Measured 2026-09-06 on the composer's tree: four live mask -/// entries, none of them the `Masked`'s current box, and the field it was -/// meant to clip drew nothing at all on the emulator. The slot is -/// allocated once and rewritten in place now (`ActiveData::own_mask`), so -/// this pins both halves: one entry, and that entry is the widget's own -/// region. #[test] fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { let mut rsc = TestRsc { @@ -769,9 +609,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { inner: inner_root, }); let masked_id = masked.id(); - // Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling, - // which is what moves the bar away from the provisional slot it is - // first drawn at -- the move that left the stale mask behind. let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK)); let filler = rsc.ui.widgets.add_strong(Sized { inner: filler.any(), @@ -808,14 +645,6 @@ fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() { ); } -/// A `dp` cap that has done its job must be reported in pixels. `Span` -/// places a child using the `abs`/`rel` of the length it reported, so a -/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's -/// bar a slot of **zero** the moment its content grew past six lines -- -/// and the `ScrollArea` inside then measured its container at -63px (the -/// padding, subtracted from nothing) and panned the whole message out of -/// view. Measured on this checkout's emulator, 2026-09-06: -/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`. #[test] fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() { let mut rsc = TestRsc { @@ -858,15 +687,6 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() { ); } -/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on -/// the branch that fix had no reason to touch: `draw_inner`'s -/// size-independent fast path rewrites a widget's primitives *in place* -/// and leaves its move slot alone, so unlike `mov` there is no slot delta -/// for `region` to have absorbed. Counting one there anyway makes -/// `resolved_region` subtract a delta the chain never held, and the -/// widget's hit box lands short of where it is drawn by exactly the -/// distance it just moved -- with nothing on screen to say so, since the -/// primitives are in the right place. #[test] fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() { let mut rsc = TestRsc { @@ -879,9 +699,6 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at( y: Some(Len::abs(100.0)), }); let spacer_w = spacer.weak(); - // `Rect` is `is_size_independent`, so growing the spacer above it - // offers this one a region that changed *both* position and size -- - // the one shape that reaches the branch under test. let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let below_w = below.weak(); let mut span = Span::empty(Dir::DOWN); @@ -909,17 +726,9 @@ fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at( ); } -/// A parent that both `mov`s a child (its own layout moved the box it -/// offers) and places it inside that box in the same frame -- what -/// `LazySpan::place`'s Bottom-known branch does once a row's cached height -/// stops matching what the row reports, which is reachable as soon as a -/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a"). struct MoveThenPlace { inner: StrongWidget, - /// Where the child is *offered* a (constant-size) box, moved between - /// frames by the test. offer_top: f32, - /// Where the child is then placed within this widget's own region. place_top: f32, } @@ -945,7 +754,6 @@ impl Widget for MoveThenPlace { } } -/// Placement must preserve a move already applied in the same frame. #[test] fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() { let mut rsc = TestRsc { @@ -975,10 +783,6 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement "the child should be drawn where it was placed, not where it was offered: {before:?}" ); - // Move the offered box without changing its size (the `mov` fast path) - // and place the child at the same spot as before. Marking the parent - // dirty is what a real container's own content change does; the child - // itself is untouched, which is the case `mov` exists for. { let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap(); parent.offer_top = 200.0; @@ -993,22 +797,8 @@ fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement ); } -// --------------------------------------------------------------------- -// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1. -// -// The shape a mask clips to is a *primitive already drawn*, never a copy -// of one, so "the child's clipped corner" and "the container's own corner" -// are the same arithmetic. These say so by evaluating both and demanding -// exact equality: an approximate assertion would also pass a second copy -// of the radius that merely happened to agree. -// --------------------------------------------------------------------- - const RADIUS: f32 = 20.0; -/// A rounded container with `.masked_by` it, holding a `Rect::REST` child -/// that fills it -- so the child's own corners are exactly the corners -/// being clipped away. Returns the drawn state, the mask, the child, and -/// the shape primitive the mask points at. fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) { let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let child_id = child.id(); @@ -1046,9 +836,6 @@ fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u3 (render, mask, child_id, slot) } -/// The pass condition: the child's coverage at a corner pixel *equals* -/// the container's own coverage there. Exactly equal, because it is the -/// same primitive evaluated once -- LAYOUT.md's point 1. #[test] fn a_masked_child_is_clipped_by_its_container_s_own_corner() { let mut rsc = TestRsc { @@ -1062,12 +849,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() { .expect("a mask's shape is a rect") .radius; - // Across the whole corner arc, not one point on it: a single sample - // is satisfied by a mask that clips to the box and happens to agree - // where the two coincide. Swept from the arc's own centre -- the - // straight chord between the two ends of the arc lies *inside* the - // circle everywhere, so a walk along it never leaves the shape and - // the `outside` count below is what caught that. let arc_center = corners.top_left + Vec2::new(radius, radius); let (mut outside, mut inside) = (0, 0); for i in 0..=20 { @@ -1095,9 +876,6 @@ fn a_masked_child_is_clipped_by_its_container_s_own_corner() { ); } -/// A hit test asks the same question the pixels do: the corner the -/// container rounded away is not there to be pressed, and a point just -/// inside the curve is. LAYOUT.md's point 4. #[test] fn a_mask_s_shape_decides_what_can_be_pressed() { let mut rsc = TestRsc { @@ -1106,20 +884,16 @@ fn a_mask_s_shape_decides_what_can_be_pressed() { let (render, mask, _child, slot) = rounded_container(&mut rsc); let corners = render.primitive_corners(slot, &rsc); - // The very corner of the box, which the radius cut off. let cut = corners.top_left + Vec2::new(1.0, 1.0); assert!( !render.mask_admits(mask, cut, &rsc), "the corner the container rounded away is still pressable", ); - // The same distance in along the diagonal, past the curve. let inside = corners.top_left + Vec2::new(RADIUS, RADIUS); assert!( render.mask_admits(mask, inside, &rsc), "a point well inside the curve is not pressable", ); - // And the middle of an edge, which no radius touches -- the half the - // rounding had no reason to change. let edge = Vec2::new( (corners.top_left.x + corners.bot_right.x) / 2.0, corners.top_left.y + 1.0, @@ -1130,11 +904,6 @@ fn a_mask_s_shape_decides_what_can_be_pressed() { ); } -/// Nested masks multiply, so a pixel inside two feathered corners is -/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be -/// decreased / multiplied" Iris asked for. Written as a product of the -/// two the shader would compute separately, which is what "multiply" -/// means and what an intersection test would get wrong. #[test] fn nested_masks_multiply_their_coverage() { let mut rsc = TestRsc { @@ -1182,8 +951,6 @@ fn nested_masks_multiply_their_coverage() { rounded_rect_coverage(pos, c.top_left, c.bot_right, radius) }; - // A point on the corner arc, where both feathers are partial -- the - // only place a product and a minimum differ. let slot = render.first_primitive(inner_shape_id).unwrap(); let corners = render.primitive_corners(slot, &rsc); let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3); @@ -1200,11 +967,6 @@ fn nested_masks_multiply_their_coverage() { ); } -/// A plain `.masked()` -- no shape given -- still clips to the widget's -/// own box with square corners, which is what every list and scroll area -/// relies on. The half the shape work had no reason to touch, and the one -/// that would silently round every existing clip if `set_mask` ever wrote -/// a radius of its own. #[test] fn a_plain_mask_still_clips_to_a_square_box() { let mut rsc = TestRsc { @@ -1236,16 +998,6 @@ fn a_plain_mask_still_clips_to_a_square_box() { ); } -/// A scroll area created to be *read* opens at the beginning of its -/// content, however many frames it takes to learn how long that content -/// is. -/// -/// The bug this pins: `content_len` was `0.0` both for "nothing here" and -/// for "not drawn yet", so the first frame's clamp found a range of zero, -/// read `amt == len` as "sitting at the end", and set `snap_end` -- and -/// the frame after, now knowing the real length, jumped to it. On screen -/// that was a code fence opening at the end of its longest line, in the -/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08). #[test] fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] { @@ -1267,10 +1019,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { let mut render = UiRenderState::new(); render.resize((800.0, 100.0)); - // Twice: the first draw is the one that measures the content, and - // the defect only showed on the second. The touch in between is - // what asks for that second draw -- an unchanged frame draws - // nothing at all, which is the point of the frame before it. render.update(&root, &mut rsc); let _ = rsc.ui.widgets.get_mut(&weak); render.update(&root, &mut rsc); @@ -1283,17 +1031,6 @@ fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() { } } -/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another -/// `Span` places those children a slot out of step", worked around in -/// `transcript-ui/src/tool.rs` by flattening the two spans into one -- -/// which costs a tool group the inset its cards should sit inside. -/// -/// The shape is the smallest one that reproduced it there: an outer -/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children -/// are each a `Pad` around a fixed-height rect. Each rect is asserted to -/// be *drawn* where its own box is -- `primitive_corners` rather than -/// `window_region`, since the report is about what is on screen and the -/// two resolve the move chain differently. #[test] fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() { const PAD: f32 = 4.0; @@ -1376,11 +1113,6 @@ fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() { } } -/// Growing an already-drawn row first measures its new child against the -/// row's old height. That provisional box can end before it starts when the -/// old trailing edge is above the new child's cursor. The size must bubble to -/// `LazySpan` and the corrected allocation must travel back down before this -/// update is presented; a later stream event is not a layout pass. #[test] fn a_new_child_in_a_growing_lazy_row_uses_its_final_box_immediately() { const FIRST: f32 = 30.0; diff --git a/src/lib.rs b/src/lib.rs index 37eb711..af647fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,13 +7,6 @@ #![feature(option_into_flat_iter)] #![feature(async_fn_traits)] -// Two windowing backends live side by side, chosen by target rather than by -// feature flag: winit everywhere but Android, android-view on it. They are -// mutually exclusive rather than both-compiled-in because winit's own -// Android support pulls in `android-activity`, which needs one of its -// `game-activity`/`native-activity` features selected -- exactly what -// `iris-core` was kept free of, and android-view is the framework's own -// answer to the same surface on that platform. See RUST.md's I2. #[cfg(target_os = "android")] pub mod android; #[cfg(not(target_os = "android"))] diff --git a/src/platform.rs b/src/platform.rs index 86ec5ad..058df8c 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,22 +1,3 @@ -//! Capabilities a widget tree needs from whatever is hosting it, that -//! neither iris nor the app can perform itself. -//! -//! Same shape as [`crate::attr::FocusHost`], and for the same reason: the -//! interface is declared here, below, and implemented by each backend -//! above (`default/platform.rs`, `android/platform.rs`), so a widget can -//! ask for the capability by trait bound instead of a caller threading a -//! callback down through every builder. - -/// Hand a URL to whatever the platform opens URLs with. -/// -/// One method rather than a general "run an intent"/"exec" surface: the -/// only thing a transcript needs is to follow a link a reader tapped, and -/// a narrower capability is a narrower thing to get wrong. -/// -/// **Nothing is reported back.** There is no answer worth branching on -- -/// the platform either shows a browser or does not, and both are outside -/// this process -- so failures are logged where they happen (each impl) -/// rather than turned into a `Result` every call site would discard. pub trait OpenUrl { fn open_url(&mut self, url: &str); } diff --git a/src/sense.rs b/src/sense.rs index 9f3729a..61e8d9b 100644 --- a/src/sense.rs +++ b/src/sense.rs @@ -22,27 +22,9 @@ pub enum CursorSense { Hovering, HoverEnd, Scroll(Axis), - /// Delivered exactly once, in place of `PressEnd`, to whichever widget - /// currently holds pointer capture (`UiRenderState::capture_pointer`) - /// when the button lifts -- see `iris::sense`'s pointer-capture doc - /// and `DragGesture`. A widget must register this explicitly (it is - /// never bundled into `click_or_drag`/`unclick`, since most widgets - /// never call `capture_pointer` and have no use for it) to receive it - /// at all; ordinary hit-tested widgets keep seeing `PressEnd`. + /// Terminal event replacing `PressEnd` for the pointer captor. Drop, - /// Delivered exactly once to a widget that was tracking this press - /// when **another** widget took pointer capture - /// (`UiRenderState::capture_pointer`): the gesture it was following - /// has been taken away and it will see no further frame of it, not - /// even a `PressEnd` or a `Drop`. - /// - /// A separate sense rather than a second meaning for `Drop`, because - /// the two say opposite things to the widget reading them: `Drop` is - /// "your gesture finished", and a widget acts on it (a fling, a tap, - /// a link followed), while `Cancel` is "your gesture was never - /// yours", and acting on it is exactly the bug -- a horizontal pan of - /// a code fence would follow whatever markdown link the finger - /// happened to go down on. Registered explicitly, like `Drop`. + /// Another widget captured this press; abandon it without acting. Cancel, } @@ -57,23 +39,8 @@ impl Event for CursorSenses { type State = SensorState; type Global = PointerInput; fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option> { - // `Drop` is never derived from raw cursor/hover state below (the - // free `should_run`'s own arm for it is only ever asked here, - // never independently true or false against the button) -- it is - // set exclusively by `run_sensors`' pointer-capture branch, which - // has already decided this exact frame is the captured widget's - // terminal event. Matching it by identity, ahead of the general - // derivation, matters because a captured widget's registration - // list very likely also carries `PressEnd` (`unclick()`, for the - // ordinary un-captured case) -- the same button-lift condition - // `PressEnd` matches on, so falling through to the loop below - // would let whichever of the two happens to be registered first - // win, silently swallowing the `Drop` a caller relied on. - // The same argument as `Drop` immediately above, for the same - // reason: `run_sensors` has already decided this frame is a - // cancellation for this widget, and the registration list very - // likely also carries `Pressing`, which the loop below would - // match against a button that is still down. + // 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()); } @@ -108,8 +75,6 @@ impl CursorSenses { return false; }; match (self.drag_axis, sense) { - // Directional drags are candidates until movement chooses an - // axis. Only the matching one consumes its visual layer. (Some(axis), CursorSense::Pressing(_)) => data.captured || data.drag_axis == Some(axis), (Some(_), CursorSense::PressStart(_) | CursorSense::PressEnd(_)) => false, _ => sense.is_momentary(), @@ -127,26 +92,11 @@ impl CursorSense { pub fn unclick() -> Self { Self::PressEnd(CursorButton::Left) } - /// What a widget driving a [`DragGesture`] must register: the frames - /// of the gesture, plus **both** of the ways it can end for that - /// widget -- its own [`Self::Drop`] once it has captured the pointer, - /// and [`Self::Cancel`] if somebody else captured it first. - /// - /// One function rather than a set spelled out per call site, because - /// the two terminal senses are exactly what gets forgotten: a `ScrollArea` - /// registered `click_or_drag | unclick` and so never saw the end of - /// any gesture it had captured, which left its arbiter panning from a - /// stale position and made the *next* drag jump by the distance - /// between them -- Iris's "it keeps snapping back to some position - /// when horizontally scrolling" (docs/RUST.md, 2026-09-08). + /// Drag frames and both terminal outcomes: `Drop` and `Cancel`. pub fn drag_senses() -> CursorSenses { Self::click_or_drag() | Self::unclick() | Self::Drop | Self::Cancel } - /// The frames of a drag along one axis. Before the gesture crosses - /// [`DRAG_SLOP`], directional listeners observe without consuming a - /// visual layer; once its direction is known, only listeners for that - /// axis receive and consume it. pub fn drag(axis: Axis) -> CursorSenses { let mut senses = Self::drag_senses(); senses.drag_axis = Some(axis); @@ -156,15 +106,7 @@ impl CursorSense { matches!(self, CursorSense::Pressing(CursorButton::Left)) } - /// True for a sense that names a specific thing happening this frame - /// (a button transitioning, a scroll) as opposed to the ambient, - /// always-on-while-over `Hover*` family. Used to decide whether a - /// widget actually *consumes* an input for fall-through purposes: a - /// widget that merely highlights on hover must not be able to block a - /// scroll or a click meant for whatever is behind it, the way it - /// currently could when "the cursor is over this widget" and - /// "this widget handled the event" were the same check. See - /// `SensorUi::run_sensors`. + /// Whether this event may consume input; ambient hover never does. pub fn is_momentary(&self) -> bool { !matches!( self, @@ -179,40 +121,8 @@ pub struct CursorState { pub exists: bool, pub buttons: CursorButtons, pub scroll_delta: Vec2, - /// When this pointer state was *sampled*, from the platform's own - /// input clock -- not when the handler reading it happened to run. - /// - /// It exists because Android batches touch samples: a flick on a - /// 120Hz screen arrives as one or two `MotionEvent`s carrying the - /// intermediate positions as *historical* samples - /// (`getHistoricalX`/`getHistoricalEventTime`), which - /// `IrisViewPeer::on_touch_event` replays through the sensor pass one - /// at a time. Every one of those replays happens within the same few - /// microseconds, so a gesture timing itself with `Instant::now()` - /// would see a span of nearly zero across the whole flick and divide - /// by it -- the velocity would be an artefact of how fast we looped, - /// which is exactly the inferred-as-measured number UI_RULES.md - /// forbids. Carrying the sample's own time makes the span real. pub time: Instant, - /// The platform took this gesture away rather than the finger - /// finishing it -- Android's `ACTION_CANCEL`, or a - /// `TouchAction::Cancel` line in a `harness` replay. (The winit - /// backend drives a mouse, which the platform never takes away - /// mid-gesture, so it has nothing to set this from yet; a touch - /// path there would set it from `TouchPhase::Cancelled`.) - /// - /// It is **not** a release. Every widget tracking the press is sent - /// [`CursorSense::Cancel`], including whoever holds pointer capture, - /// and nothing follows from it: no tap, no selection, and above all - /// no fling. Reporting it as an ordinary `PressEnd` instead is what - /// made leaving the app move the transcript on Iris's phone - /// (2026-09-08): the swipe up from the bottom edge to go home is - /// delivered to the app as moves and then a cancel, so a cancel read - /// as a release handed the list the swipe's own velocity and it flung - /// while nobody was looking. - /// - /// Set for the one sample that carries it and cleared by - /// [`CursorState::end_frame`], like `scroll_delta`. + /// Platform cancellation, not a release; it must never produce a fling. pub cancelled: bool, } @@ -279,11 +189,6 @@ pub enum ActivationState { Off, } -/// this and other similar stuff has a generic -/// because I kind of want to make CursorModule generic -/// or basically have some way to have custom senses -/// that depend on active widget positions -/// but I'm not sure how or if worth it pub struct Sensor { pub senses: CursorSenses, pub f: Rc>, @@ -298,7 +203,6 @@ pub struct SensorState { #[derive(Clone)] pub struct CursorData<'a> { - /// where this widget was hit pub pos: Vec2, pub size: Vec2, pub scroll_delta: Vec2, @@ -307,37 +211,15 @@ pub struct CursorData<'a> { /// The direction selected after this press crossed [`DRAG_SLOP`]. /// `None` while the gesture is still only a press. pub drag_axis: Option, - /// Whether this sample bypassed hit testing for the pointer holder. pub captured: bool, - /// the first sense that triggered this pub sense: CursorSense, pub render: &'a UiRenderState, - /// The pointer itself, for the length of this dispatch -- who holds - /// exclusive input and how to ask for it. See [`PointerRequests`]. + /// Pointer-capture state for this dispatch. pub pointer: &'a PointerRequests, } -/// What the sensor pass knows about the pointer itself rather than about -/// any one listener: who has exclusive input, and who is tracking the -/// press in flight. [`Event::Global`] for [`CursorSenses`], so it is owned -/// by the event manager that runs the dispatch and reached by `&mut` -- -/// there is no lock and no copy of it anywhere else. -/// -/// **Capture** ([`PointerRequests::capture`]) gives one widget every later -/// sample of the gesture, so a pan or a selection keeps going once the -/// finger has moved off whatever hit region first noticed the press -- -/// including right off the end of it, which is what used to leave a fling -/// never started because no widget saw the release. -/// -/// **`pressed`** is capture's other half: every widget that has been -/// handed a frame of this press and not yet been told it ended. Taking the -/// pointer is a one-way door for all of them -- they see no `PressEnd` and -/// no `Drop` -- so each is sent one [`CursorSense::Cancel`], the way -/// Android sends `ACTION_CANCEL` and the web sends `pointercancel`. -/// Without it a gesture is left open forever with a stale origin, and the -/// *next* touch anywhere on screen is measured from it: Iris's 2026-09-08 -/// phone report, where a horizontal pan inside a code fence made the -/// transcript jump on the following tap (docs/RUST.md). +/// 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, @@ -383,27 +265,18 @@ pub struct PointerRequests { } impl PointerRequests { - /// Give `id` exclusive pointer input from the next dispatch on. `id` - /// must be a widget that outlives the gesture -- a `LazySpan`'s own id, - /// not one of its virtualised rows, which can be retired mid-drag as - /// content scrolls. Overwrites any previous capture: a gesture that - /// starts a new one has already decided the old one is over, and the - /// old holder is told so with [`CursorSense::Cancel`]. + /// 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. Called for the capturing widget by - /// `run_sensors` itself once it has delivered the terminal - /// [`CursorSense::Drop`], or by that widget if it decides the gesture - /// is over some other way. + /// Give up exclusive pointer input. pub fn release(&self) { self.holder.set(None); } - /// Which widget holds exclusive pointer input, as of this moment in - /// the dispatch. What a widget checks before releasing, so it cannot - /// drop a capture that is somebody else's. + /// The current holder, used to avoid releasing another widget's capture. pub fn holder(&self) -> Option { self.holder.get() } @@ -427,16 +300,8 @@ impl SensorUi for UiRenderState { cursor: CursorState, window_size: Vec2, ) { - // `iris::frame`'s `since_input` (`iris::diagnostics::log_frame`) - // reads this back -- every backend's sensor dispatch reaches here, - // so recording it once in the one place they share is what keeps - // it from needing a copy per backend. self.note_input(cursor.time); - // The pointer's own state, taken out of the event manager for the - // length of this dispatch and put back at the end -- the same - // `mem::take` the `active` map below uses, and for the same - // borrow reason. `PointerRequests` is what a handler sees of it. let mut pointer: PointerInput = std::mem::take(&mut rsc.events_mut().get_type::().global); let requests = PointerRequests { @@ -460,13 +325,7 @@ impl SensorUi for UiRenderState { } } - // The platform took the gesture away (`CursorState::cancelled`). - // Everybody still tracking this press hears about it -- the - // capture holder included, which is what makes this different - // from the loser cancels at the end of an ordinary dispatch -- - // and no other sense is derived from this sample, because there - // is no press left to derive one from. Nothing may follow: not a - // tap, not a selection, and not a fling. + // Platform cancellation reaches every tracker and produces no other sense. if cursor.cancelled { let captured = pointer.captured.take(); let mut told: Vec = captured.into_iter().collect(); @@ -481,33 +340,12 @@ impl SensorUi for UiRenderState { for id in told { deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); } - // Deliberately not `requests.holder()`: a cancel handler has - // no gesture left to claim, so one that asked for the pointer - // anyway is refused here rather than left holding a capture - // that no release will ever reach. rsc.events_mut().get_type::().global = pointer; return; } - // Exclusive pointer capture (`PointerRequests::capture`): once - // some widget has committed to a drag, every other widget sees - // nothing from this pointer at all -- no hover, no click, no press - // -- until it releases. That is what lets a fast pan or a - // selection keep going once the finger has moved off whatever hit - // region first noticed the press (including right off the end of - // the gesture, at `PressEnd`/`Cancel`): a per-widget hit test - // would otherwise silently stop delivering to *anyone* the moment - // the pointer left every registered region, which is exactly what - // used to leave a fling never started (no widget ever saw the - // release). The captured widget keeps getting ordinary `Pressing` - // frames while the button is down and gets exactly one `Drop` -- - // not `PressEnd` -- the frame it lifts, which also releases the - // capture. + // Capture bypasses hit testing until release, when its holder receives one Drop. if let Some(id) = requests.holder() { - // The capture's path out for a widget that stopped being - // drawn mid-gesture -- a `LazySpan` row retired by virtualisation, - // a rebuilt subtree. Nothing can be delivered to an id with no - // region, so the gesture ends here for everyone. let Some(shape) = self.resolved_region(&id, rsc) else { pointer.captured = None; pointer.pressed.clear(); @@ -544,15 +382,6 @@ impl SensorUi for UiRenderState { return; } - // in order to remove this take, need to store active list in UiRenderState somehow - // this would probably be done through a generic parameter that adds yet another rsc / - // state like thing, but local to render state, and is passed to UiRsc events so you can - // update it there? - // Whether *something specific* is happening this frame (a button - // transitioning, a scroll) as opposed to the cursor merely resting - // over widgets. Only this decides whether a widget can block a - // lower layer from also seeing the event -- see the `consumed` - // comment below. let momentary_active = cursor.scroll_delta != Vec2::ZERO || cursor.buttons.iter().any(|(_, a)| !a.is_off()); @@ -562,15 +391,7 @@ impl SensorUi for UiRenderState { for (id, sensor) in active.get_mut(&layer).into_flat_iter() { let shape = self.resolved_region(id, rsc).unwrap(); let region = shape.to_px(window_size); - // The mask this widget is drawn under, applied with the - // same coverage the fragment stage clips it with - // (LAYOUT.md's "Masks with a shape", point 4): a corner - // rounded away by a container is not there to be pressed, - // and a row scrolled out of a list's box is not either. - // The box test stays because it is what says the pointer - // is over *this* widget rather than merely inside its - // clip -- the two ask different questions and both have - // to hold. + // 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 @@ -582,20 +403,8 @@ impl SensorUi for UiRenderState { continue; } - // A widget in shape always still runs (a hover-only - // highlight must fire on the topmost thing under the - // cursor even while a scroll or click passes through it), - // but whether it *consumes* the input -- stopping a lower - // layer from also seeing it -- is judged per input kind - // (LAYOUT.md's coordinator asked for this alongside the - // hit-test rewrite, since both are about `resolved_region` - // and what "under the pointer" means): with nothing - // momentary happening, "in shape" is consumption, same as - // before (the topmost widget wins an idle hover). With a - // scroll or a press/release actually happening, only a - // widget that registered a matching non-hover sense - // consumes it -- a button that only registered `click()` - // must not block a scroll meant for the list behind it. + // 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 { @@ -606,8 +415,6 @@ impl SensorUi for UiRenderState { cursor, drag_axis: pointer.drag_axis, captured: false, - // this does not have any meaning; - // might wanna set up Event to have a prepare stage sense: CursorSense::Hovering, render: self, pointer: &requests, @@ -621,12 +428,7 @@ impl SensorUi for UiRenderState { sensed = true; } rsc.run_event::(*id, data, state); - // Anything handed a frame while the button is down may - // have opened a gesture on it, and is owed a `Cancel` if - // somebody else captures the pointer -- see - // `PointerInput`. Recorded for every such widget rather - // than only the ones known to drag, because this layer - // cannot see what a handler did with the frame. + // 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); } @@ -639,11 +441,6 @@ impl SensorUi for UiRenderState { pointer.captured = requests.holder(); match pointer.captured { - // A capture taken during this frame's dispatch: every other - // widget tracking the same press is told, once, that it is - // over for them. Delivered after `active` is restored, since - // these are ordinary registered handlers being run outside - // the loop. Some(winner) => { let losers: Vec = pointer .pressed @@ -656,14 +453,9 @@ impl SensorUi for UiRenderState { deliver_cancel(self, rsc, state, loser, &cursor, window_size, &requests); } } - // The press ended without anyone capturing: everybody who saw - // it got their own `PressEnd`, so there is nothing to cancel - // and nothing to remember. None if !button_down => pointer.pressed.clear(), None => {} } - // A cancel handler may itself have captured (a widget deciding - // the gesture is now its own); `requests` is still the truth. pointer.captured = requests.holder(); if !button_down { pointer.press_origin = None; @@ -673,11 +465,6 @@ impl SensorUi for UiRenderState { } } -/// Hand one widget a [`CursorSense::Cancel`] -- the gesture it was -/// tracking has been taken by whoever captured the pointer. Silent if -/// the widget has no resolved region any more (it was retired in the -/// same frame), which is the same "nothing to deliver to" case -/// `run_sensors`' capture branch handles by releasing. fn deliver_cancel( render: &UiRenderState, rsc: &mut Rsc, @@ -713,30 +500,6 @@ pub fn should_run( drag_axis: Option, captured: bool, ) -> Option { - // Every sense below that is about the *pointer* rather than about - // hovering needs the pointer to actually be on this widget, and - // `hover` is the only thing here that knows: `run_sensors` runs a - // widget one more time after the pointer has left it (`ActivationState - // ::End`, which is not `Off`) so that a `HoverEnd` can fire, and - // deriving a press from raw button state alone handed that frame a - // `PressStart` too. A widget the finger is nowhere near then opened a - // gesture and, if it went straight to panning, captured the pointer -- - // which is the whole of Iris's 2026-09-08 "if I try to scroll - // vertically while a horizontal scroll animation is still active, it - // stays locked to the horizontal scroll". Measured: a fence flicked - // sideways is left `hover == On` (the capture branch above returns - // before the loop that would have updated it), so the *next* touch - // down anywhere on the screen decayed it to `End`, ran the fence's - // `ScrollController::drag` with a `PressStart`, and -- the press being a catch - // of its own fling, which commits with no slop -- captured the whole - // gesture 500px away from the fence. The list under the finger moved - // by nothing at all. - // - // The rule is the set's, not one member's: press *and* scroll, since - // a wheel event reaching a widget the cursor has just left is the same - // fault with a different sense. `Drop` and `Cancel` are exempt because - // they are delivered deliberately to a widget that is *not* under the - // pointer, and `run_sensors` hands both of those an `On` anyway. let on_this = hover.is_on(); for sense in senses.iter() { if match sense { @@ -752,17 +515,7 @@ pub fn should_run( && cursor.scroll_delta.axis(*axis).abs() >= cursor.scroll_delta.axis(!*axis).abs() } - // Never derived here -- `Drop` only ever fires through - // `CursorSenses::should_run`'s own special case, ahead of this - // loop, for the one widget `run_sensors`' capture branch is - // delivering it to this frame. If this arm answered from raw - // button state instead, an ordinary hit-tested widget that - // happened to register `Drop` (with no capture involved at - // all) would see it fire on every plain button-up under the - // cursor. - // Neither is ever derived from raw state -- see the `Drop` - // note above; both are set by `run_sensors` alone, for the one - // widget it is delivering to this frame. + // Capture routing supplies terminal events; raw button state must not derive them. CursorSense::Drop | CursorSense::Cancel => false, } && (captured || !matches!(sense, CursorSense::Pressing(_)) @@ -868,30 +621,8 @@ impl BitOr for CursorSenses { } } -/// One `iris::input` line for a single platform input sample -- called -/// once per platform event: `android::view::IrisViewPeer::on_touch_event` -/// once per real `MotionEvent` (`historical` carrying whatever samples it -/// batched, oldest first, exactly as Android delivers and replays them); -/// the winit backend once per pointer `WindowEvent`, always with an empty -/// `historical` since winit does not batch; and `harness::Harness::touch` -/// once per `TouchScript` line, also with an empty `historical`, which is -/// what makes a harness-produced report round-trip through -/// `iris/benches/report_to_touch.py` back into the exact script that was -/// replayed (docs/RUST.md's "Three test layers" / phone logging sections). -/// -/// `action` is one of the four words [`crate::harness::TouchScript::parse`] -/// accepts (`"down"`/`"move"`/`"up"`/`"cancel"`), so the same string is -/// both what a real device's `MotionAction` is translated to and what the -/// parser reads back -- one vocabulary rather than two that have to be -/// kept in step by hand. `t_ms`/`historical`'s own times are whatever the -/// caller's own clock calls "the start of this recording" -- the harness's -/// own `t_ms`, or nanoseconds since `IrisViewPeer`'s `input_clock` anchor -/// converted to ms -- so they are comparable to a `.touch` file's own -/// column but not to another process's. -/// -/// Gated on [`crate::diagnostics::trace_enabled`] rather than -/// `log::log_enabled!` -- see that module's doc for why the level alone -/// cannot give this a default-off switch. +/// 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; @@ -907,36 +638,8 @@ pub fn log_input_event(action: &str, x: f32, y: f32, t_ms: u64, historical: &[(u ); } -/// Converts a platform's own monotonic timestamps into [`Instant`]s -/// through **one** anchor, so that everything this process is told the -/// time of is dated on a single ruler. -/// -/// Two kinds of timestamp go through it on Android and they have to agree, -/// which is why there is one type and not one per source: a touch -/// sample's `MotionEvent` time, and the `Choreographer` frame time a -/// `doFrame` callback carries. Both are `CLOCK_MONOTONIC` in nanoseconds -/// (`SystemClock.uptimeMillis`'s base and `System.nanoTime`'s are the same -/// clock), so one `(Instant, nanos)` pair converts either exactly, and a -/// fling launched by a gesture is then advanced on the clock its velocity -/// was measured on. -/// -/// A fresh `Instant::now()` per event, minus each sample's age inside it, -/// can date a later event's first sample before the previous event's last -/// one whenever delivery jitters by more than the batch spans -- which -/// [`VelocityTracker`] would rightly reject. The same jitter in a *frame* -/// clock is what makes a fling stutter: the display presents frames on an -/// even cadence, so sampling the animation at "whenever the callback got -/// to run" moves the content by an uneven distance each time even when no -/// frame is late. -/// -/// The anchor is taken from the **earliest sample of the first event**, -/// not from that event's own time: an event batches samples that are by -/// definition older than itself, and anchoring on the event's own time -/// leaves them before the anchor, where they clamp onto one instant. Three -/// samples sharing a timestamp make the Lsq2 fit degenerate, so the flick -/// that produced them reads 0 px/s -- reachable whenever the first event a -/// view sees is a `Move` (the `Down` went to another view, or the view was -/// attached mid-gesture). Found by review, 2026-09-07. +/// 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, @@ -945,29 +648,17 @@ pub struct DeviceClock { } impl DeviceClock { - /// `now` is when the first event arrived, `event_time` its own - /// timestamp, and `oldest` the timestamp of the earliest sample it - /// carries -- equal to `event_time` when it batches none, which is - /// also what a frame time anchoring this passes for both. + /// `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 { - // `checked_sub` rather than `-`: an `Instant` taken very early - // in a process's life has nothing to subtract from, and the - // saturating answer (everything in the first batch at `now`) - // is the old behaviour rather than a panic. + // 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, } } - /// Dates one sample, in the order the platform delivers them. - /// - /// The `debug_assert!` is deliberately not an `assert!`: this runs once - /// per touch sample, which on a 120Hz screen with batching is hundreds - /// a second, and a mis-ordered sample degrades a velocity rather than - /// drawing something wrong (CODE_RULES' hot-loop exception). pub fn sample(&mut self, nanos: i64) -> Instant { debug_assert!( nanos >= self.last_nanos, @@ -979,26 +670,16 @@ impl DeviceClock { self.at(nanos) } - /// Dates a sample without treating it as the newest one seen -- for - /// reads that are out of band, such as looking at a batch before - /// replaying it. pub fn at(&self, nanos: i64) -> Instant { self.anchor_at + Duration::from_nanos(nanos.saturating_sub(self.anchor_nanos).max(0) as u64) } - /// Milliseconds since the anchor, which is the column an `iris::input` - /// line and a `.touch` file both carry (see [`log_input_event`]). pub fn ms_since_anchor(&self, nanos: i64) -> u64 { (nanos.saturating_sub(self.anchor_nanos).max(0) as u64) / 1_000_000 } } -/// How long a stationary press has to be held before it is treated as a -/// long-press rather than the start of a pan. pub const LONG_PRESS: Duration = Duration::from_millis(500); -/// How far a press has to move, in pixels, before it counts as a drag -/// rather than jitter -- for both the pan-vs-select axis test and the -/// "did this actually move" long-press guard. pub const DRAG_SLOP: f32 = 8.0; /// What a [`DragArbiter`] decided a frame's drag should mean. `Undecided` @@ -1007,31 +688,15 @@ pub const DRAG_SLOP: f32 = 8.0; #[derive(Debug, Clone, Copy, PartialEq)] pub enum DragOutcome { Undecided, - /// Scroll the enclosing list by this many window-space pixels along - /// the drag axis (the delta since the arbiter's last decided frame). Pan(f32), - /// A selection should begin at the arbiter's press origin. SelectStart, - /// A selection already underway should extend to the current position. SelectExtend, } -/// What the thing a press landed on looked like at the moment it landed -/// -- the two facts a [`DragArbiter`] cannot see for itself and that -/// decide what the press is allowed to become. One struct rather than two -/// boolean parameters because they are read together, on exactly one call -/// (`press_start`), and a bare `false, false` at a call site says nothing -/// about which is which. +/// Target state that determines which gestures a new press may become. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PressState { - /// Whether anything was already selected *before* this press -- it - /// decides whether an early horizontal move extends that selection - /// instead of waiting for a long-press. pub already_selected: bool, - /// Whether the target was already moving under its own momentum (a - /// `LazySpan` with a fling in flight, `Scrollable::is_scrolling`). See - /// [`DragArbiter::press_start`]: a press on moving content is a catch, - /// and catches skip the slop entirely. pub scrolling: bool, } @@ -1043,33 +708,10 @@ enum ArbiterState { Selecting, } -/// Decides, one shared instance per gesture surface (a transcript's whole -/// row list here), whether a touch drag that starts on a row's own -/// selectable text is panning the list or extending a text selection -- -/// RUST.md's I5 finding that both wanted the same `CursorSense:: -/// click_or_drag()` gesture, with the inner text layer winning every frame -/// regardless of which one the reader meant. Decided the way Android -/// itself decides it, so a reader's existing muscle memory carries over: -/// -/// - An ordinary vertical drag pans -- checked first, and immediately, -/// so a swipe never waits on the long-press timer. -/// - A stationary press held past [`LONG_PRESS`] starts a selection. -/// Every drag frame after that extends it, whichever direction it goes. -/// - A drag that starts **horizontally** while something is already -/// selected extends that selection right away, skipping the long-press -/// wait -- the "drag the selection handle" gesture a reader reaches for -/// once text is already highlighted. -/// -/// Pure state, no rendering or widget access, so it is unit-testable -/// exactly like the rest of this module (`sense_tests.rs`'s style) with a -/// caller-supplied `Instant` rather than a real clock. +/// Chooses between axial panning, long-press selection, and immediate +/// cross-axis extension of an existing selection. pub struct DragArbiter { state: ArbiterState, - /// Which way a pan runs. A transcript pans down its list and a code - /// fence pans across its own long lines, and the two decisions are - /// the same one with the axes swapped -- so the axis is a field - /// rather than a second copy of this state machine, and everything - /// below reads `along`/`across` instead of `dy`/`dx`. axis: Axis, origin: Vec2, origin_at: Instant, @@ -1083,13 +725,10 @@ impl Default for DragArbiter { } impl DragArbiter { - /// A vertical arbiter -- what a list, and every caller before the - /// axis became a field, wants. pub fn new() -> Self { Self::default() } - /// An arbiter whose pan runs along `axis`. pub fn on(axis: Axis) -> Self { Self { state: ArbiterState::Idle, @@ -1100,20 +739,8 @@ impl DragArbiter { } } - /// A fresh press-down at `pos`, with whatever the caller's target - /// looked like at that moment ([`PressState`]). - /// - /// `press.scrolling` short-circuits the whole decision: the press - /// commits to panning on this very sample, with no [`DRAG_SLOP`] and - /// no long-press timer, because a finger put down on content that is - /// already moving means "stop it here" and nothing else. That is - /// Compose's `scrollable`, whose `startDragImmediately` is - /// `ScrollingLogic.shouldScrollImmediately()` -- - /// `scrollableState.isScrollInProgress` -- and whose - /// `DragGestureNode.processInitialDownState` then consumes the DOWN - /// on the `Initial` pass and calls `sendDragStart` + - /// `sendDragEvent(Offset.Zero)` on the `Main` pass of that same - /// event, rather than moving to its await-touch-slop state. + /// 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; @@ -1127,31 +754,13 @@ impl DragArbiter { }; } - /// Whether this arbiter has no press in flight -- either it has never - /// seen [`Self::press_start`], or the last one it saw was released. - /// What a caller whose own `PressStart` sense can miss (see - /// [`Self::update`]'s doc) uses to notice that a `Pressing` frame has - /// arrived with no matching start, and recover by starting one now. + /// Whether no press is in flight, including after a missed `PressStart`. pub fn is_idle(&self) -> bool { matches!(self.state, ArbiterState::Idle) } - /// The press continues (still down) at `pos`. Call once per frame - /// while the button/finger is down; returns what this frame means. - /// - /// A caller must not call this while [`Self::is_idle`] is true for a - /// press that is genuinely still down -- `Idle` has no way to tell - /// "no press is happening" from "a press is happening but this - /// arbiter never got its `press_start`," so it always answers - /// `Undecided` and never leaves `Idle` on its own. That second case is - /// real: a touch's `ACTION_DOWN` lands whatever pixel the finger - /// actually hit, which is not guaranteed to be inside the same - /// row-local sensor region a later `ACTION_MOVE` in the same gesture - /// lands in (a row's own padding/gap, or its non-selectable header, is - /// pointer-transparent to `CursorSense`) -- so the widget that - /// receives the gesture's first `Pressing` frame may never have seen - /// its `PressStart`. `iris::transcript_ui::selection::Selection::drag` - /// is the caller that recovers from this, via `is_idle`. + /// 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, @@ -1187,24 +796,7 @@ impl DragArbiter { self.axis, ); } - // `along` here is the *whole* drag since `press_start`, - // not since the last frame -- nothing panned while - // `Undecided` was withholding the slop, so applying it - // in full on this one frame is a visible jump the - // instant `DRAG_SLOP` is crossed (IRIS_TODO.md's - // "scrolling down sometimes jitters the text," root- - // caused by tracing `LazySpan`'s per-frame offset against - // a synthetic monotonic drag: the offset held flat for - // every `Undecided` frame, then stepped by several - // frames' worth of motion at once on the frame slop - // was crossed, before resuming ordinary per-frame - // deltas). Only the excess past the slop threshold is - // real, undecided motion the reader hasn't seen - // reflected yet -- so only that excess is applied now, - // the same way Android's own touch handling consumes - // `ViewConfiguration.getScaledTouchSlop()` once from - // the first scroll past it rather than replaying the - // whole pre-threshold drag in one step. + // 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 @@ -1223,96 +815,40 @@ impl DragArbiter { } } - /// The press was released -- back to idle for the next one. pub fn release(&mut self) { self.state = ArbiterState::Idle; } - /// Whether the arbiter's current gesture (if any) has committed to - /// panning -- what a caller checks at release time to decide whether - /// to hand the tracked velocity to [`crate::widget::ScrollController::fling`], per - /// IRIS_TODO.md's "swiping has no momentum": a fling must only follow - /// a pan, never a text selection that happened to end with the finger - /// still moving. + /// Whether release may turn the tracked velocity into a fling. pub fn is_panning(&self) -> bool { matches!(self.state, ArbiterState::Panning) } - /// Which way this arbiter's pan runs -- what [`DragGesture`] reads to - /// know which component of a window position to hand its - /// [`VelocityTracker`], so the axis is stated once here rather than - /// stored a second time beside it. pub fn axis(&self) -> Axis { self.axis } - /// Whether a press is in flight that has committed to neither a pan - /// nor a selection -- what a release checks to tell a **tap** from - /// the end of a drag. A tap is exactly "pressed and let go without - /// ever deciding", so it is read here rather than timed separately: - /// one gesture machine, one answer. pub fn is_undecided(&self) -> bool { matches!(self.state, ArbiterState::Undecided { .. }) } } -/// What a [`DragGesture`] decided this frame -- [`DragOutcome`] plus the -/// one further state a shared gesture needs: the drag ending, with the -/// released velocity if (and only if) it had committed to panning. #[derive(Debug, Clone, Copy, PartialEq)] pub enum GestureOutcome { Undecided, - /// Same units and sign as [`DragOutcome::Pan`] -- the caller's own - /// convention (`ScrollController::scroll`'s, for a transcript) to apply. Pan(f32), SelectStart, SelectExtend, - /// The press ended without ever committing to a pan or a selection -- - /// a tap. Distinct from `Released(None)`, which is the end of a - /// gesture that *did* commit (a selection, or a pan too slow to - /// fling): a caller acting on a tap -- following a markdown link -- - /// must not also act when the finger was panning the list past that - /// link, which is the tap-vs-drag rule this enum exists to state - /// once for every caller rather than per widget. + /// The press never committed to a pan or selection. Tapped, - /// The drag ended -- `PressEnd` or the capture's own terminal `Drop`. - /// `Some(velocity)` only if the gesture had committed to panning - /// (never a tap, a long-press selection, or one still `Undecided`); - /// same units as `Pan`, so a caller hands it to `ScrollController::fling` with - /// whatever sign flip it already applies to `Pan`. Released(Option), - /// Another widget took the pointer (`CursorSense::Cancel`), so this - /// gesture is over and **nothing** should be acted on: not a tap, not - /// a fling, not a selection. Distinct from `Released(None)`, which is - /// a gesture of this widget's own that simply ended with nothing to - /// hand on. Cancelled, } -/// Bundles a [`DragArbiter`] and a [`VelocityTracker`] into the one thing -/// most drag-driven widgets need: arbitrate pan-vs-hold, track the pan's -/// velocity, and take pointer capture (`UiRenderState::capture_pointer`) -/// the moment the gesture commits so the rest of it -- including the -/// terminal release -- keeps reaching the same widget even after the -/// finger has moved off whatever hit region first noticed the press. Iris -/// asked for this to live here rather than in `transcript-ui::Selection` -/// (2026-09-06): "dragging should be part of the -/// default input system ... anything that provides good performance and -/// can be generalized well is part of iris rather than the app." A caller -/// still decides what a committed pan or a completed selection *means* -/// (transcript-ui's pan-vs-select is one call site; a slider or a plain -/// scroll area is another) -- this only owns the *mechanics* every one of -/// them would otherwise duplicate. pub struct DragGesture { arbiter: DragArbiter, velocity: VelocityTracker, - /// Set when a press began as a *catch* (`PressState::scrolling`) and - /// cleared by the first frame that actually moves the content. While - /// it is set the gesture is a pan that has panned nothing, so its - /// release is `Released(None)`: not a `Tapped`, because Compose's - /// scrollable consumed that DOWN and no click or long-press detector - /// under it ever saw the gesture at all, and not a - /// `Released(Some(v))`, because there is no velocity to hand on. + /// A caught fling that has not moved: consumed as a pan, but with no release velocity. catch_unmoved: bool, } @@ -1327,7 +863,6 @@ impl DragGesture { Self::on(Axis::Y) } - /// A gesture whose pan runs along `axis` -- see [`DragArbiter::on`]. pub fn on(axis: Axis) -> Self { Self { arbiter: DragArbiter::on(axis), @@ -1336,36 +871,12 @@ impl DragGesture { } } - /// Whether this gesture has no press in flight -- a thin passthrough - /// to the underlying `DragArbiter::is_idle`, for a caller (a test, a - /// diagnostic) that wants to observe the recovery behaviour `handle`'s - /// idle-recovery branch documents without reaching into a private - /// field. pub fn is_idle(&self) -> bool { self.arbiter.is_idle() } - /// Whether feeding `sense` to [`Self::handle`] right now would begin - /// a **new press**: any frame that is not a release, arriving while - /// no press is in flight. That covers a `PressStart` and equally the - /// first `Pressing` frame of a gesture whose `PressStart` never - /// arrived (`handle`'s recovery branch below). A caller reads this to - /// prepare the thing being dragged on exactly the frames `handle` - /// will call `DragArbiter::press_start` -- - /// `transcript_ui::Selection::drag` stops its list's fling and - /// reports whether there was one -- rather than keeping its own copy - /// of that rule, which is the one place the two could disagree. - /// - /// **A `PressStart` is not special-cased to `true`**, which it was - /// for one afternoon: one touch-down reaches every sensor under the - /// finger, and a transcript row's block and the tool row containing - /// it both drive this same shared `DragGesture`, so `handle` sees one - /// `PressStart` twice. Restarting on the second delivery re-reads the - /// caller's [`PressState`] *after* the first delivery has already - /// acted on it -- the list's fling is cancelled by then, so - /// `scrolling` comes back false and a catch silently becomes an - /// ordinary slop-waiting press. The second delivery is a continuation - /// of a press already in flight, and this says so. + /// 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, @@ -1391,22 +902,6 @@ impl DragGesture { press: PressState, ) -> GestureOutcome { match sense { - // Somebody else won this gesture. Forget it completely -- - // leaving the arbiter open is the fault this sense was added - // for, since its origin then measures the *next* touch and - // pans by the distance between two unrelated fingers. - // - // **Unless this gesture is the winner.** A `Cancel` goes to - // every widget that was handed a frame of the press and did - // not capture it (`PointerInput`'s doc), and one gesture is - // routinely driven by several of those: a transcript row's - // text block feeds `transcript_ui::Selection`'s shared - // `DragGesture`, which captures under the *list's* id -- so - // the block is a "loser" on the very frame its own gesture - // committed. Acting on that would release the pan the frame - // it started. `id` is what this gesture captures as, so - // comparing it against the holder is exactly the question - // "was it us that won". CursorSense::Cancel if pointer.holder() == Some(id) => GestureOutcome::Undecided, CursorSense::Cancel => { if crate::diagnostics::trace_enabled() { @@ -1421,14 +916,7 @@ impl DragGesture { GestureOutcome::Cancelled } CursorSense::Drop | CursorSense::PressEnd(_) => { - // Once: a `velocity()` is a full Lsq2 fit, and the log - // line below wants the same number the outcome carries. let released = self.velocity.velocity(); - // `catch_unmoved` is only ever set beside `Panning` (a - // catch enters it on the down) and only ever left set - // while nothing has moved, so `Panning` is the one state - // it can be observed in. If that stops holding, the - // branch below is silently swallowing a tap. debug_assert!( !self.catch_unmoved || self.arbiter.is_panning(), "a caught press that never moved must still be panning at release", @@ -1442,13 +930,6 @@ impl DragGesture { } else { GestureOutcome::Released(None) }; - // The one line that settles "why did that flick not fling" - // from a logcat, which is the only instrument available on - // Iris's phone (this-machine-android: system tracing does - // not work there). Every input to the decision is here, so - // a zero velocity can be told apart from a gesture that - // never reached `Panning` at all -- the two look identical - // on screen and had to be guessed between twice. log::info!( "iris drag release: samples={} span={:.1}ms v={:.0} outcome={:?}", self.velocity.sample_count(), @@ -1456,14 +937,6 @@ impl DragGesture { released, outcome, ); - // The samples themselves, so a flick that felt wrong on - // Iris's phone can be replayed here instead of guessed at: - // paste them into a `touch/*.touch` recording or straight - // into `iris/benches/velocity_reference.py`. Debug rather - // than info because it is one line per gesture and the - // ring the report copies is small -- but "small" is still - // not "free" (docs/RUST.md's review, D1), so it is gated - // the same way every other `debug!` in this pass is. if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::input", @@ -1491,13 +964,6 @@ impl DragGesture { // it is the same one the caller reads. _ if self.starts_press(sense) => { self.velocity.reset(); - // Where the finger went down is a sample, exactly as - // Compose's `DragGestureNode.sendDragStart` feeds the DOWN - // change to its tracker before any move. It is one of the - // three a fit needs, and it is the one that fixes the - // origin of the curve; without it a 120Hz flick delivering - // its whole motion in two frames has too few points and - // does not fling at all. self.velocity .add_position(pos_window.axis(self.arbiter.axis()), now); self.arbiter.press_start(pos_window, now, press); @@ -1532,13 +998,8 @@ impl DragGesture { DragOutcome::Pan(dy) => { pointer.capture(id); if dy != 0.0 { - // The catch has moved something, so its release is an - // ordinary pan release again -- see `catch_unmoved`. self.catch_unmoved = false; } - // The raw position, not `dy`: `dy` has the touch slop - // subtracted out of the frame that crossed it, and the - // tracker fits a curve through where the finger *was*. self.velocity .add_position(pos.axis(self.arbiter.axis()), now); GestureOutcome::Pan(dy) @@ -1555,26 +1016,11 @@ impl DragGesture { } } -/// Compose's `HistorySize`: how many samples the tracker holds at all. -/// Compose's is a circular buffer of exactly this many; the deque below -/// drops its oldest instead, which is the same set of samples. const HISTORY_SIZE: usize = 20; -/// Compose's `HorizonMilliseconds`: a sample older than this than the -/// newest one is not part of the estimate. const HORIZON_MS: f32 = 100.0; -/// Compose's `AssumePointerMoveStoppedMilliseconds`: a gap this long -/// between two consecutive samples means the finger stopped, and -/// everything older than the gap is a different motion. const ASSUME_POINTER_MOVE_STOPPED_MS: f32 = 40.0; -/// `VelocityTracker1D`'s `minSampleSize` for `Strategy.Lsq2` -- a -/// quadratic needs three points, and fewer answers `0`. const MIN_SAMPLE_SIZE: usize = 3; -/// Compose's `if (norm < 0.000001f)` in `polyFitLeastSquares`: below this, -/// the vectors are linearly dependent and there is no solution, so the fit -/// returns nothing rather than dividing by it. const DEGENERATE_NORM: f32 = 0.000001; -/// The degree Compose fits (`polyFitLeastSquares(.., degree = 2)`), and -/// the number of coefficients that produces. const FIT_DEGREE: usize = 2; const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; @@ -1588,46 +1034,6 @@ const FIT_COEFFICIENTS: usize = FIT_DEGREE + 1; /// deliberately **no** matching minimum: see `ScrollController::fling`. pub const MAX_FLING_VELOCITY_DP_S: f32 = 8000.0; -/// Estimates a drag's speed along one axis the way Compose's touch -/// scrolling does, so a fling released here starts at the speed the same -/// finger would have started one in a `LazyColumn` -- Iris's phone report -/// of 2026-09-07: "flinging now actually works but is slower than -/// Compose's immediately after releasing the flick (the slow down seems -/// correct)". The curve was already AOSP's; only the initial speed was -/// wrong. -/// -/// **What it was, and why that was slow.** Until 2026-09-07 this held -/// per-frame *deltas* and answered their sum over the span between the -/// oldest and newest -- an average. An average cannot tell an -/// accelerating flick from a steady drag, and a flick is by definition -/// accelerating: on the reference accelerating sample set -/// (`iris/benches/velocity_reference.py`) the average reads 1080px/s -/// where Compose reads 2445px/s, so every fling started at under half -/// the speed the finger asked for. -/// -/// **What Compose actually does, which is not what it is remembered as.** -/// `scrollable`/`draggable` release through the 2D `VelocityTracker`, -/// which on Android is `Lsq2VelocityTracker` -- two -/// `VelocityTracker1D(strategy = Lsq2)` over **absolute positions**, -/// fitting a degree-2 polynomial by least squares and taking its -/// derivative at the newest sample. `Strategy.Impulse` is reached only -/// through `DifferentialVelocityTracker`, whose one caller is -/// `NonTouchScrollingLogic`: mouse wheel and trackpad, never a finger. -/// (`AndroidComposeUiFlags.isFrameworkVelocityTrackerEnabled`, which -/// would swap in the platform's own tracker, defaults to `false`.) Read -/// out of `androidx.compose.ui:ui-android:1.12.0` and -/// `androidx.compose.foundation:foundation-android:1.12.0`'s -/// `-sources.jar`, 2026-09-07 -- the versions -/// `app/gradle/libs.versions.toml` builds the Compose app against, which -/// is the app Iris is comparing this one with. -/// -/// **So it holds positions, not deltas.** Lsq2 refuses differential data -/// in Compose itself (`"Lsq2 not (yet) supported for differential axes"` -/// is a thrown `IllegalStateException`), and a fit needs points on a -/// curve rather than the curve's increments. [`DragGesture`] feeds it the -/// raw window-space coordinate along the drag axis, exactly as Compose -/// feeds `originalEventPosition`. -/// /// The numbers its tests assert on come from /// `iris/benches/velocity_reference.py`, an independent transcription of /// the same Kotlin -- not from this code, for the reason @@ -1655,14 +1061,7 @@ impl VelocityTracker { self.samples.clear(); } - /// Record where the finger was, along the drag axis, at `at`. A - /// **position**, not a per-frame delta -- see the type's doc. pub fn add_position(&mut self, position: f32, at: Instant) { - // A caller that samples out of order (a restored/replayed - // gesture, a test) would make `velocity`'s reverse walk compute - // negative ages and fit a curve through a shuffled x-axis -- - // masking the bug that produced it rather than surfacing it - // (review, 2026-09-06 finding 4). debug_assert!(self.samples.back().is_none_or(|&(last, _)| at >= last)); self.samples.push_back((at, position)); while self.samples.len() > HISTORY_SIZE { @@ -1686,13 +1085,6 @@ impl VelocityTracker { } } - /// Every held sample as `t_ms:position`, offsets from the oldest -- - /// what the release log prints at debug level so a gesture reported - /// from the phone can be replayed here (`TouchScript`, layer 1) or - /// pasted into `velocity_reference.py`. Iris has no logcat, so the - /// only way a flick that felt wrong on her screen becomes a number - /// anybody can check is for the samples themselves to be in the - /// report. pub fn samples_display(&self) -> String { let Some(&(first, _)) = self.samples.front() else { return String::new(); @@ -1714,10 +1106,6 @@ impl VelocityTracker { /// `calculateVelocity(maximumVelocity)`'s `NaN -> 0`. The maximum /// itself is applied by the caller that knows the density /// ([`crate::widget::ScrollController::fling`]). - /// - /// `0.0` with fewer than [`MIN_SAMPLE_SIZE`] usable samples, which is - /// Compose's answer too: a press and a single move carry no curve to - /// fit, so they do not fling. pub fn velocity(&self) -> f32 { let mut positions = [0.0f32; HISTORY_SIZE]; let mut ages = [0.0f32; HISTORY_SIZE]; @@ -1727,10 +1115,6 @@ impl VelocityTracker { return 0.0; }; let mut previous_at = newest_at; - // Newest first, walking back while the samples are one continuous - // motion -- Compose's own loop, including that `previous_at` - // steps sample to sample (the `Strategy.Lsq2 || isDataDifferential` - // branch) rather than staying on the newest. for &(at, position) in self.samples.iter().rev() { let age = newest_at.duration_since(at).as_secs_f32() * 1000.0; let gap = previous_at.duration_since(at).as_secs_f32() * 1000.0; @@ -1765,21 +1149,12 @@ impl VelocityTracker { } } -/// Compose's `polyFitLeastSquares` at its one call site's shape: degree -/// [`FIT_DEGREE`], weights all 1, coefficients lowest order first. Gram- -/// Schmidt QR of the Vandermonde matrix, then back-substitution. -/// /// Fixed-size arrays rather than Compose's allocated `Matrix`, since both /// dimensions are constants here -- `FIT_COEFFICIENTS` rows by at most /// `HISTORY_SIZE` columns. Compose truncates the degree when it has fewer /// points than coefficients; [`MIN_SAMPLE_SIZE`] makes that unreachable /// from the only caller, so the truncation is an assert instead of a /// branch that could never be exercised. -// Both guards stay `debug_assert!` under the 2026-09-07 review's rule: -// they are preconditions of a fit run on every velocity query, and the two -// callers between them already answer 0 below `MIN_SAMPLE_SIZE` and check -// the result with `is_finite`, so a release build has a defined outcome -// rather than a silently wrong one. /// `None` where Compose returns no solution: see `DEGENERATE_NORM`. fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS]> { debug_assert_eq!(x.len(), y.len()); @@ -1790,7 +1165,6 @@ fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS ); let m = x.len(); - // a[i][h] = x[h]^i. let mut a = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; for h in 0..m { a[0][h] = 1.0; @@ -1799,7 +1173,6 @@ fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS } } - // q: orthonormal basis; r: upper triangular. let mut q = [[0.0f32; HISTORY_SIZE]; FIT_COEFFICIENTS]; let mut r = [[0.0f32; FIT_COEFFICIENTS]; FIT_COEFFICIENTS]; for j in 0..FIT_COEFFICIENTS { @@ -1813,14 +1186,6 @@ fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS w[h] -= dot * z[h]; } } - // Compose's own bail-out, not a clamp. `polyFitLeastSquares` - // treats a norm this small as "the vectors are linearly dependent, - // so there is no solution" and returns nothing; clamping instead - // -- which this did until 2026-09-07 - // (review, 2026-09-07's R7) -- produces a `q` row of zeros, - // a zero on `r`'s diagonal and a 0/0 that only the caller's - // `is_finite` check happened to catch. Working by accident, and - // not what the source it is transcribed from does. let norm = dot(&q[j][..m], &q[j][..m]).sqrt(); if norm < DEGENERATE_NORM { return None; @@ -1838,7 +1203,6 @@ fn poly_fit_least_squares(x: &[f32], y: &[f32]) -> Option<[f32; FIT_COEFFICIENTS } } - // Solve R B = Qt Y, bottom-right to top-left. let mut coefficients = [0.0f32; FIT_COEFFICIENTS]; for i in (0..FIT_COEFFICIENTS).rev() { let mut c = dot(&q[i][..m], &y[..m]); @@ -1854,20 +1218,6 @@ fn dot(a: &[f32], b: &[f32]) -> f32 { a.iter().zip(b).map(|(x, y)| x * y).sum() } -/// Android's fling deceleration curve, ported from AOSP's -/// `android.widget.OverScroller.SplineOverScroller` (the same curve -/// Compose's `androidx.compose.animation.AndroidFlingSpline` and -/// `androidx.compose.animation.FlingCalculator` reuse) so a fling here -/// travels the same distance a Compose `LazyColumn`'s own -/// `ScrollableDefaults.flingBehavior()` would for the same initial -/// velocity -- RUST.md's "Benchmark v2" box asked the two apps' fling -/// phase to be comparable, and IRIS_TODO.md's "swiping has no momentum" -/// asked for the same physics a reader's muscle memory already expects -/// from every other Android scroll view. Both sources were read at -/// `frameworks/base`'s `core/java/android/widget/OverScroller.java` and -/// `androidx.compose.animation:animation:1.12.0`'s `SplineBasedDecay.kt` -/// (2026-09-07); they agree line for line. -/// /// **One table, indexed by even steps of *time*.** `SPLINE_POSITION[i]` /// is the fraction of the total distance covered at time fraction /// `i / NB_SAMPLES`, so a lookup brackets `t` between `index / N` and @@ -1876,24 +1226,10 @@ fn dot(a: &[f32], b: &[f32]) -> f32 { /// whose target moved), which nothing here has; it is deliberately not /// built, so there is one array and one indexing rule rather than two of /// each to pick the wrong one from. -/// -/// The wrong one was picked, and this is what it cost. Until 2026-09-07 -/// the two halves of AOSP's build loop were transposed -- the bisection -/// solved the tension curve and the sample evaluated the `P1`/`P2` one, -/// where AOSP does the opposite -- which made this table and `SPLINE_TIME` -/// *identical*, and the old lookup, which bracketed `t` between -/// `SPLINE_TIME` entries, then returned exactly `t` for every `t`. A fling -/// coasted at constant speed for its whole duration and stopped dead: -/// Iris's phone report of 2026-09-07, "just linear velocity with an abrupt -/// stop", verbatim out of the arithmetic. Every test it had compared the -/// curve with itself, so none of them could see it; -/// `the_spline_matches_aosps_own_table` pins the absolute numbers now. mod android_fling_spline { use std::sync::OnceLock; const NB_SAMPLES: usize = 100; - /// Where the two cubic tension lines cross (AOSP's own constant name - /// and value, `SplineOverScroller.INFLEXION`). pub(super) const INFLEXION: f32 = 0.35; const START_TENSION: f32 = 0.5; const END_TENSION: f32 = 1.0; @@ -1920,9 +1256,6 @@ mod android_fling_spline { loop { x = x_min + (x_max - x_min) / 2.0; coef = 3.0 * x * (1.0 - x); - // Solved on the `P1`/`P2` curve and sampled on the tension - // one. Transposing these two is the defect this module's - // doc comment describes; they are not interchangeable. let tx = coef * ((1.0 - x) * P1 + x * P2) + x * x * x; if (tx - alpha).abs() < 1e-5 { break; @@ -1949,10 +1282,6 @@ mod android_fling_spline { let t = time_fraction.clamp(0.0, 1.0); let index = (t * NB_SAMPLES as f32) as usize; if index >= NB_SAMPLES { - // The end of the fling: all of the distance covered and - // nothing left moving. AOSP's `distanceCoef = 1f` / - // `velocityCoef = 0f` defaults, which its - // `if (index < NB_SAMPLES)` leaves in place. return SplineSample { distance_fraction: 1.0, velocity_fraction: 0.0, @@ -1968,42 +1297,13 @@ mod android_fling_spline { } } -/// AOSP `SplineOverScroller`'s two other physical constants: the default -/// `ViewConfiguration.getScrollFriction()` and the deceleration rate a -/// friction of `0.84` per frame at 60Hz corresponds to -/// (`ln(0.78)/ln(0.9)`, `SplineOverScroller.DECELERATION_RATE`). const FLING_FRICTION: f32 = 0.015; -/// AOSP's own look-and-feel tuning constant, the argument -/// `SplineOverScroller`'s constructor passes to `computeDeceleration` when -/// it builds `mPhysicalCoeff` -- *not* the scroll friction, which is a -/// different number used a different place in the same formula. This was -/// `FLING_FRICTION` here until 2026-09-07, making the coefficient 56x too -/// small, which put an `ln` of a 56x-too-large ratio through -/// `exp(_/(rate-1))`: an ordinary flick came out lasting **30 seconds** -/// instead of 1.6. Nothing could see it while a finger fling never -/// animated at all (`ScrollController::fling`'s doc), which is why two defects had to -/// be fixed before either was visible. const FLING_TUNING: f32 = 0.84; fn deceleration_rate() -> f32 { (0.78f32.ln()) / (0.9f32.ln()) } const GRAVITY_EARTH: f32 = 9.80665; -/// Turns an initial fling velocity into a total travel distance and -/// duration, following AOSP `SplineOverScroller`'s own closed-form -/// formulas (`getSplineFlingDistance`/the duration half of `fling()`) -- -/// ported the same way Compose's `FlingCalculator` is, including its -/// `density`-dependent physical coefficient (`computeDeceleration`, -/// `GravityEarth * 39.37 * density * 160 * friction`). Density and -/// `density` is physical pixels per `dp`, and the velocity handed in has -/// to be in those same physical pixels -- which is what a touch event -/// carries. It does **not** cancel out: `duration` is -/// `exp(ln(k*v/C) / (rate-1))` with `C` proportional to density, so the -/// wrong density changes how long a fling lasts exponentially rather than -/// scaling it. An earlier version of this comment claimed the opposite and -/// `ScrollController::fling` passed `1.0`; on a 2.75-density screen that gave a -/// one-second flick a 45-second coast (measured 2026-09-07). `LazySpan` reads -/// its density from the painter now. pub struct FlingCalculator { physical_coefficient: f32, } @@ -2021,12 +1321,7 @@ impl FlingCalculator { .ln() } - /// Total signed distance the fling travels before settling, in the - /// same pixel units `velocity` was given in. pub fn distance(&self, velocity: f32) -> f32 { - // See `ScrollController::fling`'s matching assertion -- a non-finite velocity - // here silently produces a NaN distance rather than surfacing the - // bug that produced it (review, 2026-09-06 finding 3). debug_assert!(velocity.is_finite()); if velocity == 0.0 { return 0.0; @@ -2038,9 +1333,7 @@ impl FlingCalculator { magnitude.copysign(velocity) } - /// How long the fling takes to settle. pub fn duration(&self, velocity: f32) -> Duration { - // See `distance`'s matching assertion, above. debug_assert!(velocity.is_finite()); if velocity == 0.0 { return Duration::ZERO; @@ -2064,14 +1357,6 @@ impl FlingCalculator { self.distance(velocity) * android_fling_spline::sample(fraction).distance_fraction } - /// The signed *speed* at `elapsed` into the same fling, in the units - /// `velocity` was given in -- AOSP's `mCurrVelocity` and Compose's - /// `FlingInfo.velocity`. It falls from roughly `velocity` at the start - /// to zero at `duration()`, which is the whole difference between a - /// fling and a constant-speed slide, so it is what - /// `ScrollController::tick`'s debug line reports: successive frames printing - /// a shrinking number is the evidence that the curve is being followed - /// at all. pub fn velocity_at(&self, velocity: f32, elapsed: Duration) -> f32 { let duration = self.duration(velocity); if duration.is_zero() { @@ -2083,10 +1368,6 @@ impl FlingCalculator { } } -/// One fling in flight: the physics ([`FlingCalculator`]) plus how much of -/// its total travel has already been applied, so a tick only ever hands -/// back this frame's *incremental* delta. -/// /// It owns the curve and the clock and nothing else. Which way a positive /// delta moves the content, and whether the content has anywhere left to /// go, are the caller's -- a `LazySpan` scrolls its anchor one way and a @@ -2094,13 +1375,6 @@ impl FlingCalculator { /// which would have to be told, which is the same thing as not knowing. /// So a caller applies [`Self::tick`]'s delta in its own convention and /// calls [`Self::stop`] when it runs out of content. -/// -/// Every scrolling widget in this crate flings through this one type, -/// which is what Iris's 2026-09-08 "flinging doesn't work in horizontal -/// scroll areas -- flinging should be enabled by default in all scroll -/// areas on android to match compose's behavior" asks for: Compose's -/// `scrollable` attaches `ScrollableDefaults.flingBehavior()` on every -/// axis, and it is not something a caller opts into. pub struct Flinger { fling: Option, } @@ -2156,16 +1430,6 @@ impl Flinger { /// for View interop. A 50dp/s floor would swallow slow, deliberate /// releases that Compose flings. pub fn start(&mut self, velocity_px_per_s: f32, density: f32) -> bool { - // A NaN/inf velocity (a `VelocityTracker::velocity()` - // divide-by-near-zero span, or a caller passing a raw device value - // straight through) would propagate silently into - // `deceleration_for`'s `.ln()` -- the fling either never settles - // or jumps to NaN positions with nothing on screen saying why - // (review, 2026-09-06 finding 3). A plain `assert!` rather - // than a `debug_assert!`: it is one comparison per *gesture*, and - // every build anybody runs -- the emulator's and Iris's phone's -- - // is release, where a debug-only guard against silently wrong - // output is no guard at all (review, 2026-09-07's R1). assert!(velocity_px_per_s.is_finite()); assert!(density.is_finite() && density > 0.0); let max = MAX_FLING_VELOCITY_DP_S * density; @@ -2220,14 +1484,6 @@ impl Flinger { let target = f.calc.position_at(f.velocity, elapsed); let delta = target - f.applied; f.applied = target; - // The evidence that the spline is actually being followed, at the - // one granularity where a linear coast and a decelerating one look - // different: successive `dy` and `speed` shrinking. It was neither - // observable nor observed while `distance_fraction` returned `t` - // (`android_fling_spline`'s doc). Gated on - // `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/ - // RUST.md's review, D1): one line per fling *tick*, unconditional, - // was enough on its own to help fill the log ring. if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::frame", @@ -2251,20 +1507,12 @@ mod velocity_tracker_tests { use super::*; use std::sync::LazyLock; - // A single fixed base rather than a fresh `Instant::now()` per call -- - // computing it once per test keeps every sample's spacing exact - // instead of at the mercy of however long the test itself takes to - // run between calls, the same reasoning `drag_arbiter_tests::t` uses. static BASE: LazyLock = LazyLock::new(Instant::now); fn t(ms: u64) -> Instant { *BASE + Duration::from_millis(ms) } - /// Every number below is printed by `iris/benches/velocity_reference.py`, - /// an independent transcription of the same Kotlin. Do not "fix" one by - /// running the Rust and copying what it said -- that is exactly how the - /// fling spline shipped as a straight line for two builds. fn tracker(samples: &[(u64, f32)]) -> VelocityTracker { let mut v = VelocityTracker::new(); for &(ms, position) in samples { @@ -2273,9 +1521,6 @@ mod velocity_tracker_tests { v } - /// f32 Gram-Schmidt against the reference's f64: the fits here agree to - /// several digits, so a tolerance this tight still fails by a mile on - /// any wrong estimator (the average misses by 25--125%). fn assert_velocity(samples: &[(u64, f32)], expected: f32) { let got = tracker(samples).velocity(); let tolerance = expected.abs() * 1e-3 + 1e-3; @@ -2285,11 +1530,6 @@ mod velocity_tracker_tests { ); } - /// `transcript-fixture/touch/flick-120hz.touch`, as `DragGesture` feeds - /// it: the DOWN position and one position per MOVE, no sample for the - /// UP (Compose's `Lsq2VelocityTracker.addPointerInputChange` adds none - /// either). **The before/after of Iris's 2026-09-07 report** on this - /// recording: the average answered 12250px/s, Compose answers 15250. const FLICK_120HZ: [(u64, f32); 5] = [ (0, 1000.0), (4, 1040.0), @@ -2305,26 +1545,10 @@ mod velocity_tracker_tests { #[test] fn a_steady_drag_reports_its_own_speed() { - // 5px every 10ms, 11 samples spanning 100ms. The one case where a - // constant-velocity fit and an average must agree -- kept because - // it is the sanity check, and kept *with* the two below because on - // its own it cannot tell the two estimators apart at all. let samples: Vec<(u64, f32)> = (0..=10).map(|i| (i * 10, (i * 5) as f32)).collect(); assert_velocity(&samples, 500.0); } - /// **The negative control for the whole change.** Deltas doubling into - /// the release: Compose reads 2445px/s where the average reads 1080, - /// so a flick started at 44% of the speed the finger asked for. This - /// is the test the old estimator fails and the steady drag above does - /// not. Reverting `velocity` to `(newest - oldest) / span` fails seven - /// -- this one, the flick recording, the horizon, the stopped finger, - /// the minimum sample count and both `drag_gesture` flick tests, plus - /// `phone_screen.rs`'s flick -- and leaves everything else green. Rerun - /// 2026-09-07: this comment used to say - /// "exactly this one, the flick recording, and `phone_screen.rs`", - /// which disagreed with docs/RUST.md's count of the same experiment. - /// Seven is what the run prints; RUST.md was right. #[test] fn an_accelerating_flick_reads_its_speed_at_the_release() { const ACCELERATING: [(u64, f32); 6] = [ @@ -2345,9 +1569,6 @@ mod velocity_tracker_tests { #[test] fn fewer_than_three_samples_reports_zero() { - // Compose's `minSampleSize` for Lsq2 is 3: a quadratic through two - // points is not a fit. So a press and a single move do not fling, - // which is what Compose does with the same two samples. assert_eq!(VelocityTracker::new().velocity(), 0.0); assert_velocity(&[(0, 0.0)], 0.0); assert_velocity(&[(0, 0.0), (8, 100.0)], 0.0); @@ -2355,9 +1576,6 @@ mod velocity_tracker_tests { #[test] fn only_the_last_100ms_of_samples_count() { - // An old, fast jump outside `HORIZON_MS` followed by a slow steady - // drag reports the recent speed, not both. 1px/10ms = 100px/s; the - // average of the whole set is 9182px/s. let mut samples = vec![(0u64, 0.0f32)]; samples.extend((0..11).map(|i| (10 + i * 10, 1000.0 + i as f32))); assert_velocity(&samples, 100.0); @@ -2365,10 +1583,6 @@ mod velocity_tracker_tests { #[test] fn a_finger_that_stops_before_lifting_does_not_fling() { - // A 48ms gap is past `ASSUME_POINTER_MOVE_STOPPED_MS`, so the walk - // back stops at it and the fast motion before it is a different - // gesture. One usable sample, so 0 -- where the average would - // still say 2533px/s and fling from a standstill. assert_velocity( &[(0, 0.0), (4, 40.0), (8, 90.0), (12, 150.0), (60, 152.0)], 0.0, @@ -2387,8 +1601,6 @@ mod velocity_tracker_tests { #[test] fn only_the_last_twenty_samples_are_held() { - // `HISTORY_SIZE`. The oldest fall out rather than the newest being - // refused -- a long drag's velocity is its recent motion. let samples: Vec<(u64, f32)> = (0..40).map(|i| (i * 4, (i * 10) as f32)).collect(); let v = tracker(&samples); assert_eq!(v.sample_count(), HISTORY_SIZE); @@ -2398,20 +1610,12 @@ mod velocity_tracker_tests { ); } - /// Found in review, 2026-09-07. Three samples at one instant -- - /// which the input clock produced on its own before 2ec0fee -- leave - /// the second basis vector all zeros, and Compose calls that "linearly - /// dependent, no solution" and returns nothing. Clamping the norm to - /// 1e-6 instead reached the solve with a zero on `r`'s diagonal and - /// answered `[NaN, NaN, NaN]`, which only the caller's `is_finite` - /// check kept off the fling path. #[test] fn a_fit_through_linearly_dependent_points_has_no_solution() { assert_eq!( poly_fit_least_squares(&[0.0, 0.0, 0.0], &[0.0, 40.0, 90.0]), None, ); - // The other half: an ordinary set still fits. let fit = poly_fit_least_squares(&[-8.0, -4.0, 0.0], &[1000.0, 1040.0, 1086.0]) .expect("three distinct points describe a quadratic"); assert!(fit.iter().all(|c| c.is_finite())); @@ -2419,8 +1623,6 @@ mod velocity_tracker_tests { #[test] fn the_sample_list_is_reported_relative_to_the_first() { - // What the release log prints at debug level, and what a phone - // report has to be replayable from. assert_eq!( tracker(&FLICK_120HZ[..3]).samples_display(), "0.0:1000.0 4.0:1040.0 8.0:1086.0" @@ -2449,11 +1651,6 @@ mod fling_calculator_tests { assert_eq!(calc.distance(-12000.0), -d_fast); } - /// Summing the spline's own per-frame position deltas across the - /// whole fling has to land within 1% of the closed-form `distance()` - /// -- this is the guarantee that `ScrollController::tick`'s per-frame reads - /// of `position_at` actually add up to the total the fling promised, - /// not merely that the two formulas look plausible independently. #[test] fn integrating_position_at_matches_the_closed_form_distance() { let calc = FlingCalculator::new(1.0); @@ -2469,19 +1666,6 @@ mod fling_calculator_tests { } } - /// The absolute numbers, against AOSP's own formula worked by hand -- - /// the one thing every other test here cannot see, because they all - /// compare this calculator with itself (monotonic, signed, integrates - /// to the closed form) and so pass just as happily with a coefficient - /// 56x out. That is exactly the state this file was in: an ordinary - /// flick lasted 30 seconds on the emulator and every test was green. - /// - /// `SplineOverScroller` at ppi = 2.75*160 = 440: - /// `mPhysicalCoeff = 9.80665 * 39.37 * 440 * 0.84 = 142,698`; - /// `l = ln(0.35 * v / (0.015 * mPhysicalCoeff))`; - /// `duration = exp(l / (DECELERATION_RATE - 1))`. - /// For v = 3000 px/s that is 0.592s and 621px; for 11444 px/s, - /// 1.586s. #[test] fn a_flick_lasts_what_aosps_own_formula_says_it_does() { let calc = FlingCalculator::new(2.75); @@ -2516,28 +1700,12 @@ mod fling_calculator_tests { last = p; t += Duration::from_millis(16); } - // Well past the end, it stays pinned at the total -- a caller - // must be able to ask "where would this fling be" without first - // checking whether it has already settled. assert_eq!( calc.position_at(velocity, duration + Duration::from_secs(5)), total ); } - /// The table itself, against AOSP's own entries. Every number here - /// came out of `benches/fling_spline_reference.py`, which is a - /// separate hand transcription of `OverScroller.java` and - /// `SplineBasedDecay.kt` -- so this is the one test in the file that - /// is not the Rust code grading its own homework, and the only kind - /// that could have caught the transposed build loop - /// `android_fling_spline`'s doc describes. - /// - /// The property that names the old defect directly: the curve is - /// **not** the identity. At a tenth of the way through its time a - /// fling has covered 27.4% of its distance, and at half its time - /// 85.8%. The old table returned 0.100 and 0.500 -- a constant-speed - /// slide -- so the two `assert!`s below fail by a factor of three. #[test] fn the_spline_matches_aosps_own_table() { for (t, expected) in [ @@ -2555,8 +1723,6 @@ mod fling_calculator_tests { "distance fraction at t={t}: got {got}, AOSP says {expected}" ); } - // Speed falls monotonically to nothing -- the difference between - // a fling and a slide, and what the abrupt stop was. let mut last = f32::INFINITY; for step in 0..=100 { let v = android_fling_spline::sample(step as f32 / 100.0).velocity_fraction; @@ -2566,17 +1732,6 @@ mod fling_calculator_tests { assert_eq!(android_fling_spline::sample(1.0).velocity_fraction, 0.0); } - /// The same curve carried through `distance`/`duration` at Iris's own - /// phone density (2.55, `docs/bench/iris-phone-v2-2026-09-06.md`), - /// again with every number from `benches/fling_spline_reference.py`. - /// A fling's *speed* a third of the way through is 4733px/s out of an - /// initial 11064 -- what a reader sees as deceleration, and the - /// quantity that was constant before this. - /// - /// The sample fractions are deliberately not round: the velocity - /// coefficient is piecewise constant across each of the 100 samples, - /// so `0.75` sits exactly on a step and the assertion would be about - /// which side of it the last float landed rather than about the curve. #[test] fn a_flick_decelerates_the_way_aosp_says_it_does() { let calc = FlingCalculator::new(2.55); @@ -2618,9 +1773,6 @@ mod drag_arbiter_tests { use super::*; fn t(ms: u64) -> Instant { - // A fixed base plus an offset, rather than `Instant::now()` per - // call -- keeps every test's timing deterministic instead of at - // the mercy of how long the test itself took to run. Instant::now() - Duration::from_secs(3600) + Duration::from_millis(ms) } @@ -2635,27 +1787,16 @@ mod drag_arbiter_tests { fn a_vertical_drag_pans_immediately() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); - // The transition frame applies only the motion past `DRAG_SLOP` - // (20 - 8 = 12), not the full 20px since `press_start` -- see the - // `Pan` arm's own comment for why replaying the whole withheld - // drag in one step is the scroll-jitter bug this guards against. assert_eq!( a.update(Vec2::new(0.0, 20.0), t(10)), DragOutcome::Pan(12.0) ); - // Subsequent frames keep panning, by the delta since last frame. assert_eq!( a.update(Vec2::new(0.0, 35.0), t(20)), DragOutcome::Pan(15.0) ); } - /// Direct regression test for the fix: a slow drag that crosses - /// `DRAG_SLOP` by only a fraction of a pixel must not still produce a - /// visible jump -- the amount applied on the crossing frame should - /// itself shrink toward zero as the crossing gets closer to exactly - /// `DRAG_SLOP`, rather than always dumping the whole pre-threshold - /// distance at once. #[test] fn crossing_the_slop_by_a_little_pans_by_a_little() { let mut a = DragArbiter::new(); @@ -2670,10 +1811,6 @@ mod drag_arbiter_tests { fn a_horizontal_drag_with_nothing_selected_does_not_select() { let mut a = DragArbiter::new(); a.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); - // Horizontal movement alone, with no prior selection, is not any - // of the three named gestures -- it stays undecided rather than - // guessing (it will resolve to a long-press-selection if the - // finger then stops moving, or nothing if it lifts). assert_eq!( a.update(Vec2::new(20.0, 0.0), t(10)), DragOutcome::Undecided @@ -2699,9 +1836,6 @@ mod drag_arbiter_tests { a.update(Vec2::new(0.0, 0.0), t(LONG_PRESS.as_millis() as u64 + 1)), DragOutcome::SelectStart ); - // Even a vertical move now extends the selection rather than - // panning -- once a selection has started, it owns the gesture - // until release. assert_eq!( a.update(Vec2::new(0.0, 40.0), t(600)), DragOutcome::SelectExtend @@ -2742,15 +1876,6 @@ mod drag_arbiter_tests { ); } - /// A fresh arbiter that never saw `press_start` -- the state a widget's - /// own arbiter is left in when a gesture's `ACTION_DOWN` landed on a - /// pixel no sensor covered (a row's padding/gap, or its header) and - /// only a later `ACTION_MOVE` reached this widget. `update` must not - /// silently swallow the whole rest of the gesture here; `is_idle` is - /// what a caller checks to notice and recover (RUST.md's I5 - /// intermittent-touch-scroll-dropout finding, 2026-09-05) -- - /// `transcript_ui::selection::Selection::drag` is the real caller, - /// this is the pure-state half of the fix. #[test] fn is_idle_reports_a_press_that_was_never_started() { let a = DragArbiter::new(); @@ -2759,11 +1884,6 @@ mod drag_arbiter_tests { #[test] fn update_on_an_idle_arbiter_stays_undecided_forever_without_recovery() { - // Documents the failure this fix works around: calling `update` - // (as if the arbiter were mid-gesture) without ever having called - // `press_start` leaves it stuck answering `Undecided`, even for a - // movement well past `DRAG_SLOP` that would otherwise pan - // immediately. let mut a = DragArbiter::new(); assert_eq!( a.update(Vec2::new(0.0, 100.0), t(10)), @@ -2775,8 +1895,6 @@ mod drag_arbiter_tests { #[test] fn a_caller_can_recover_a_missed_press_start_via_is_idle() { let mut a = DragArbiter::new(); - // Simulates the real call site: a `Pressing` frame arrives with no - // matching `PressStart` ever having reached this arbiter. assert!(a.is_idle()); a.press_start(Vec2::new(0.0, 700.0), t(0), PressState::default()); assert_eq!( @@ -2786,10 +1904,6 @@ mod drag_arbiter_tests { assert!(!a.is_idle()); } - /// The tap-vs-drag rule a markdown link is followed by - /// (`transcript-ui`'s `row.rs`): a press that never committed is a - /// tap, and a press that panned or selected is not -- read from this - /// one machine rather than timed a second time beside it. #[test] fn a_press_that_never_moved_is_still_undecided_at_release() { let mut a = DragArbiter::new(); @@ -2799,9 +1913,6 @@ mod drag_arbiter_tests { assert!(!a.is_panning()); } - /// The half the tap rule had no reason to touch: a gesture that - /// panned must not also read as a tap when the finger comes up over - /// the link it started on. #[test] fn a_press_that_panned_is_not_undecided_at_release() { let mut a = DragArbiter::new(); @@ -2811,7 +1922,6 @@ mod drag_arbiter_tests { assert!(!a.is_undecided()); } - /// A long press that grew a selection is not a tap either. #[test] fn a_long_press_that_selected_is_not_undecided() { let mut a = DragArbiter::new(); @@ -2823,10 +1933,6 @@ mod drag_arbiter_tests { assert!(!a.is_undecided()); } - /// Both axes are one machine with the axis passed in: a horizontal - /// arbiter (a code fence panning across its own long lines) pans on - /// exactly the drag a vertical one ignores, and ignores the one it - /// pans on. #[test] fn a_horizontal_arbiter_pans_on_the_drag_a_vertical_one_ignores() { let mut across = DragArbiter::on(Axis::X); @@ -2843,9 +1949,6 @@ mod drag_arbiter_tests { DragOutcome::Undecided ); - // ...and a vertical drag over the horizontal one stays undecided, - // which is what lets the list behind a code fence still be - // panned by a finger that started on the fence. let mut across = DragArbiter::on(Axis::X); across.press_start(Vec2::new(0.0, 0.0), t(0), PressState::default()); assert_eq!( @@ -2867,10 +1970,6 @@ mod drag_arbiter_tests { } } -/// [`DragGesture`] end to end, at the shape Android actually delivers a -/// flick in. The arbiter and the tracker each behave correctly on their -/// own (the two modules above); what these cover is the join between them -/// at release, which is where the phone's missing fling lived. #[cfg(test)] mod drag_gesture_tests { use super::*; @@ -2882,27 +1981,14 @@ mod drag_gesture_tests { *BASE + Duration::from_millis(ms) } - /// The pointer as a handler sees it, with nothing captured. - /// `DragGesture` only ever reads and sets the holder, which needs no - /// widget tree behind it. fn pointer() -> PointerRequests { PointerRequests::default() } - /// The id a capture records. Any id will do -- nothing here - /// resolves it -- so it comes from a real (empty) widget registry - /// rather than being fabricated. fn some_id(ui: &mut UiData) -> WidgetId { ui.widgets.add_strong(Rect::new(UiColor::WHITE)).id() } - /// **The phone's shape.** A 120Hz flick reaches the app as very few - /// `MotionEvent`s -- the intermediate positions are batched inside - /// them as historical samples, which `IrisViewPeer::on_touch_event` - /// replays one at a time, so the frames here are what a whole flick - /// can amount to. Compose fits a quadratic through the positions, so - /// three of them (the press and two moves) is the fewest that can - /// fling; the number is `velocity_reference.py`'s, not this code's. #[test] fn a_flick_delivered_as_two_move_frames_releases_with_a_velocity() { let mut ui = UiData::default(); @@ -2943,10 +2029,6 @@ mod drag_gesture_tests { PressState::default(), ); - // (0, 0) (8, 100) (16, 220) through Compose's Lsq2 fit. Note the - // release adds no sample -- Compose's tracker ignores the UP - // position -- so the finger resting for those last 8ms costs - // nothing, which is the whole point of ignoring it. match out { GestureOutcome::Released(Some(v)) => { assert!((v - 16250.0).abs() < 20.0, "expected ~16250, got {v}"); @@ -2955,12 +2037,6 @@ mod drag_gesture_tests { } } - /// The case the port had every reason to get wrong: **one** move frame - /// is two samples, and a quadratic through two points is not a fit. - /// Compose answers 0 here (`minSampleSize = 3`) and so does this, so - /// the release is a pan that flings nothing rather than a pan that - /// flings at a guessed speed. Before 2026-09-07 the average answered - /// 12500px/s from the same gesture. #[test] fn a_flick_delivered_as_one_move_frame_carries_no_velocity_to_fit() { let mut ui = UiData::default(); @@ -2995,9 +2071,6 @@ mod drag_gesture_tests { assert_eq!(out, GestureOutcome::Released(Some(0.0))); } - /// The other half of the same join, and the case the fix had no - /// reason to touch: a press and release with no motion at all is a - /// tap, and must not acquire a velocity from the seeded press sample. #[test] fn a_tap_is_still_a_tap_and_flings_nothing() { let mut ui = UiData::default(); @@ -3024,10 +2097,6 @@ mod drag_gesture_tests { assert_eq!(out, GestureOutcome::Tapped); } - /// A long-press selection released while the finger was still moving - /// must not fling either -- `Released(None)`, never the tracked - /// velocity. Also untouched by the press-seeding above, which is why - /// it is checked here rather than assumed. #[test] fn a_selection_release_carries_no_velocity() { let mut ui = UiData::default(); @@ -3043,7 +2112,6 @@ mod drag_gesture_tests { t(0), PressState::default(), ); - // Held still past LONG_PRESS, which is what starts a selection. g.handle( &r, id, @@ -3063,9 +2131,6 @@ mod drag_gesture_tests { assert_eq!(out, GestureOutcome::Released(None)); } - /// A catch: the press lands on content that is already moving, so it - /// pans from its very first sample with no `DRAG_SLOP` withheld -- - /// docs/IRIS_TODO.md's "it fails to stop & snap to where finger is". #[test] fn a_press_on_moving_content_pans_from_the_first_sample() { let mut ui = UiData::default(); @@ -3086,11 +2151,8 @@ mod drag_gesture_tests { t(0), caught, ), - // The down itself moves nothing; it only stops the fling. GestureOutcome::Pan(0.0), ); - // A move of 2px, a quarter of `DRAG_SLOP` -- an ordinary press - // would still be `Undecided` here. assert_eq!( g.handle( &r, @@ -3104,9 +2166,6 @@ mod drag_gesture_tests { ); } - /// The sibling of the case above, and the pair that says the catch is - /// not simply "every press pans": the same two samples with nothing - /// moving underneath stay inside the slop and decide nothing. #[test] fn the_same_press_on_settled_content_stays_undecided() { let mut ui = UiData::default(); @@ -3135,12 +2194,6 @@ mod drag_gesture_tests { ); } - /// A catch released without moving is `Released(None)`: not a - /// `Tapped`, because Compose's scrollable consumed that DOWN and no - /// click detector under it ever saw the gesture -- so stopping a - /// fling with a finger must not also follow the link it landed on -- - /// and not a `Released(Some(v))`, because there is no velocity to - /// hand on. #[test] fn a_catch_released_without_moving_is_neither_a_tap_nor_a_fling() { let mut ui = UiData::default(); @@ -3169,14 +2222,8 @@ mod drag_gesture_tests { caught, ); assert_eq!(out, GestureOutcome::Released(None)); - // The identical gesture with nothing moving underneath is the - // tap it looks like -- `a_tap_is_still_a_tap_and_flings_nothing` - // above. The two differ by `scrolling` and nothing else. } - /// Once a catch has actually moved the content, its release is an - /// ordinary pan release again and hands on a velocity -- otherwise - /// "catch it, then keep flicking" would stop dead every time. #[test] fn a_catch_that_then_drags_still_flings() { let mut ui = UiData::default(); @@ -3220,14 +2267,6 @@ mod drag_gesture_tests { ); } - /// One touch-down reaches every sensor under the finger, and a - /// transcript row's block and the tool row containing it share one - /// `DragGesture` -- so `handle` sees the same `PressStart` twice, and - /// the second delivery carries a `PressState` the first has already - /// acted on (`Selection::drag` has cancelled the fling by then, so - /// `scrolling` is false). It must be a continuation, not a restart; - /// as a restart it silently turned every catch back into an ordinary - /// slop-waiting press, which is how this was found. #[test] fn a_second_delivery_of_one_press_start_does_not_restart_it() { let mut ui = UiData::default(); diff --git a/src/sense_tests.rs b/src/sense_tests.rs index 353379c..d8f849b 100644 --- a/src/sense_tests.rs +++ b/src/sense_tests.rs @@ -1,11 +1,3 @@ -//! IRIS_TODO.md's "Input does not fall through by input type": a widget -//! that only registered `click()` used to also block a `ScrollArea` meant for -//! whatever is behind it, because `run_sensors` decided "consumed, stop -//! looking at lower layers" from mere hover, not from anything actually -//! matching. Exercised as a plain unit test for the same reason -//! `layout_tests.rs` is one: `UiRenderState` and a minimal `HasEvents` -//! impl need no GPU or window. - use crate::prelude::*; use std::{cell::Cell, rc::Rc, time::Instant}; @@ -62,7 +54,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { events: EventManager::default(), }; - // Both cover the whole window -- the button "sitting over" the list, // the case in IRIS_TODO.md's report. let list = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let list_weak = list.weak(); @@ -88,9 +79,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { }); } - // A Stack draws its children on separate layers in order, which is - // exactly the "one thing drawn over another" shape `run_sensors` - // walks top layer first. let root = rsc .ui .widgets @@ -130,15 +118,6 @@ fn a_button_over_a_list_scrolls_the_list_and_still_clicks() { ); } -/// The bug behind "finger flings do nothing" (RUST.md's P0 phone report, -/// defect 2): a fast gesture's `PressEnd` can land at a screen position -/// nothing is registered at -- past the edge of whatever widget noticed -/// the press, in a gap, or off the loaded content entirely. Before pointer -/// capture, `run_sensors`' hit test simply delivered nothing that frame, -/// so a widget mid-drag never saw its release and never got a chance to -/// start a fling. `PointerRequests::capture`/`DragGesture` fix this -/// by giving the drag's widget every frame regardless of where the -/// pointer is, including the terminal `Drop` in place of `PressEnd`. #[test] fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { let mut rsc = SenseRsc { @@ -146,8 +125,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { events: EventManager::default(), }; - // A small draggable widget in the corner -- the release below lands - // far outside it, exactly the "moved off the hit region" case. let draggable = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any(); let draggable_weak = draggable.weak(); @@ -159,10 +136,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { CursorSense::click_or_drag() | CursorSense::unclick() | CursorSense::Drop, move |ctx, rsc| match ctx.data.sense { CursorSense::PressStart(_) | CursorSense::Pressing(_) => { - // Any committed drag takes capture -- a real caller - // would gate this on a `DragArbiter`/`DragGesture` - // decision, but this test only needs to exercise the - // capture-and-release mechanics themselves. ctx.data.pointer.capture(draggable_weak.id()); let _ = rsc; } @@ -187,8 +160,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { "the press should have taken capture" ); - // The release lands nowhere near the widget's own region -- the exact - // shape of a fast fling's `ACTION_UP`. let mut release = cursor_at((95.0, 95.0).into()); release.buttons.left = ActivationState::End; render.run_sensors(&mut rsc, &mut state, release, (100.0, 100.0).into()); @@ -206,12 +177,6 @@ fn a_release_outside_every_hit_region_still_reaches_the_captured_widget() { ); } -/// A widget that never registers `CursorSense::Drop` at all must not be -/// affected by someone else's capture -- capture is per-gesture, not -/// global suppression of the whole input system for widgets that were -/// never party to it. (Practically this matters because a captured -/// widget's registration list still has to include `Drop` for `should_run` -/// to ever match it; this pins that half of the contract.) #[test] fn capturing_one_widget_starves_every_other_widget_of_events() { let mut rsc = SenseRsc { @@ -257,13 +222,6 @@ fn capturing_one_widget_starves_every_other_widget_of_events() { ); } -/// IRIS_TODO.md's "the composer has no touch-drag scroll": `ScrollArea` only -/// answered a wheel, so a finger drag over overflowed text did nothing. -/// End-to-end over the real wiring -- `scrollable()`'s own registration, -/// `run_sensors`' dispatch, `ScrollController::drag`, `DragGesture`'s arbitration and -/// pointer capture -- rather than only `ScrollController::drag`'s own unit tests in -/// `scroll.rs`, because the registration is exactly the half those cannot -/// see. #[test] fn a_finger_drag_over_a_scroll_area_pans_it() { let mut rsc = SenseRsc { @@ -271,7 +229,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { events: EventManager::default(), }; - // 1000px of content in a 100px window: room to pan. let scroll_strong = rect(UiColor::WHITE) .height(Len::abs(1000.0)) .scrollable(Axis::Y, Pin::Start) @@ -282,11 +239,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - // `ScrollArea` reads its content length back from the draw it just did, so - // the frame after is the first one that knows there is anything to pan - // -- the one-frame lag LAYOUT.md section 4 documents. `scroll(0.0)` is - // how `layout_tests.rs` asks for that second frame, and it also drops - // `snap_end`, leaving this parked at the start of the content. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); render.update(&root, &mut rsc); assert_eq!(rsc.ui.widgets.get(&scroll).unwrap().amt(), 0.0); @@ -302,7 +254,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { "the touch-down alone must not move anything" ); - // Inside the slop: still a tap as far as anything can tell. let mut nudge = cursor_at((50.0, 80.0 - (DRAG_SLOP - 1.0)).into()); nudge.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, nudge, (100.0, 100.0).into()); @@ -313,8 +264,6 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { "a press inside DRAG_SLOP must not scroll" ); - // Past it, upward: the content follows the finger up, which for this - // widget means more `amt`. let mut drag = cursor_at((50.0, 80.0 - (DRAG_SLOP + 40.0)).into()); drag.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, drag, (100.0, 100.0).into()); @@ -325,23 +274,13 @@ fn a_finger_drag_over_a_scroll_area_pans_it() { "expected the 40px past the slop to pan it, got {after}" ); - // And the gesture holds the pointer, so the rest of it reaches this - // widget even once the finger leaves its box. assert_eq!(pointer_input(&mut rsc).holder(), Some(scroll.id())); } -/// A defect found in review, 2026-09-07. The first `MotionEvent` a view -/// sees can be a `Move` -- the `Down` went to another view, or the view was attached -/// mid-gesture -- and its batched samples are older than its own -/// timestamp. Anchoring on that timestamp clamped every one of them onto -/// the anchor, so the tracker saw three samples at one instant, the Lsq2 -/// fit went degenerate, and the flick read 0 px/s. #[test] fn the_first_events_batched_samples_are_dated_apart() { const MS: i64 = 1_000_000; let now = Instant::now(); - // A 120Hz batch: three historical samples at 0/4/8ms and the event's - // own at 12ms. let clock = DeviceClock::anchored(now, 12 * MS, 0); assert_eq!( @@ -361,9 +300,6 @@ fn the_first_events_batched_samples_are_dated_apart() { assert_eq!(clock.ms_since_anchor(8 * MS), 8); } -/// The same clock has to keep ordering *across* events: the sample it -/// compares a new event's first sample against is the previous event's -/// last one, never the anchor. #[test] fn the_clock_orders_samples_across_events() { const MS: i64 = 1_000_000; @@ -377,18 +313,6 @@ fn the_clock_orders_samples_across_events() { ); } -/// Iris's 2026-09-08 phone report, first half: "it keeps snapping back to -/// some position when horizontally scrolling." -/// -/// A `ScrollArea` that has committed to a pan holds the pointer, so the -/// gesture's end arrives as `CursorSense::Drop` -- and `scrollable` -/// used to register `click_or_drag | unclick` only, which `should_run` -/// never matches a `Drop` against. So the widget never learned its own -/// gesture had ended: its `DragArbiter` stayed `Panning` at the position -/// the finger left, and the *next* drag's first frame was measured from -/// there and applied in one step. The registration is -/// `CursorSense::drag_senses()` now, which is the rule for every widget -/// driving a `DragGesture` rather than a fact about this one. #[test] fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { let mut rsc = SenseRsc { @@ -417,8 +341,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { render.update(&root, rsc); }; - // One pan of 40px past the slop, then a release well outside the - // widget -- the ordinary shape of a flick. send(&mut render, &mut rsc, 80.0, ActivationState::Start); send( &mut render, @@ -435,9 +357,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { "the release must give the pointer back" ); - // A second gesture, starting where the first one did. If the arbiter - // were still panning from the release position, this first frame - // would apply the whole distance between the two at once. send(&mut render, &mut rsc, 80.0, ActivationState::Start); let after_second = rsc.ui.widgets.get(&scroll).unwrap().amt(); assert!( @@ -448,16 +367,6 @@ fn a_scroll_area_that_captured_the_pointer_learns_its_gesture_ended() { ); } -/// The second half of the same report: "tapping sometimes seems to make -/// the scrolling jump, particularly when tapping on things that have -/// events like horizontal scrolling." -/// -/// Two widgets see the same press -- a scroll area and, under it, -/// something tracking the gesture for a list. When the scroll area -/// captures, the other one is cut off completely: no `PressEnd`, no -/// `Drop`. It has to be told, or its gesture stays open at an origin -/// belonging to a finger that has long gone, and the next unrelated touch -/// is measured from it. #[test] fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { let mut rsc = SenseRsc { @@ -465,10 +374,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { events: EventManager::default(), }; - // The bystander contains the capturer on a lower visual layer: the - // shape of a vertical transcript scroller with a higher horizontal - // scroller inside one row. Both observe the undecided press, then only - // the recognizer matching its direction may consume it. let capturer = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let capturer_weak = capturer.weak(); let bystander = rsc.ui.widgets.add_strong(Stack { @@ -535,8 +440,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { "the widget that lost the gesture must be told exactly once" ); - // And exactly once: the frames after the capture reach the capturer - // alone, so there is nothing left to cancel. let mut more = cursor_at((50.0, 10.0).into()); more.buttons.left = ActivationState::On; render.run_sensors(&mut rsc, &mut state, more, win); @@ -554,16 +457,6 @@ fn taking_the_pointer_cancels_everyone_else_tracking_the_press() { ); } -/// Iris's rule for nested scrolling, 2026-09-08: "it should only trigger -/// horizontal if you drag left or right, and vertical should fall through -/// if you drag up or down." -/// -/// One mechanism does both, and it is `DragArbiter`'s existing axis test: -/// each scroll area's gesture commits only on its own axis, so a drag -/// along the other one is never claimed and the enclosing area's gesture -/// -- which sees the same press, being an ancestor rather than a sibling -/// layer -- is the one that commits and captures. This pins the pair, -/// including the direction the change had no reason to touch. #[test] fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { for (name, to, pans, still) in [ @@ -579,23 +472,16 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { ui: UiData::default(), events: EventManager::default(), }; - // 1000px square of content in a 100px window: room to pan either - // way, in an X area inside a Y one. let seen = Rc::new(Cell::new(None)); let record = seen.clone(); let outer_strong = rect(UiColor::WHITE) .width(Len::abs(1000.0)) .height(Len::abs(1000.0)) .scrollable(Axis::X, Pin::Start) - // The inner area's own handle, taken as the chain is built -- - // the whole point is to exercise `scrollable`'s real - // registration on both, so neither is assembled by hand. .with_id(move |_rsc, id| { record.set(Some(id)); id }) - // The horizontal area is visually above the vertical one, as - // it is when a raised transcript row contains sideways content. .layer_offset(1) .scrollable(Axis::Y, Pin::Start) .add_strong(&mut rsc); @@ -607,8 +493,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - // The second frame, where each area knows its content length -- - // LAYOUT.md section 4's one-frame lag, and what drops `snap_end`. for a in areas { rsc.ui.widgets.get_mut(&a).unwrap().scroll(0.0); } @@ -639,24 +523,6 @@ fn a_drag_pans_whichever_nested_scroll_area_owns_its_axis() { } } -/// Iris's 2026-09-08 report: "if I try to scroll vertically while a -/// horizontal scroll animation is still active, it stays locked to the -/// horizontal scroll", with her own diagnosis -- "tapping outside of -/// something that a fling is currently active for should have no code in -/// common with the fling that could influence it." -/// -/// She was right that it was global state, and this is where it lived. -/// `run_sensors` runs a widget one more frame *after* the pointer has -/// left it, so a `HoverEnd` can fire ([`ActivationState::End`], which is -/// not `Off`) -- and `should_run` derived a press from the button alone, -/// so that farewell frame also carried a `PressStart`. A widget nowhere -/// near the finger therefore opened a gesture, and a `ScrollArea` catching -/// its own fling commits with no slop, so it captured the pointer and the -/// whole gesture went to it. -/// -/// Two areas side by side here rather than one, because "the press went -/// to the wrong widget" and "the press went nowhere" are different -/// failures and only the second area can tell them apart. #[test] fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { let mut rsc = SenseRsc { @@ -664,11 +530,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { events: EventManager::default(), }; - // Two 1000px-tall scroll areas, stacked: the top half of the window - // is the first, the bottom half the second. Each area's own handle is - // taken as its chain is built (`with_id`, the same way the nested-axes - // test above does it), since what is under test is `scrollable()`'s - // real registration rather than a `ScrollArea` assembled by hand. let seen: [Rc>>>; 2] = Default::default(); let half = |slot: &Rc>>>| { let record = slot.clone(); @@ -691,19 +552,12 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { let mut render = UiRenderState::new(); render.resize((win.x, win.y)); render.update(&root, &mut rsc); - // The second frame is the first that knows how long the content is -- - // see `a_finger_drag_over_a_scroll_area_pans_it`. for w in [&top_w, &bottom_w] { rsc.ui.widgets.get_mut(w).unwrap().scroll(0.0); } render.update(&root, &mut rsc); let mut state = (); - // Flick the top area and let go: it is left flinging, and -- because - // the release goes through `run_sensors`' capture branch, which - // returns before the loop that would have updated anybody's hover -- - // its sensor is left `On` with the pointer no longer on it. Both - // halves of the real gesture, since both are what the bug needs. let base = Instant::now(); let mut t = 0; let sample = |render: &mut UiRenderState, @@ -746,9 +600,6 @@ fn a_press_does_not_reach_a_widget_the_pointer_has_just_left() { ); let flung_to = rsc.ui.widgets.get(&top_w).unwrap().amt(); - // Now press and drag in the *bottom* area: the top area's hover - // decays to `End` on this very sample, which is the frame that used - // to carry a `PressStart` to it. t += 8; sample( &mut render, diff --git a/src/widget/position/lazy_span.rs b/src/widget/position/lazy_span.rs index 6be7b8f..425b6c7 100644 --- a/src/widget/position/lazy_span.rs +++ b/src/widget/position/lazy_span.rs @@ -1,17 +1,8 @@ -//! A virtualised, variable-height span laid out from an anchor. -//! -//! It owns a `ScrollController`, draws only rows overlapping the viewport, -//! and leaves clipping to an optional `.masked()` wrapper. Cached row heights -//! let retained rows move directly; a new or resized row is drawn and then -//! placed at the exact extent it reports. See `docs/SCROLL.md`. use crate::prelude::*; use iris_core::util::HashMap; use std::collections::VecDeque; use std::time::Instant; -/// A stable identifier for a loaded row, reused across pages so that a row -/// already measured and drawn is not treated as new when data is inserted -/// elsewhere. See the module doc for why this is a plain integer. pub type RowKey = u64; /// One loaded row: a stable key plus its content widget, built by the @@ -30,30 +21,20 @@ impl LazyItem { } } -/// Which of a row's two edges is pinned in place, named along `dir` -/// rather than by screen position: `Leading` is the top of a `Dir::DOWN` -/// span and the bottom of a `Dir::UP` one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Edge { Leading, Trailing, } -/// A slot index one below the lowest real item index, reserved for the -/// "more before" sentinel. `isize::MIN` rather than `-1` so it can never -/// collide with a real index no matter how `items` grows, and so it needs -/// no adjustment when rows are inserted or removed at the front (only real -/// indices shift; the sentinels are fixed constants). const BEFORE_SLOT: isize = isize::MIN; /// The mirror of `BEFORE_SLOT` for "more after" -- fixed regardless of how /// many real items exist, so appending or removing at the back never has /// to touch it either. const AFTER_SLOT: isize = isize::MAX; +const GENEROUS_PADDING: f32 = 100_000.0; +const CONTENT_OFFSET_REBASE: f32 = 65_536.0; -/// Where the span is anchored: `slot`'s `edge` renders at `offset` pixels -/// from the viewport's leading edge (its top, for a `Dir::DOWN` span), and -/// everything else is placed outward from that one fixed point. See the -/// module doc's "Rows draw once and are moved" section. #[derive(Debug, Clone, Copy)] struct Anchor { slot: isize, @@ -61,42 +42,20 @@ struct Anchor { offset: f32, } -/// A visible row's on-screen box as of the last successful layout, cached -/// only so `note_tap`'s effect can be resolved next frame without a scan -/// over anything outside the viewport. Cleared on any structural change -/// (`push_front`/`push_back`/`pop_front`/`pop_back`/`set_more_*`) rather -/// than kept in step with slot-index shifts, since a tap landing in the -/// same tick as a page insert is rare enough that "the tap is silently -/// dropped" is an acceptable answer and it avoids a second piece of index -/// bookkeeping to keep correct. #[derive(Debug, Clone, Copy)] struct RowExtent { slot: isize, - /// Both in **direction-relative** pixels from this widget's leading - /// edge (`abs_region`'s space), not screen pixels -- the walk works in - /// one space and only the public helpers below convert - /// ([`LazySpan::to_screen`]). lead: f32, trail: f32, } -/// Which of a row's edges the walk already knows, and where it is. The -/// two are symmetric -- one pinned edge plus a height gives the box -- -/// so `place` parameterises over them through [`Self::edges`] rather -/// than carrying a copy of the same logic per direction. #[derive(Clone, Copy)] enum Placement { - /// This row's leading edge is known; its trailing edge is wherever its - /// own height puts it. Leading(f32), - /// This row's trailing edge is known; its leading edge is that height - /// back from it. Trailing(f32), } impl Placement { - /// The `(leading, trailing)` edges this placement implies for a row - /// of `height`. fn edges(self, height: f32) -> (f32, f32) { match self { Placement::Leading(lead) => (lead, lead + height), @@ -105,77 +64,28 @@ impl Placement { } } -/// A virtualised span of variable-height rows, laid out lazily from an -/// anchor. See the module doc for the design. pub struct LazySpan { - /// Which end of this widget's box item 0 sits at, and which way the - /// sequence grows -- the same meaning `Span::dir` has, so the word is - /// one concept across both. Deliberately *not* the same question as - /// which end the view is pinned to (`snap_end`): a transcript's oldest - /// message is item 0 and sits at the **top** (`Dir::DOWN`) while the - /// view sits at the **bottom**, so conflating the two would stand it - /// on its head. dir: Dir, items: VecDeque, more_before: Option, more_after: Option, anchor: Option, - /// The position, the gesture, the fling and the pin -- everything - /// about scrolling that is not this widget's own layout, in the same - /// struct a `ScrollArea` holds rather than a protocol between the two - /// (`docs/SCROLL.md`). This widget's `draw` is one instance of the - /// contract in [`ScrollController`]'s module doc: take the delta, - /// lay out, report what it did and how far it can still go. ctl: ScrollController, viewport_len: f32, - /// `viewport_len` as of the *previous* draw -- what `repair_anchor` - /// compares against to tell "the container was actually resized" from - /// "an ordinary frame where `snap_end` merely hasn't been recomputed - /// since a `scroll()` call yet." Without this distinction, - /// `repair_anchor` would re-snap a deliberate scroll away from the - /// bottom back to flush on the very next frame, since `snap_end` is - /// only recomputed at the *end* of a layout pass and so still reads - /// `true` (from before the scroll) the next time `repair_anchor` runs. last_viewport_len: f32, pending_tap: Option, extents: HashMap, - /// Last reported height, pruned when its row is evicted. heights: HashMap, - /// Direction-relative translation applied to every row through one - /// retained child-coordinate slot. Row regions subtract this value, so - /// changing it moves the visible run without changing any retained row's - /// own geometry. content_offset: f32, - /// Whether the last walk found no more content before the leading - /// edge *and* nothing left to give back there -- what - /// [`Self::overscroll_gap`] reads. `false` by default, matching - /// "assume there is more content until a walk proves otherwise." at_start: bool, - /// The mirror of `at_start` for the trailing end. at_end: bool, - /// The extreme edges the last walk reached, in the walk's own - /// direction-relative pixels, kept so [`Self::travel`] can say - /// **exactly** how much of a delta this span is able to take rather - /// than only whether it is against a wall: with no more content past - /// an edge, the travel left in that direction is the distance from - /// that edge to the viewport's. An estimate here would leave `amt` - /// drifting from what is on screen by every overshoot into a wall. content_lead: f32, content_trail: f32, - /// Whether the last walk ran out of items before its leading / - /// trailing edge -- the structural half of `at_start`/`at_end`, and - /// what says whether `content_lead`/`content_trail` bound a scroll at - /// all. With more content past an edge there is no bound to give. no_more_before: bool, no_more_after: bool, } impl LazySpan { - /// `pin` says which end of its content this span opens at and clings - /// to as rows arrive -- [`Pin::End`] for a transcript, and independent - /// of `dir`, which says where item 0 is (see the field). `dir` is also - /// what resolves [`Pin::Pos`]/[`Pin::Neg`], the axis-absolute way of - /// asking the same question. pub fn new(dir: Dir, pin: Pin) -> Self { Self { dir, @@ -207,10 +117,6 @@ impl LazySpan { self.items.is_empty() } - /// Insert-above: O(1). The anchor is keyed by slot index, not by an - /// absolute content offset, so the only bookkeeping a prepended row - /// needs is shifting that one index -- nothing about an already-placed - /// row's position is touched. See the module doc. pub fn push_front(&mut self, row: LazyItem) { self.items.push_front(row); if let Some(a) = &mut self.anchor @@ -222,9 +128,6 @@ impl LazySpan { self.extents.clear(); } - /// O(1). If the list is currently flush with its own end - /// ([`ScrollController::pinned_to_end`]), the new row becomes the - /// anchor so a live list stays pinned to its newest content. pub fn push_back(&mut self, row: LazyItem) { self.items.push_back(row); if self.ctl.pinned_to_end() { @@ -237,10 +140,6 @@ impl LazySpan { self.extents.clear(); } - /// O(1). If the anchor was pinned to the row being removed, it is - /// invalidated here and repaired (falling back to the bottom-most - /// remaining row) on the next `draw`, since there is no more specific - /// answer than "wherever this widget's default now is." pub fn pop_front(&mut self) -> Option { let popped = self.items.pop_front(); if let Some(row) = &popped { @@ -257,7 +156,6 @@ impl LazySpan { popped } - /// O(1); see `pop_front`. pub fn pop_back(&mut self) -> Option { let old_len = self.items.len() as isize; let popped = self.items.pop_back(); @@ -302,13 +200,6 @@ impl LazySpan { Some(old) } - /// Drop every loaded row and reset to the same state `LazySpan::new` would - /// give -- the fallback path for a change `apply`-style incremental - /// callers can't express as a replace-or-append (RUST.md: `group_tool_runs` - /// regrouping an earlier row). `more_before`/`more_after` are left - /// alone: a full paging reset is a different operation from "the - /// content changed," and a caller that wants both calls - /// `set_more_before(None)`/`set_more_after(None)` itself. pub fn clear(&mut self) { self.items.clear(); self.anchor = None; @@ -318,26 +209,6 @@ impl LazySpan { self.content_offset = 0.0; } - /// Move the anchor's edge by `amt` pixels, where positive brings - /// **later** content into view. - /// - /// Named apart from [`Scrollable::scroll`] rather than shadowing it: - /// the two run in different spaces and an inherent method silently - /// wins over a trait one, so a caller reaching for the public - /// convention would have got this instead. - /// - /// Private, and in the direction-relative space the walk works in - /// rather than the screen space every public delta speaks in: the - /// anchor's offset says where the pinned edge *sits*, so moving the - /// content forward moves that number down, and for a `Sign::Neg` - /// `dir` "forward" is up the screen rather than down it. - /// [`Self::flip_delta`] is the one conversion, exactly as - /// [`Self::flip_pos`] is for positions. - /// - /// Unclamped here, on purpose: it is one write. The controller does - /// the clamping, against the walls [`Self::travel`] published from the - /// last walk, and `overscroll_gap` gives back whatever that could not - /// know about. fn move_anchor(&mut self, amt: f32) { if self.anchor.is_none() { return; @@ -354,15 +225,6 @@ impl LazySpan { self.ctl.moved_by(moved); } - /// The anchor's own row index and pixel offset, formatted the same - /// shape Compose's `firstVisibleItemIndex`/`firstVisibleItemScrollOffset` - /// report (`idx=N/off=Mpx`) -- what RUST.md's "Benchmark v2" fling - /// phase reads before/after/between its fling runs so the two apps' - /// travel can be compared directly. `more_before`/`more_after` - /// sentinels print as `idx=more-before`/`idx=more-after` rather than - /// leaking their internal `isize` representation; `idx=none` if the - /// list has never drawn (no anchor yet -- e.g. right after - /// `jump_to_end` and before the next frame runs `repair_anchor`). pub fn anchor_position_display(&self) -> String { match self.anchor { None => "idx=none".to_string(), @@ -372,17 +234,11 @@ impl LazySpan { } } - /// Snap to the newest content (last item, or the `more_after` - /// sentinel if set), aligned to the trailing edge of the viewport -- - /// its bottom for a `Dir::DOWN` span. O(1). pub fn jump_to_end(&mut self) { self.anchor = None; self.pending_tap = None; } - /// Snap to the oldest loaded content (first item, or `more_before` if - /// set), aligned to the leading edge of the viewport -- its top for a - /// `Dir::DOWN` span. O(1). pub fn jump_to_start(&mut self) { let slot = if self.more_before.is_some() { BEFORE_SLOT @@ -399,16 +255,6 @@ impl LazySpan { self.pending_tap = None; } - /// Convert between the screen-space pixel offsets every caller of this - /// widget speaks in -- a pointer position, a row's box, both measured - /// from this widget's **top** (or left) -- and the direction-relative - /// space the walk works in, which for a `Sign::Neg` `dir` runs the - /// other way. Its own inverse, so one function covers both directions. - /// - /// The whole of the conversion lives at this boundary rather than in - /// the layout: `abs_region` is the only other place that knows which - /// way round the box is, and keeping the walk in one space is what - /// lets the anchor, the placement and the clamp be written once. fn flip_pos(&self, pos: f32) -> f32 { match self.dir.sign { Sign::Pos => pos, @@ -416,24 +262,6 @@ impl LazySpan { } } - /// [`Self::flip_pos`] for a *delta*: convert between the screen-space - /// scroll deltas every caller speaks in -- positive scrolls the - /// reader up or left, whatever this span's `dir` is -- and the - /// direction-relative amount [`Self::scroll`] takes, where positive - /// always brings later content into view. Its own inverse, so one - /// function covers both directions and both ways round. - /// - /// **The sign is a screen direction, not a logical one** (Iris, - /// 2026-09-08: "positive should always scroll up / left, and negative - /// down / right ... that way it always works as the user would - /// expect"). Without this a `Dir::UP` span pans backwards against - /// every other scrollable in iris for the same delta, because its - /// later content is *above* rather than below -- and a test written - /// in the walk's own space cannot see it, since both halves agree - /// with each other while the screen disagrees with both. - /// - /// A position needs `viewport_len` to flip about and a delta does - /// not, which is why they are two functions rather than one. fn flip_delta(&self, amt: f32) -> f32 { match self.dir.sign { Sign::Pos => -amt, @@ -441,12 +269,6 @@ impl LazySpan { } } - /// Record where (in pixels from this widget's top edge, the space a - /// pointer event arrives in) the user last touched it, for the *next* - /// layout pass in which some row's height changes to resolve against - /// -- see the module doc's "hold the edge nearest the tap" section. - /// Does not by itself mark anything dirty; the row whose height is - /// about to change is what triggers the redraw this is read during. pub fn note_tap(&mut self, viewport_pos: f32) { self.pending_tap = Some(self.flip_pos(viewport_pos)); } @@ -503,10 +325,6 @@ impl LazySpan { } } - /// The real row key at `slot`, or `None` for a sentinel -- sentinels - /// have no key of their own to cache an extent under, so they are - /// simply not addressable by `note_tap`'s hit test (nothing to expand - /// there). fn slot_key(&self, slot: isize) -> Option { match slot { BEFORE_SLOT | AFTER_SLOT => None, @@ -563,11 +381,6 @@ impl LazySpan { } } - /// Repair the anchor if the row it names no longer exists (evicted by - /// a `pop_*`, or a sentinel that was cleared), and re-home a - /// still-flush-with-the-end anchor's offset when the viewport itself - /// resized. Falls back to bottom-anchored-at-the-newest-content, - /// matching this widget's default when nothing else is known. fn repair_anchor(&mut self) { if self.items.is_empty() && self.more_before.is_none() && self.more_after.is_none() { self.anchor = None; @@ -629,26 +442,6 @@ impl LazySpan { } } - /// Move the anchor onto a row that is actually on screen, without - /// moving anything that is drawn: the row it re-homes to keeps the - /// exact top edge this frame's layout gave it. - /// - /// [`Self::scroll`] moves the anchor's *offset* and nothing else, so - /// panning away from the anchor's own row leaves that row further and - /// further outside the viewport, and every row between it and the - /// viewport has to be walked on every frame from then on -- before - /// `place`'s intersection test, drawn too. Measured on the bench - /// fixture before this: 8 scrolls of 3000px left **64 rows** placed in - /// a 2012px viewport, ~59 of them off-screen, and the ones above it - /// drawn straight over the header (docs/IRIS_TODO.md, 2026-09-07). - /// Re-homing each frame makes the walk O(visible) again whatever - /// distance was travelled, which is what the module doc claims. - /// - /// Only when the anchor's own row has left the viewport, so - /// `update_snap_end`'s pinned-to-newest anchor -- last slot, bottom - /// edge at the viewport's own bottom, which intersects it -- is left - /// exactly as it is rather than rewritten into a top-edge anchor that - /// no longer reads as flush with the end. fn rehome_anchor(&mut self) { let Some(anchor) = self.anchor else { return; @@ -656,9 +449,6 @@ impl LazySpan { if self.extents.values().any(|e| e.slot == anchor.slot) { return; } - // The topmost row on screen, so the anchor's offset stays a small - // number near the viewport's own leading edge rather than - // whatever the last row's bottom happens to be. let Some(first) = self .extents .values() @@ -679,39 +469,10 @@ impl LazySpan { }); } - /// The empty band at one edge that content on the other side of the - /// viewport could fill -- positive to move content toward the leading - /// edge -- or `None` when the layout already sits on its content. - /// This is what makes a `scroll` or a fling past the end of the - /// content settle *on* the end rather than beyond it. - /// - /// `top`/`bottom` are the extreme edges the walk actually placed, so - /// the gap is already measured: `at_start` means nothing is above - /// `top`, and if `top` is nevertheless below the viewport's own - /// leading edge then those pixels are empty and always will be. This - /// is the whole of what the module doc used to list as deliberately - /// unsolved ("no overscroll clamping ... nothing to measure how much - /// content is left without walking it") -- true of *total* content - /// height, but the walk hands back both ends of the loaded run for - /// free, which is all a clamp needs. `tick_fling` stops a fling that - /// has reached an end, but stops it wherever the spline's last step - /// had already put it: a hard fling to the top of the bench fixture - /// left the first row **1398px below** a 600px viewport, i.e. the - /// whole screen blank, and it stayed there (docs/IRIS_TODO.md, - /// 2026-09-07: "black from the header down"). - /// - /// **Only when the opposite end is not also inside the viewport.** - /// Both at once means the content is shorter than the viewport, where - /// the space is not overscroll at all -- it is a bottom-anchored list - /// with three rows in it, and pulling those to the top would be this - /// widget rejecting its own default (`repair_anchor`). fn overscroll_gap(&self, lead: f32, trail: f32) -> Option { if self.at_start == self.at_end { return None; } - // `at_start`/`at_end` already carry the sign of their own gap - // (`top >= 0.0`, `bottom <= viewport_len`), so this is the gap - // itself, positive to move content toward the leading edge. let gap = if self.at_start { lead } else { @@ -723,15 +484,6 @@ impl LazySpan { (gap.abs() >= 0.5).then_some(gap) } - /// Place every row that reaches the viewport, outward from the - /// anchor, and return the extreme `(leading, trailing)` edges the walk - /// reached. Rebuilds `extents` and `at_start`/`at_end` from what it - /// placed; the caller clears `extents` first, since `reanchor_at_tap` - /// reads the previous frame's copy. - /// - /// Called a second time in the same `draw` when the first pass lands - /// off the end of the content -- see [`Self::overscroll_gap`] and - /// `draw`. fn lay_out(&mut self, painter: &mut Painter, stable_anchor_lead: Option) -> (f32, f32) { let anchor = self .anchor @@ -779,14 +531,6 @@ impl LazySpan { self.content_lead = lead; self.content_trail = trail; - // Both halves of `intersects_viewport`'s rule, checked where they - // are cheap to check: what this pass put on screen is exactly what - // overlaps the viewport, and nothing above or below it can be - // seen. The first failed silently for a whole build -- an - // off-screen row draws correctly, it is just in the wrong place. - // `assert!` for R1's reason: it walks the rows *on screen*, a - // handful, once per draw, and a release build is the only build - // this fault has ever been seen in. assert!( self.extents .values() @@ -812,30 +556,10 @@ impl LazySpan { self.ctl.set_pinned_to_end(pinned); } - /// **The one rule for what this list draws**: a row is on screen if - /// any part of it is, so a row straddling either edge is drawn *in - /// full* and one that has left the viewport entirely is not drawn at - /// all. Both halves matter and they failed in opposite directions on - /// Iris's phone (docs/IRIS_TODO.md, 2026-09-07): rows already scrolled - /// past were still being drawn, over the header above the list, and - /// the part of a straddling row above the viewport had nothing - /// clipping it. The viewport here is the list's own box -- `0 .. - /// viewport_len`, `painter.region()` in window terms -- which is the - /// same box `LazySpan::draw` requires a mask on, so that what this test - /// admits and what the clip keeps are one region rather than two that - /// can disagree. fn intersects_viewport(&self, lead: f32, trail: f32) -> bool { trail > 0.0 && lead < self.viewport_len } - /// The box between two edges measured **along `dir`** from this - /// widget's own leading edge, which for `Sign::Neg` is its bottom (or - /// right). Every other length in this widget -- `viewport_len`, an - /// `Anchor`'s offset, a `Placement`'s edges -- is in that same - /// direction-relative space, so the whole layout is written once and - /// only this function knows which way round the box is. The mirror is - /// `UiSpan::flip`, the same one `Span::draw` uses for a reversed - /// `Dir`. fn abs_region(dir: Dir, start: f32, end: f32) -> UiRegion { let span = UiSpan::new(UiScalar::abs(start), UiScalar::abs(end)); let mut region = UiRegion::from_axis(dir.axis, span, UiSpan::FULL); @@ -845,9 +569,6 @@ impl LazySpan { region } - /// A row's stable local box. `lead`/`trail` are where it belongs on - /// screen; the child-coordinate move slot adds `content_offset` back in - /// the shader and in `resolved_region`. fn row_region(&self, lead: f32, trail: f32) -> UiRegion { Self::abs_region( self.dir, @@ -870,15 +591,11 @@ impl LazySpan { } } - /// Keep an anchor row's local leading edge unchanged while its desired - /// on-screen leading edge moves. Scrolling and end-pinned growth can then - /// move the visible run through one child-coordinate write. fn stabilize_lead(&mut self, painter: &mut Painter, from: f32, to: f32) { self.content_offset += to - from; self.apply_content_offset(painter); } - /// Place a row and remember its height for later walks. fn place( &mut self, painter: &mut Painter, @@ -904,12 +621,6 @@ impl LazySpan { }; let key = self.slot_key(slot); let cached = key.and_then(|k| self.heights.get(&k).copied()); - // A dirty descendant is redrawn before its changed size bubbles up - // to this list. By the time the anchored row is placed, its retained - // draw can therefore already report the new height without another - // provisional row draw. This is deliberately anchor-only: the walk - // has no old on-screen reference with which to stabilize another - // row's coordinate space. 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) @@ -977,20 +688,7 @@ impl LazySpan { } (lead, trail) } -} -/// Primary-axis room for a row whose extent is not cached yet. -const GENEROUS_PADDING: f32 = 100_000.0; - -/// Keep stable local row coordinates precise over an indefinitely long -/// scroll. Crossing this costs one rare O(visible) rebase of row move slots; -/// ordinary ticks remain one child-coordinate write. -const CONTENT_OFFSET_REBASE: f32 = 65_536.0; - -impl LazySpan { - /// Make this span scrollable: the wheel and a finger drag, registered - /// on the span itself. - /// /// **Inherent, and it shadows `WidgetLike::scrollable` on purpose.** /// That one wraps its widget in a `ScrollArea`, which is exactly what /// must not happen here -- a lump slid about by a parent would never @@ -1000,10 +698,6 @@ impl LazySpan { /// this, and it needs neither of the other's arguments: the axis is /// `dir`'s and the pin was chosen at construction. /// - /// A span reached through a builder (already wrapped, already behind a - /// closure) gets the trait method instead, correctly -- by then it is - /// a different widget. - /// /// A caller with a drag arbiter of its own registers the wheel and /// leaves the drag out rather than calling this: one gesture, one /// arbiter (`transcript_ui`'s `Selection`, and `DragGesture`'s doc). @@ -1011,29 +705,7 @@ impl LazySpan { let axis = self.dir.axis; scroll_senses(self, axis) } -} -impl Scrollable for LazySpan { - fn controller(&self) -> &ScrollController { - &self.ctl - } - - fn controller_mut(&mut self) -> &mut ScrollController { - &mut self.ctl - } -} - -impl LazySpan { - /// How far this span can still travel each way, from the edges the - /// last walk actually placed -- what the controller clamps the next - /// delta against, so that a delta running 250px past the end gives - /// 250 back rather than everything or nothing. - /// - /// **The bound is exact where there is one, and `INFINITY` where there - /// is not.** With content still loaded past an edge this span - /// genuinely cannot say how far it goes without walking there, and - /// saying so is what lets the walk find the wall and `overscroll_gap` - /// hand the overshoot back inside the same frame. fn travel(&self) -> Travel { let forward = if self.no_more_after { (self.content_trail - self.viewport_len).max(0.0) @@ -1045,11 +717,6 @@ impl LazySpan { } else { f32::INFINITY }; - // `forward`/`backward` are the walk's own directions -- toward - // later content and toward earlier -- and `Travel` is in screen - // space, so which is which depends on `dir` exactly as - // `flip_delta` does. A `Dir::UP` span's later content is *above* - // it, so scrolling back down the screen is what runs out first. match self.dir.sign { Sign::Pos => Travel { back: backward, @@ -1063,6 +730,16 @@ impl LazySpan { } } +impl Scrollable for LazySpan { + fn controller(&self) -> &ScrollController { + &self.ctl + } + + fn controller_mut(&mut self) -> &mut ScrollController { + &mut self.ctl + } +} + impl Widget for LazySpan { /// A lazy span animates exactly one thing, its fling -- and it drives /// its own rather than being handed deltas by a `ScrollArea` around @@ -1091,20 +768,12 @@ impl Widget for LazySpan { // stale (the content changed under a settled anchor) the walk // below finds the real ones and `overscroll_gap` gives back the // difference before this frame ends. - // - // Deliberately after the early return above: a delta that arrives - // while there is nothing to scroll stays banked rather than being - // silently spent on an empty list. let delta = self.ctl.take_delta(); if delta != 0.0 { let amt = self.flip_delta(delta); self.move_anchor(amt); } - // `reanchor_at_tap` reads `extents` as they stood after the - // *previous* frame's layout -- the last on-screen box for each - // visible row -- so the clear that starts rebuilding it for this - // frame has to wait until after this call, not before. if let Some(tap) = self.pending_tap.take() { self.reanchor_at_tap(tap); } @@ -1120,26 +789,6 @@ impl Widget for LazySpan { let (lead, trail) = self.lay_out(painter, stable_anchor_lead); - // **The clamp is applied inside the frame that found it**, not - // marked for the next one: layout is a pure function of the state - // rather than of how many frames have been drawn (Iris, - // 2026-09-08), and a correction that lands next frame is a frame - // drawn wrong -- with nothing guaranteed to ask for that next - // frame, since a fling that ran out at an end has already stopped - // requesting them, which is exactly what left the list parked past - // its own first row. Same shape as `Scroll::draw`, which measures - // its content and places it again in the one frame. - // - // One further pass settles it, always: the gap is measured from - // the edges this walk actually placed, so moving the anchor by it - // puts that edge exactly on the viewport's, and the rows the - // second walk brings into view are placed outward from there. The - // opposite end cannot open a new gap -- that would mean the - // content is shorter than the viewport, which `overscroll_gap` - // already declines to touch. The extra walk is paid only on a - // frame that was overscrolled, and it re-offers every row the same - // box at a new offset, which `draw_inner` dispatches as an O(1) - // move. if let Some(gap) = self.overscroll_gap(lead, trail) { let stable_anchor_lead = self.anchor.and_then(|anchor| { self.extents @@ -1168,17 +817,8 @@ mod tests { use super::*; use std::time::Instant; - /// Every row in `build_flingable_list` is this tall, which is what - /// makes `scroll_position` exact. const FLING_ROW_H: f32 = 20.0; - /// How far a `build_flingable_list` span has scrolled from its very - /// first row, in pixels: read off the leading row on screen, whose - /// content position is exactly `slot * FLING_ROW_H` because every row - /// there is that tall. Measures where the content actually sits rather - /// than any bookkeeping about it, and unlike a single row's extent it - /// stays defined however far the list travels -- `extents` holds only - /// what is on screen (`LazySpan::intersects_viewport`). fn scroll_position(list: &LazySpan) -> f32 { let first = list .extents @@ -1201,8 +841,6 @@ mod tests { } } - /// A fixed-height row so its size is exact and predictable in tests -- - /// mutating `.y` afterward is how tests simulate a row "expanding." fn fixed_row(rsc: &mut TestRsc, height: f32) -> (WeakWidget, StrongWidget) { let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)); let sized = rsc.ui.widgets.add_strong(Sized { @@ -1213,8 +851,6 @@ mod tests { (sized.weak(), sized.any()) } - /// Pushes one row per key and returns the rows' own weak handles (for - /// mutating a specific row's height later), in the same order as `keys`. fn push_rows( rsc: &mut TestRsc, list: &mut LazySpan, @@ -1230,16 +866,6 @@ mod tests { .collect() } - /// Adds `list` to the arena and returns both a typed weak handle (for - /// calling `LazySpan`'s own methods through `Widgets::get`/`get_mut`, which - /// need a `Sized` widget type) and the erased root `UiRenderState::update` - /// draws. - /// - /// The root is a `Masked` around the list rather than the list - /// itself, because that is what every real caller has to do -- a - /// `LazySpan` draws the row straddling each edge in full and asserts - /// something is clipping it (`LazySpan::draw`). The mask is the full - /// window here, which is also the list's own box. fn add_list(rsc: &mut TestRsc, list: LazySpan) -> (WeakWidget, StrongWidget) { let strong = rsc.ui.widgets.add_strong(list); let weak = strong.weak(); @@ -1250,13 +876,6 @@ mod tests { (weak, root.any()) } - /// The case the top-edge cull and the overscroll clamp both had no - /// reason to touch: fewer rows than fit. Every one of them is drawn - /// (nothing here is outside the viewport), and `overscroll_gap` - /// leaves the list bottom-anchored -- the gap above the first row is - /// not overscroll, it is where this widget puts a short list, and - /// pulling it to the top would be the clamp overriding - /// `repair_anchor`'s own default. #[test] fn a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom() { let mut rsc = TestRsc { @@ -1268,9 +887,6 @@ mod tests { let mut render = UiRenderState::new(); render.resize((100.0, 100.0)); - // Several frames, since the clamp acts on the frame *after* the - // one that measured a gap: a wrong one would walk the rows up the - // screen 40px at a time rather than settle. for _ in 0..4 { render.update(&root, &mut rsc); let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); @@ -1290,22 +906,6 @@ mod tests { } } - /// `Dir::UP` is not a screen direction bolted on at the end: item 0 - /// sits at the **bottom** and the sequence grows toward the top, which - /// is the mirror of `Dir::DOWN` and the reason the walk is written in - /// leading/trailing terms with `abs_region` the only place that knows - /// which way round the box is. Same three rows, same viewport, so the - /// only difference from - /// `a_list_shorter_than_the_viewport_is_drawn_whole_and_stays_at_the_bottom` - /// is `dir` -- and the newest row moves from the bottom of the screen - /// to the top. - /// - /// Asserts on each row's **resolved window region**, not on `extents`: - /// those are kept in the walk's own direction-relative space and - /// converted on the way out, so an `extent()`-only test passes even with - /// the flip in `abs_region` deleted. The resolved region also includes - /// the child-coordinate offset that now keeps retained row geometry - /// stable. #[test] fn a_dir_up_span_grows_upward_from_item_zero() { let mut rsc = TestRsc { @@ -1337,31 +937,13 @@ mod tests { "item 0 is drawn furthest down" ); - // And the public extent agrees with the pixels, in screen space. let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(list_ref.extent(2), Some((0.0, 20.0))); assert_eq!(list_ref.extent(0), Some((40.0, 60.0))); } - /// A delta means a screen direction, not a logical one: the same - /// negative delta moves the content up the screen whichever way the - /// span is laid out (Iris, 2026-09-08 -- "positive should always - /// scroll up / left, and negative down / right"). A `Dir::UP` span - /// used to pan the opposite way for the same number, because - /// the delta reached the walk without the flip its - /// positions already went through. - /// - /// Asserted on where rows were **drawn**, for the reason - /// `a_dir_up_span_grows_upward_from_item_zero` gives: an assertion in - /// the walk's own space checks the bookkeeping against itself and - /// passes with the flip deleted. #[test] fn a_delta_moves_both_directions_the_same_way_on_screen() { - // 10 rows of 20px in a 100px viewport. Both spans open pinned to - // the end of their content -- which is the bottom of the screen - // for `Dir::DOWN` and the top of it for `Dir::UP` -- so each is - // first walked into the middle, where both have content to move - // in either direction, and only then handed the same delta. let moved_by = |dir: Dir| { let mut rsc = TestRsc { ui: UiData::default(), @@ -1385,8 +967,6 @@ mod tests { "there was content to take the whole delta: asked {delta}, moved {moved}", ); }; - // Away from the pinned end, in whichever screen direction - // that is for this `dir`. push( &mut rsc, &mut render, @@ -1396,8 +976,6 @@ mod tests { }, ); - // Row 4 is on screen in both spans now, and stays drawn - // across a move this small whichever way it goes. let top = |render: &UiRenderState, rsc: &TestRsc| { render .window_region(&rows[4], rsc) @@ -1419,11 +997,6 @@ mod tests { } } - /// The conversion the reversed direction makes necessary: a pointer - /// position arrives in screen pixels while the walk works in - /// direction-relative ones, so a hit test that skipped the flip would - /// silently answer with the row mirrored across the viewport -- wrong - /// in a way that looks like a working list until you tap one. #[test] fn a_reversed_span_hit_tests_in_screen_space() { let mut rsc = TestRsc { @@ -1464,8 +1037,6 @@ mod tests { render.resize((100.0, 60.0)); render.update(&root, &mut rsc); - // 5 rows of 20px into a 60px viewport: rows 2,3,4 visible, bottom - // (row 4) flush with the viewport's own bottom edge. let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert_eq!(list_ref.extents.len(), 3); let last = list_ref.extents[&4]; @@ -1474,21 +1045,11 @@ mod tests { assert!(!list_ref.extents.contains_key(&1)); } - /// A row shaped like the ordinary `.background(rect(tint))` idiom: - /// a `Stack` whose first child is a `Rect` (`is_size_independent`, - /// fills whatever region it is given -- see `rect.rs`) and whose - /// second (the one `StackSize::Child` reports as the row's own size) - /// is a `Sized`-wrapped `Rect` of the given height. Returns the - /// background rect's own id (to check what it actually painted at) - /// alongside the row widget. fn background_styled_row(rsc: &mut TestRsc, height: f32) -> (WidgetId, StrongWidget) { let (bg_id, _, row) = resizable_background_row(rsc, height); (bg_id, row) } - /// [`background_styled_row`] with the foreground's own `Sized` handed - /// back too, so a test can change the row's height the way a tool card - /// being collapsed or opened does. fn resizable_background_row( rsc: &mut TestRsc, height: f32, @@ -1509,18 +1070,6 @@ mod tests { (bg_id, fg_weak, rsc.ui.widgets.add_strong(stack).any()) } - /// Iris's 2026-09-08 report: "collapsing and opening an edit card - /// draws the card background a frame late, so it looks closed even - /// when there's text, and then looks open even when the text is - /// collapsed." - /// - /// A row is offered a box sized to its *cached* height, and a - /// `.background(rect(..))` fills whatever box it is given -- so on the - /// frame a row changes height its text is laid out at the new height - /// and its background painted at the old one. Every row is exercised, - /// in both directions, because which of `place`'s two placements a row - /// takes depends on where it sits relative to the anchor and the fault - /// was in both. #[test] fn a_row_that_changes_height_draws_its_background_at_the_new_height_immediately() { for key_to_change in 0..5u64 { @@ -1565,17 +1114,6 @@ mod tests { #[test] fn a_fill_shaped_background_is_not_left_oversized() { - // Regression test for a real bug found building the I3 example: - // `place`'s Bottom-known branch used to measure a row at an - // oversized, fixed-size region and placement (a pure - // translation) it into its final box. A row's own natural height - // is independent of that oversized offer (true for wrapped text), - // but a `Rect` background is *defined* to fill whatever it is - // given -- so it painted at the oversized size, and moving it - // afterward never shrank it back down. Only visible by checking - // what the background rect's own primitive covers, not the row's - // reported extent (which was already correct, since it comes from - // the *foreground* child). let mut rsc = TestRsc { ui: UiData::default(), }; @@ -1620,8 +1158,6 @@ mod tests { let extents_before = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); - // Prepend far-above rows one at a time -- an O(1) push each, - // touching nothing currently on screen. for key in 0..10u64 { let (_, w) = fixed_row(&mut rsc, 20.0); rsc.ui @@ -1633,11 +1169,6 @@ mod tests { render.update(&root, &mut rsc); let (draws, _rewrites, _moves, _shapes) = render.take_counters(); - // None of the already-visible rows (11, 12) were touched: the - // extents for those keys are numerically unchanged, and the only - // draws possible are for the list widget itself plus any row that - // entered view (none did -- the prepended rows are far above the - // anchor's slot, which only moved by an index, not a redraw). let extents_after = rsc.ui.widgets.get(&list_weak).unwrap().extents.clone(); for key in [11u64, 12] { assert_eq!( @@ -1645,9 +1176,6 @@ mod tests { (extents_after[&key].lead, extents_after[&key].trail) ); } - // The list widget itself draws once (it was marked dirty by the - // pushes); no row draw is attributable to the 10 prepended rows, - // since none of them ever entered the viewport. assert!( draws <= 2, "insert-above touched more than the list itself: {draws} draws" @@ -1660,8 +1188,6 @@ mod tests { ui: UiData::default(), }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); - // Five rows of 20px; with a 100px viewport all are visible, - // anchored at the bottom by default (row 4's bottom at 100). let rows = push_rows(&mut rsc, &mut list, &[0, 1, 2, 3, 4], 20.0); let (list_weak, root) = add_list(&mut rsc, list); @@ -1669,9 +1195,6 @@ mod tests { render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - // Row 2 occupies [40, 60) before it grows. Tap near its top edge - // (41) so growing it should hold *that* edge fixed and push row 3 - // and row 4 further down, leaving rows 0/1 untouched above it. let row2 = rows[2]; { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); @@ -1681,8 +1204,6 @@ mod tests { } rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(41.0); - // Grow row 2 from 20px to 50px -- marks it (and, once its size - // changes, the list) dirty via the ordinary redraw-bubble path. rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); render.update(&root, &mut rsc); @@ -1690,9 +1211,6 @@ mod tests { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let row2_ext = list_ref.extents[&2]; let row3_ext = list_ref.extents[&3]; - // Top edge held at 40 (nearest the tap at 41): row 2 now spans - // [40, 90), and row 3 -- below the grown row -- is pushed down to - // start at 90, not left at its old 60. assert!( (row2_ext.lead - 40.0).abs() < 0.01, "top edge should stay put: {row2_ext:?}" @@ -1705,7 +1223,6 @@ mod tests { (row3_ext.lead - 90.0).abs() < 0.01, "row below the expanded row should be pushed down: {row3_ext:?}" ); - // Rows above the expanding row are untouched. let row1_ext = list_ref.extents[&1]; assert!((row1_ext.lead - 20.0).abs() < 0.01); assert!((row1_ext.trail - 40.0).abs() < 0.01); @@ -1724,7 +1241,6 @@ mod tests { render.resize((100.0, 100.0)); render.update(&root, &mut rsc); - // Row 2 spans [40, 60). Tap near its bottom (59) instead. let row2 = rows[2]; rsc.ui.widgets.get_mut(&list_weak).unwrap().note_tap(59.0); rsc.ui.widgets.get_mut(&row2).unwrap().y = Some(Len::abs(50.0)); @@ -1733,8 +1249,6 @@ mod tests { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); let row2_ext = list_ref.extents[&2]; let row1_ext = list_ref.extents[&1]; - // Bottom edge held at 60: the row grows upward instead, and row 1 - // (above it) is pushed up to end at 10, not left at 20. assert!( (row2_ext.trail - 60.0).abs() < 0.01, "bottom edge should stay put: {row2_ext:?}" @@ -1767,14 +1281,6 @@ mod tests { render.update(&root, &mut rsc); render.take_counters(); - // Backwards, into content that exists: a list opens flush with - // its newest end, so a *negative* delta from there is - // overscroll, and the clamp lays out a second time within the - // frame to give it back -- a correct extra pass, but not the - // ordinary scroll tick whose cost this test is about. - // Admit any row touching the viewport's leading edge first; its - // initial placement is real virtualization work, not movement of - // the already-retained run. rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(5.0); render.update(&root, &mut rsc); render.take_counters(); @@ -1783,10 +1289,6 @@ mod tests { render.update(&root, &mut rsc); let (draws, _rewrites, moves, _shapes) = render.take_counters(); - // The visible window is a fixed ~10 rows regardless of n. The - // row walk still runs so virtualization can admit and retire - // rows, but every retained row shares the list's one child - // coordinate move. 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"); } @@ -1806,8 +1308,6 @@ mod tests { render.resize((100.0, 200.0)); render.update(&root, &mut rsc); - // Accumulate enough shared translation to cross the precision - // threshold while staying away from either end of the content. rsc.ui .widgets .get_mut(&list_weak) @@ -1828,9 +1328,6 @@ mod tests { .top_left .y; - // This draw first rebases the local coordinate space, then applies - // the requested pixel of movement. Neither operation may jump the - // rendered row. rsc.ui.widgets.get_mut(&list_weak).unwrap().scroll(1.0); render.update(&root, &mut rsc); let after = render @@ -1845,13 +1342,6 @@ mod tests { ); } - /// The streamed-reply case (RUST.md's "streaming still costs a full - /// rebuild" fix, `transcript-ui::TranscriptScreen::apply`): a delta - /// swaps the last row's widget for a taller one, same key, same slot. - /// A list flush with its own end (the default, `snap_end`) must stay - /// flush -- the row grows *upward* from the pinned bottom edge, not - /// the other way around, exactly like an ordinary resize of that same - /// row would (`expanding_a_row_holds_the_bottom_edge_when_tap_is_lower`). #[test] fn replacing_the_last_row_stays_pinned_to_the_bottom() { let mut rsc = TestRsc { @@ -1865,7 +1355,6 @@ mod tests { render.resize((100.0, 60.0)); render.update(&root, &mut rsc); - // Row 4 is flush with the viewport's bottom edge before the replace. { let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!((list_ref.extents[&4].trail - 60.0).abs() < 0.01); @@ -1897,15 +1386,6 @@ mod tests { ); } - /// Neither `replacing_the_last_row_stays_pinned_to_the_bottom` nor - /// its sibling below ever asserts the *evicted* key's own bookkeeping - /// is actually gone -- both replace row 4 with another row also keyed - /// `4`, so `heights.remove(&old.key)` removing and re-inserting the - /// same key would pass either test even if it did nothing (review, - /// 2026-09-06; the same class of bug -- a stale handle outliving what - /// it points to -- - /// production-tested from `LazySpan`'s own side). Replacing with a - /// **different** key is what actually exercises the removal. #[test] fn replace_back_forgets_the_evicted_keys_own_height() { let mut rsc = TestRsc { @@ -1943,12 +1423,6 @@ mod tests { ); } - /// The other half of the same fix's contract: replacing a row that is - /// *not* on screen must not move anything that is. `replace_back` only - /// touches the last slot's own widget and this file's own `heights`/ - /// `extents` caches for that one key -- nothing about `Anchor` changes - /// -- so the already-placed rows above it should come out at the exact - /// same boxes on the next frame. #[test] fn replacing_the_last_row_out_of_view_does_not_move_visible_rows() { let mut rsc = TestRsc { @@ -1960,15 +1434,7 @@ mod tests { let mut render = UiRenderState::new(); render.resize((100.0, 60.0)); - // Settle at the default (bottom) anchor first -- `jump_to_start` - // does not touch `snap_end`, and `repair_anchor` only leaves a - // freshly-set anchor's offset alone once `viewport_len` has - // already matched `last_viewport_len` once, the same reason - // `moves_stay_o1_across_list_size` settles before the tick it - // actually measures. render.update(&root, &mut rsc); - // Scrolled to the oldest content: rows 0,1,2 visible, row 4 is far - // below the viewport. rsc.ui.widgets.get_mut(&list_weak).unwrap().jump_to_start(); render.update(&root, &mut rsc); @@ -2002,7 +1468,6 @@ mod tests { } } - /// Replacing a streamed row must not grow the active widget set. #[test] fn replacing_the_last_row_many_times_does_not_leak_primitives() { let mut rsc = TestRsc { @@ -2040,17 +1505,12 @@ mod tests { ); } - /// An ancestor redraw must retire every replaced primitive. #[test] fn an_ancestor_redrawing_a_dirty_row_leaves_no_stale_copy() { let mut rsc = TestRsc { ui: UiData::default(), }; let mut list = LazySpan::new(Dir::DOWN, Pin::End); - // Rows that own a primitive *at their own id* (a background rect), - // not only through a child: an orphan is a widget's own primitive - // outliving its own redraw, so a row whose top-level widget paints - // nothing itself cannot show one however broken the path is. let mut rows = Vec::new(); for key in 0..5u64 { let (bg_id, row) = background_styled_row(&mut rsc, 20.0); @@ -2064,16 +1524,10 @@ mod tests { render.update(&root, &mut rsc); assert!(render.orphaned_primitives().is_empty()); - // A streamed row's content changing: the row is marked dirty (any - // `.set()` on it does this)... let (row2, row2_bg) = rows[2]; rsc.ui.widgets.get_dyn_mut(row2).unwrap(); rsc.ui.widgets.get_dyn_mut(row2_bg).unwrap(); - // Redraw the *list* by name, so the dirty row is reached by its - // ancestor's draw rather than by `redraw_updates` happening to - // pick it first -- which is the order `HashSet` iteration makes - // arbitrary, and the reason this went unnoticed. render.redraw(list_weak.id(), &mut rsc); let orphans = render.orphaned_primitives(); @@ -2084,15 +1538,6 @@ mod tests { ); } - /// Enough rows, tall enough, that a fling toward the start has real - /// room to travel before the walls stop it -- shared by the tests - /// below. - /// - /// Built the way a real caller does: `Masked(LazySpan)`, with the span - /// driving its own `ScrollController` -- the gesture, the fling and - /// `amt` are its own, and so is the walk that says how far it can - /// actually go. The mask is outside because what needs clipping is the - /// row straddling an edge, and masks are inherited down the chain. fn build_flingable_list( rsc: &mut TestRsc, ) -> (WeakWidget, StrongWidget, UiRenderState) { @@ -2111,9 +1556,6 @@ mod tests { (list_weak, root, render) } - /// Drive one frame of a fling: tick the span the way - /// `UiData::tick_animations` does, then draw. Answers whether the - /// fling is still going. fn fling_frame( rsc: &mut TestRsc, scroll: &WeakWidget, @@ -2134,12 +1576,6 @@ mod tests { let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; - // Toward the start: **positive**, which is the finger's direction - // and `ScrollController::scroll`'s convention -- the one convention a delta - // has anywhere in the crate now. It used to be negative here, - // because a `LazySpan`'s anchor offset ran the opposite way to a - // `ScrollArea`'s `amt` while both were public and both claimed to - // mirror the other. assert!(rsc.ui.widgets.get_mut(&scroll).unwrap().fling(8000.0)); assert!(rsc.ui.widgets.get(&scroll).unwrap().is_scrolling()); @@ -2160,10 +1596,6 @@ mod tests { ); } - /// The registration half, which is the frame loop's rather than the - /// widget's: a fling that nothing registers never moves, however right - /// its velocity is -- which is exactly what a finger fling did on - /// Iris's phone for two builds. #[test] fn a_registered_fling_is_driven_by_tick_animations_and_then_unregisters() { let mut rsc = TestRsc { @@ -2195,16 +1627,10 @@ mod tests { "the fling was registered but never applied" ); - // Nothing left registered, so the next frame costs nothing. let now = start + std::time::Duration::from_millis(steps * 16); assert!(!rsc.ui.tick_animations(now)); } - /// The sign, pinned across the whole handoff: gesture -> `ScrollArea` -> - /// controller -> anchor. A negative delta is the finger moving the - /// negative way along the axis, which pulls **later** content up into - /// view. Getting this wrong anywhere in that chain scrolls the list - /// backwards, which no type can catch. #[test] fn a_negative_delta_moves_toward_the_end() { let mut rsc = TestRsc { @@ -2212,7 +1638,6 @@ mod tests { }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; - // Start well back from the end so there is room to move forward. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(2000.0); render.update(&root, &mut rsc); let before = scroll_position(rsc.ui.widgets.get(&list_weak).unwrap()); @@ -2230,19 +1655,12 @@ mod tests { ); } - /// `Scroll::amt` is the accumulated movement the child actually made, - /// so it stays equal to what is on screen even when a delta runs off - /// the end of the content. This is the whole reason the span reports - /// what it *moved* rather than the caller adding up what it asked - /// for: an all-or-nothing answer would leave `amt` over-counted by - /// every overshoot, and nothing would ever correct it. #[test] fn amt_counts_only_what_the_child_could_take() { let mut rsc = TestRsc { ui: UiData::default(), }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); - // A short move away from the end, all of which is available. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100.0); render.update(&root, &mut rsc); assert!( @@ -2251,9 +1669,6 @@ mod tests { rsc.ui.widgets.get(&scroll).unwrap().amt() ); - // 200 rows of 20px in a 600px viewport: 3400px of travel in all, - // so this asks for far more than is left and must be given only - // what there was. rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(100_000.0); render.update(&root, &mut rsc); let amt = rsc.ui.widgets.get(&scroll).unwrap().amt(); @@ -2263,11 +1678,6 @@ mod tests { ); } - /// A fling stops when the child says it could not take the delta, - /// rather than spending its remaining distance on content that is not - /// there. Before the clamp existed, a hard fling to the top of the - /// bench fixture left the first row 1398px below a 600px viewport -- - /// the whole screen blank -- and it stayed there. #[test] fn a_fling_stops_at_the_first_row() { let mut rsc = TestRsc { @@ -2275,8 +1685,6 @@ mod tests { }; let (scroll, root, mut render) = build_flingable_list(&mut rsc); let list_weak = scroll; - // An enormous velocity that would travel far past all 200 rows if - // unclamped. rsc.ui.widgets.get_mut(&scroll).unwrap().fling(50_000.0); let start = Instant::now(); for step in 0..2000 { @@ -2285,16 +1693,8 @@ mod tests { break; } } - // No settling frame: the draw that runs out of content gives the - // pixels back inside that same frame, so the last frame the loop - // drew is already flush with the top. let list_ref = rsc.ui.widgets.get(&list_weak).unwrap(); assert!(list_ref.at_start, "the fling should have reached the start"); - // Both edges, so neither an overshoot past the top nor one left - // uncorrected can pass. `extents` used to hold every row the walk - // placed, on screen or not, so this read was once satisfied by a - // first row sitting 1398px *below* the viewport with the whole - // screen blank. let first = list_ref.extents[&0]; assert!( first.lead.abs() < 0.5, @@ -2304,13 +1704,6 @@ mod tests { ); } - /// Asking for more travel than the content has leaves it *on* its - /// first row rather than beyond it, in the frame that asked. Since the - /// position moved into `ScrollArea`, this is prevented at the source -- - /// the clamp only allows what the walk says is there -- rather - /// than corrected afterwards, and `overscroll_gap` is left for the - /// case a scroll cannot cause: content or a viewport that changed - /// under a settled anchor. #[test] fn scrolling_past_the_start_lands_on_it_in_the_same_frame() { let mut rsc = TestRsc { diff --git a/src/widget/position/pad.rs b/src/widget/position/pad.rs index 7e1f39a..7cf690a 100644 --- a/src/widget/position/pad.rs +++ b/src/widget/position/pad.rs @@ -3,7 +3,6 @@ use crate::prelude::*; pub struct Pad { pub padding: Padding, pub inner: StrongWidget, - /// Cleared after the reported size fits the offered region. pub exact_region: bool, } @@ -130,10 +129,6 @@ impl Padding { } } -/// Covers both a bare number (`.pad(8)`, via `Len`'s own `From` -/// blanket -- an `abs`/physical-pixel `Len`) and a `Len` directly -/// (`.pad(dp(10))`) with the one impl, since `Len: Into` is the -/// reflexive case of the same bound. impl> From for Padding { fn from(amt: T) -> Self { Self::uniform(amt.into()) diff --git a/src/widget/position/scroll_area.rs b/src/widget/position/scroll_area.rs index 963cbec..5f294a5 100644 --- a/src/widget/position/scroll_area.rs +++ b/src/widget/position/scroll_area.rs @@ -1,13 +1,6 @@ -//! `ScrollArea`: a fixed child, slid about by a [`ScrollController`]. -//! -//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what -//! `amt` means, and how this differs from a `LazySpan`, which scrolls -//! itself. Read it first; this file is the detail. - use crate::prelude::*; use std::time::Instant; -/// A scrolling view that moves one fixed child as a subtree. pub struct ScrollArea { inner: StrongWidget, ctl: ScrollController, @@ -72,23 +65,15 @@ impl Widget for ScrollArea { } impl ScrollArea { - /// `pin` says which end this area opens at and clings to -- see - /// [`Pin`], and `WidgetLike::scrollable`, which is how one of these is - /// normally built. pub fn new(inner: StrongWidget, axis: Axis, pin: Pin) -> Self { Self { inner, - // A fixed child is laid out from the box's negative edge - // onward, always, so the end of its content is the positive - // one -- which is what makes `Pin::End` and `Pin::Pos` the - // same pin here and different ones in a reversed `LazySpan`. ctl: ScrollController::new(Dir::new(axis, Sign::Pos), pin), container_len: 0.0, content_len: None, } } - /// A content-sized box offset by the current scroll amount. fn child_region(&self, content_len: f32) -> UiRegion { let axis = self.ctl.axis(); let mut region = UiRegion::FULL; @@ -105,22 +90,10 @@ mod tests { use iris_core::UiData; use std::time::Duration; - /// A scroll area with 1000px of content in a 100px box, drawn once and - /// settled somewhere in the middle so a drag has room in both - /// directions. - /// - /// Built and rendered for real rather than assembled field by field, - /// because a delta is spent in `draw` now (the controller banks it, and - /// only a layout knows where the content ends) -- so a test that never - /// draws would watch `amt` never move and read that as a broken - /// gesture. fn area() -> (Fixture, WidgetId) { area_on(Axis::Y) } - /// The same fixture on either axis -- a code fence pans sideways - /// through one of these exactly as a field pans down, and the pair of - /// them is what caught a fling that only worked vertically. fn area_on(axis: Axis) -> (Fixture, WidgetId) { let mut rsc = TestRsc { ui: UiData::default(), @@ -149,17 +122,12 @@ mod tests { root, render, }; - // 400px in, which is the middle of the 900px of travel this - // content has. fixture.get().scroll(-400.0); fixture.draw(); assert!((fixture.amt() - 400.0).abs() < 0.01); (fixture, id) } - /// The area under test with everything needed to draw it -- the drag - /// tests all do the same three things (reach the widget, draw, read - /// `amt`) and each of the three is a line of arena plumbing. struct Fixture { rsc: TestRsc, area: WeakWidget, @@ -180,8 +148,6 @@ mod tests { self.rsc.ui.widgets.get(&self.area).unwrap().amt() } - /// One frame of a fling, the way `UiData::tick_animations` drives - /// it: tick, then draw. Answers whether it is still going. fn fling_frame(&mut self, now: Instant) -> bool { let still = self.get().tick(now); self.draw(); @@ -189,17 +155,13 @@ mod tests { } } - /// One frame of a touch gesture, followed by the draw that spends it. fn press(f: &mut Fixture, id: WidgetId, sense: CursorSense, y: f32, t: Instant) { drag(f, id, sense, Vec2::new(0.0, y), t); } - /// The same, for a gesture whose position is not on the Y axis. fn drag(f: &mut Fixture, id: WidgetId, sense: CursorSense, pos: Vec2, t: Instant) { let pointer = PointerRequests::default(); let flung = f.get().drag(&pointer, id, sense, pos, t); - // What `WidgetLike::scrollable`'s own handler does with the - // answer, and the half a fling does not move without. if flung { let id = f.area.id(); f.rsc.ui.animate(id); @@ -218,8 +180,6 @@ mod tests { 0.0, t, ); - // Finger down by well past the slop: the content follows it down, - // which for this widget means *less* `amt`. press( &mut f, id, @@ -232,7 +192,6 @@ mod tests { "expected the 30px past the slop to be applied downward, got amt={}", f.amt() ); - // ...and the next frame's motion is a plain per-frame delta. press( &mut f, id, @@ -243,9 +202,6 @@ mod tests { assert!((f.amt() - 350.0).abs() < 0.01, "amt={}", f.amt()); } - /// The half the change had no reason to touch: a press that never - /// leaves the slop is a tap, and must move nothing at all -- otherwise - /// every tap on a scrollable field nudges its text. #[test] fn a_press_that_stays_inside_the_slop_does_not_scroll() { let (mut f, id) = area(); @@ -280,8 +236,6 @@ mod tests { ); } - /// A horizontal drag is not this widget's gesture: it must stay put - /// rather than pick up the vertical noise in a sideways swipe. #[test] fn a_horizontal_drag_does_not_scroll() { let (mut f, id) = area(); @@ -303,9 +257,6 @@ mod tests { assert!((f.amt() - 400.0).abs() < 0.01, "amt={}", f.amt()); } - /// Panning stops at the ends of the content rather than running off, - /// which is `update_amt`'s clamp -- checked through `drag` so the two - /// cannot drift apart. #[test] fn a_pan_past_the_end_clamps_instead_of_running_off() { let (mut f, id) = area(); @@ -327,10 +278,6 @@ mod tests { assert!((f.amt() - 0.0).abs() < 0.01, "amt={}", f.amt()); } - /// Iris, 2026-09-08: "Flinging doesn't work in horizontal scroll - /// areas. Flinging should be enabled by default in all scroll areas - /// on android to match composes behavior." A release with real - /// velocity coasts, decelerating, and settles on its own. #[test] fn a_released_pan_flings_and_settles() { for axis in [Axis::X, Axis::Y] { @@ -345,9 +292,6 @@ mod tests { at(0.0), t, ); - // Four samples 8ms apart, accelerating away from the start -- - // three is the fewest `VelocityTracker`'s quadratic fit can - // use, so this is a gesture that genuinely has a velocity. for (i, d) in [-40.0, -100.0, -180.0, -280.0].into_iter().enumerate() { drag( &mut f, @@ -370,9 +314,6 @@ mod tests { "{axis:?}: a released pan with velocity must fling" ); - // Frames at 8ms until it stops, with each step no longer than - // the one before it -- a coast that does not decelerate is - // the linear-spline bug this crate has had once already. let mut last_step = f32::INFINITY; let mut ticks = 0; let mut now = t + Duration::from_millis(32); @@ -397,15 +338,8 @@ mod tests { } } - /// The wall: a fling must not spend its remaining distance on content - /// that is not there. Released hard toward the start, it settles - /// exactly on it. #[test] fn a_fling_stops_at_the_end_of_the_content() { - // Both walls. A positive delta is applied as `amt -= delta`, so a - // positive velocity runs toward the start of the content and a - // negative one toward its end; 1000px of content in a 100px box - // leaves `amt` in 0..=900. for (velocity, wall) in [(50_000.0f32, 0.0f32), (-50_000.0, 900.0)] { let (mut f, _id) = area(); f.get().fling(velocity); @@ -429,10 +363,6 @@ mod tests { } } - /// A finger on coasting content stops it there, from the first - /// sample, with no `DRAG_SLOP` to wait out -- the catch - /// `DragArbiter::press_start` describes, which a scroll area needs - /// for the same reason a list does now that it can coast at all. #[test] fn a_press_on_a_coasting_area_catches_it() { let (mut f, id) = area(); @@ -457,8 +387,6 @@ mod tests { "the down itself must not move the content, only stop it" ); - // A move well under `DRAG_SLOP` still tracks the finger, because - // this press caught something that was moving. drag( &mut f, id, diff --git a/src/widget/position/scrollable.rs b/src/widget/position/scrollable.rs index b1021e1..d7331c3 100644 --- a/src/widget/position/scrollable.rs +++ b/src/widget/position/scrollable.rs @@ -1,34 +1,3 @@ -//! The scrolling capability: one `ScrollController` holding everything a -//! scroll position is made of, and a `Scrollable` trait for the widgets -//! that own one. -//! -//! **`docs/SCROLL.md` is the overview** -- the one sign convention, what -//! `amt` means, and which widgets scroll. Read it first; this file is the -//! detail. -//! -//! Two widgets scroll in iris and they scroll differently: a -//! [`ScrollArea`](super::ScrollArea) slides a fixed child about as a lump, -//! and a [`LazySpan`](super::LazySpan) lays its own rows out from an -//! anchor and cannot be slid at all. What they share is everything that is -//! *not* the layout -- the gesture, the fling, the pin, the position and -//! the account of how far it can still go -- so that lives here, in a -//! plain struct each of them contains, rather than in a protocol between -//! them (Iris, 2026-09-08: "what about adding a scroll controller that -//! both scroll and lazy span contain"). -//! -//! The contract with the owner is two calls, both in its `draw`: -//! -//! 1. [`ScrollController::take_delta`] -- what a wheel, a drag or a fling -//! asked for since the last layout, already clamped to the travel the -//! owner last reported. -//! 2. [`ScrollController::set_travel`], plus whichever of -//! [`ScrollController::moved_by`] or [`ScrollController::set_amt`] fits -//! how that owner knows where it ended up -- movement for a layout with -//! no fixed origin, an absolute position for one that has. -//! -//! Everything between the two is the owner's own layout, and everything -//! outside them is the same for both. - use crate::prelude::*; use crate::sense::{DragGesture, Flinger, GestureOutcome, PointerRequests, PressState}; use std::time::Instant; @@ -38,43 +7,15 @@ use std::time::Instant; /// this used to be, because the flag sat at the end of two constructors /// and `scrollable(axis, true)` says nothing at the call site about which /// end `true` is. -/// -/// **Two pairs, because there are two questions and they are not the same -/// one** (Iris, 2026-09-08: "that way you can select the pin based on the -/// axis's sign rather than the direction, so for example you can assure -/// it's always pinned to the bottom"): -/// -/// - [`Pin::Start`] / [`Pin::End`] are **content-relative**: the first row -/// or the newest one, wherever the layout happens to put it. A -/// transcript wants `End` -- the newest message -- and does not care -/// which edge of the screen that is. -/// - [`Pin::Neg`] / [`Pin::Pos`] are **axis-absolute**: the top or left -/// edge, and the bottom or right one, whichever end of the content sits -/// there. What to reach for when the *screen* position is the -/// requirement. -/// -/// The two coincide for content laid out along the positive axis, which is -/// everything except a reversed `LazySpan` (`Dir::UP`, `Dir::LEFT`) -- -/// where they are exact opposites, which is the whole reason both exist. #[derive(Clone, Copy, Eq, PartialEq, Debug)] pub enum Pin { - /// The start of the content: item 0, wherever it is drawn. Start, - /// The end of the content: the newest item, wherever it is drawn. End, - /// The top or left edge of the box, whichever end of the content is - /// there. Neg, - /// The bottom or right edge of the box, whichever end of the content - /// is there. Pos, } impl Pin { - /// Resolve to the one question a scrollable actually acts on: does - /// content appended to the end bring the view with it? `dir` is the - /// way this owner's content runs, which is the only thing that tells - /// the axis-absolute pair from the content-relative one. fn pinned_to_end(self, dir: Dir) -> bool { match self { Pin::Start => false, @@ -85,9 +26,6 @@ impl Pin { } } -/// How far a scrollable can still travel from where it is, as of its last -/// layout, in the same screen-space units a delta is in. -/// /// `f32::INFINITY` where the end is not in sight: a lazy layout genuinely /// does not know how much content lies past the rows it has walked, and /// saying "infinity" is the honest answer that `clamp` also happens to @@ -95,8 +33,6 @@ impl Pin { /// the owner reports what it *did* as well as what it can do. #[derive(Clone, Copy, Debug)] pub struct Travel { - /// The bound on a **positive** delta -- scrolling up or left, back - /// toward the start of the content. pub back: f32, /// The bound on a **negative** delta -- scrolling down or right, /// onward toward the end of the content. Positive itself: it is a @@ -105,67 +41,28 @@ pub struct Travel { } impl Travel { - /// Nothing known yet, so nothing is bounded -- what a scrollable - /// starts with and what it reports for an axis whose content it has - /// not measured. pub const UNBOUNDED: Self = Self { back: f32::INFINITY, fwd: f32::INFINITY, }; - /// The bound on a delta of this sign, as a positive distance. fn toward(&self, delta: f32) -> f32 { if delta >= 0.0 { self.back } else { self.fwd } } } -/// The state a scroll position is made of, owned by the widget that -/// scrolls: where it is, what it was asked to do next, how far it can go, -/// which end it clings to, and the gesture and fling that drive it. -/// -/// See the module doc for the two-call contract with its owner, and -/// [`Scrollable`] for the trait that reaches one. pub struct ScrollController { - /// Which way this area's content runs: the axis it pans along, and the - /// sign the content grows in. A plain `ScrollArea` always grows the - /// positive way; a `LazySpan` passes its own `dir`, which is what - /// tells [`Pin::Pos`]/[`Pin::Neg`] from [`Pin::Start`]/[`Pin::End`]. dir: Dir, - /// Where this area has got to, counting **forward through the - /// content**: 0 at the start, growing as the reader moves on. The - /// opposite sign to a delta, which counts the way the finger moves. - /// /// For a `ScrollArea` it is a position, clamped into the content's /// real length. For a `LazySpan` it is **movement, not position** -- /// paging rows in above moves the origin and the span cannot say by /// how much, never having measured them -- so the direction is /// comparable between the two and the absolute value is not. amt: f32, - /// Asked for but not yet laid out: how far a wheel, a drag or a fling - /// has moved this area since the last draw. Taken and cleared by - /// [`Self::take_delta`], which is the only place it is spent, because - /// the owner's `draw` is the only place the walls are known. pending: f32, - /// What the owner's last layout said was left, and what `take_delta` - /// clamps against. travel: Travel, - /// Whether this area is currently flush against the end of its - /// content, so that content appended to it should bring the view - /// along. Set from [`Pin`] at construction and recomputed by the owner - /// at the end of every layout -- it is live state, not a preference: a - /// reader who scrolls away from the end stops being pinned to it, and - /// scrolling back re-pins. pinned_to_end: bool, - /// Touch panning. Arbitration, `DRAG_SLOP` and pointer capture all - /// live in `sense.rs`; only what a committed pan *means* is decided - /// here. See [`Self::drag`]. gesture: DragGesture, - /// The momentum a release leaves behind. Every scroll area flings, on - /// either axis and with nothing to opt into -- Compose's `scrollable` - /// attaches `ScrollableDefaults.flingBehavior()` on every axis it is - /// given, and Iris asked for the same (2026-09-08: "flinging should be - /// enabled by default in all scroll areas on android to match composes - /// behavior"). fling: Flinger, /// Physical pixels per dp, copied from the painter on every draw -- /// what a fling's deceleration is computed against. 1.0 until the @@ -188,32 +85,18 @@ impl ScrollController { } } - /// Which way this area pans. pub fn axis(&self) -> Axis { self.dir.axis } - /// Which way this area's content runs -- the axis it pans along and - /// the sign it grows in. What resolves a [`Pin`]. pub fn dir(&self) -> Dir { self.dir } - /// How far the content has been pulled past the container's leading - /// edge -- see the field for what that means for each kind of owner. pub fn amt(&self) -> f32 { self.amt } - /// Pan by `amt`, in the finger's direction: **positive scrolls up or - /// left**, moving the content the positive way along the axis. One - /// convention, everywhere, and a screen direction rather than a - /// logical one so that it means the same thing to a widget laid out - /// backwards (Iris, 2026-09-08). - /// - /// Banked rather than applied: where this area can actually go is a - /// question only its owner's layout can answer, and the owner's `draw` - /// is where that answer exists. pub fn scroll(&mut self, amt: f32) { self.pending += amt; } @@ -221,13 +104,6 @@ impl ScrollController { /// What has been asked for since the last layout, clamped to the /// travel that layout reported. Called once at the top of the owner's /// `draw`. - /// - /// **Clipping it stops a fling**, because a fling that keeps spending - /// its distance on content that is not there is what left a hard flick - /// parked a whole screen past the first row of the bench fixture - /// (docs/IRIS_TODO.md, 2026-09-07). This catches the wall the owner - /// could already see; [`Self::set_travel`] catches the one it finds by - /// walking. pub fn take_delta(&mut self) -> f32 { let asked = std::mem::take(&mut self.pending); let limit = self.travel.toward(asked); @@ -238,10 +114,6 @@ impl ScrollController { taken } - /// Record content this area really moved, and by how much, in a - /// delta's own sign. For an owner that cannot state an absolute - /// position -- a lazy layout, whose origin moves as rows are paged in - /// above it. pub fn moved_by(&mut self, delta: f32) { self.amt -= delta; } @@ -253,10 +125,6 @@ impl ScrollController { self.amt = amt; } - /// Publish how far this area can still go, from the layout that just - /// ran. Stops a fling with nothing left in the direction it is - /// travelling -- the wall a lazy layout only finds by walking to it, - /// reported in the same frame that found it. pub fn set_travel(&mut self, travel: Travel) { self.travel = travel; if let Some(v) = self.fling.velocity() @@ -266,8 +134,6 @@ impl ScrollController { } } - /// What the last layout said was left. Read by an owner that has to - /// reconcile its own walls with what it was allowed to take. pub fn travel(&self) -> Travel { self.travel } @@ -296,21 +162,6 @@ impl ScrollController { /// convention. Answers whether one actually started, which is the /// caller's cue to register the widget for frames (`UiData::animate`). /// Cancels any fling already in progress. - /// - /// **Sets the fling; it does not drive it.** A fling moves only while - /// something calls [`Self::tick`] once per frame, and what does that - /// in a running app is `UiData::tick_animations`, over the ids - /// `UiData::animate` was given. Split that way because the two halves - /// have different owners: the velocity is this area's business and - /// whether anything animates at all is the frame loop's. Missing the - /// second call is what a finger fling did on Iris's phone for two - /// builds -- the velocity was right and nothing ever advanced it, - /// which looks exactly like a list that stops dead under the finger. - /// - /// The density handed on is this area's own, taken from the painter, - /// not `1.0`: it does **not** cancel out of the spline, and a - /// hardcoded 1.0 against a 2.75-density screen made a flick that - /// should coast for about a second run for 45. pub fn fling(&mut self, velocity: f32) -> bool { self.fling.start(velocity, self.density) } @@ -338,14 +189,6 @@ impl ScrollController { self.fling.velocity() } - /// Advance a fling by one frame, banking the distance it covered. - /// Answers whether it is still going, which is what - /// `UiData::tick_animations` reads to decide whether to keep the - /// widget registered -- so an owner's `Widget::tick` is this one line. - /// - /// Stopping at a wall is [`Self::take_delta`]'s and - /// [`Self::set_travel`]'s, not this method's: both know where the - /// content ends and this one does not. pub fn tick(&mut self, now: Instant) -> bool { let delta = self.fling.tick(now); self.scroll(delta); @@ -378,15 +221,6 @@ impl ScrollController { pos_window: Vec2, now: Instant, ) -> bool { - // A scroll area has no selection of its own to extend, so a drag - // across the axis stays `Undecided` and one along it past the slop - // pans, which is the whole contract here. - // - // `scrolling` is the other half: a finger put down on content that - // is still coasting means "stop it here", and commits to a pan on - // that very sample with no slop to wait out - // (`DragArbiter::press_start`). The fling is cancelled in the same - // breath, since the curve has no idea a finger came back down. let mut press = PressState::default(); if self.gesture.starts_press(sense) { press.scrolling = self.fling.is_flinging(); @@ -400,8 +234,6 @@ impl ScrollController { // arbiter of the caller's own (`Selection::drag`) hands // straight to `scroll`. GestureOutcome::Pan(dy) => self.scroll(dy), - // Same sign as `Pan`, since `tick` applies it through the same - // `scroll`. GestureOutcome::Released(Some(v)) => return self.fling(v), GestureOutcome::Undecided | GestureOutcome::Tapped @@ -427,14 +259,10 @@ pub trait Scrollable { fn controller(&self) -> &ScrollController; fn controller_mut(&mut self) -> &mut ScrollController; - /// Pan by `amt` -- positive scrolls up or left. See - /// [`ScrollController::scroll`]. fn scroll(&mut self, amt: f32) { self.controller_mut().scroll(amt); } - /// See [`ScrollController::fling`], including why starting one is not - /// the same as driving it. fn fling(&mut self, velocity: f32) -> bool { self.controller_mut().fling(velocity) } @@ -443,7 +271,6 @@ pub trait Scrollable { self.controller_mut().cancel_fling(); } - /// See [`ScrollController::drag`]. fn drag( &mut self, pointer: &PointerRequests, @@ -456,8 +283,6 @@ pub trait Scrollable { .drag(pointer, id, sense, pos_window, now) } - /// See [`ScrollController::amt`] for what this counts, which differs - /// between the two implementors in origin though not in direction. fn amt(&self) -> f32 { self.controller().amt() } @@ -482,20 +307,11 @@ pub trait Scrollable { self.controller_mut().set_pinned_to_end(pinned); } - /// Advance a fling by one frame -- an implementor's `Widget::tick` is - /// this, and nothing else animates in a scroll area. fn tick_fling(&mut self, now: Instant) -> bool { self.controller_mut().tick(now) } } -/// Register the two inputs of a scroll -- the wheel and a finger drag -- -/// on a widget that owns a [`ScrollController`], and hand back the id. -/// -/// The one place either is wired, shared by `WidgetLike::scrollable` and -/// `LazySpan::scrollable`: what differs between those two is only whether -/// there is a `ScrollArea` in the way, and a drag registered twice is a -/// gesture arbitrated twice. pub fn scroll_senses(w: WL, axis: Axis) -> impl WidgetIdFn where Rsc: HasEvents, @@ -512,11 +328,6 @@ where let flung = ctx .widget(rsc) .drag(ctx.data.pointer, id, sense, pos, ctx.data.cursor.time); - // The half that actually makes it move -- a fling is set by the - // widget and driven by the frame loop, and only this side can - // reach the loop. Only when one actually started: registering a - // widget that is not animating asks the next frame to find that - // out. if flung { rsc.ui_mut().animate(id); } diff --git a/src/widget/text/build.rs b/src/widget/text/build.rs index 7b4c696..fb826ae 100644 --- a/src/widget/text/build.rs +++ b/src/widget/text/build.rs @@ -40,10 +40,6 @@ impl> TextBuilder { self.attrs.wrap = wrap; self } - /// Per-range style overrides -- I5's inline rich text (bold, italic, - /// inline-code monospace, link colour/underline) within one wrapped - /// paragraph. See `SpanStyle`'s doc for why this exists and what it - /// replaces. pub fn spans(mut self, spans: Vec) -> Self { self.spans = spans; self diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 6ca9709..999cd64 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -8,9 +8,6 @@ use winit::{ keyboard::{Key, NamedKey}, }; -/// Which way a cursor movement goes. Named here rather than taken from the text -/// stack so that the key handling below does not have to change when the stack -/// does; the mapping onto parley lives in one place, in `apply_motion`. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Motion { Left, @@ -25,21 +22,10 @@ pub enum Motion { pub struct TextEdit { view: TextView, - /// `None` when the field is not focused -- which parley's `Selection` has no - /// way to say, since it always denotes some position in the text. A - /// collapsed selection is a caret; an uncollapsed one is a span. selection: Option, #[cfg_attr(target_os = "android", allow(dead_code))] history: Vec<(String, Option)>, double_hit: Option, - /// Where an in-flight press over this field began, while it is still - /// undecided whether the gesture is a tap (focus/show the IME) or a - /// drag (attr.rs's `Selector`/`Selectable`, Iris 2026-09-06: a swipe - /// over the composer must not summon the keyboard). `None` both before - /// any press and once the gesture has been decided either way -- - /// `attr.rs` is the only reader/writer, kept `pub(crate)` rather than - /// behind an accessor since it is pure bookkeeping with no invariant - /// beyond "some press is undecided," same shape as `double_hit` above. pub(crate) press_origin: Option, pub mode: EditMode, } @@ -105,8 +91,6 @@ impl Widget for TextEdit { }; let layout = self.view.buf.layout(); - // parley reports selection as boxes in layout space, so bidi and - // wrapped lines come out right without this code knowing about either. for (rect, _) in selection.geometry(layout) { let size = vec2(rect.width() as f32, rect.height() as f32); let top_left = vec2(rect.x0 as f32, rect.y0 as f32); @@ -130,8 +114,6 @@ impl Widget for TextEdit { true } - /// I4 (RUST.md): the one override that exists so far -- everything - /// else falls back to `Widget::access_role`'s default `Unknown`. fn access_role(&self) -> accesskit::Role { match self.mode { EditMode::SingleLine => accesskit::Role::TextInput, @@ -148,11 +130,6 @@ pub struct TextEditCtx<'a> { } impl<'a> TextEditCtx<'a> { - /// The layout, brought up to date with the text first. - /// - /// Every cursor movement and hit test goes through parley's layout, so an - /// edit that left it stale would move the caret against the previous text. - /// Shaping is skipped when nothing changed, so calling this freely is fine. fn layout(&mut self) -> &Layout { let attrs = self.text.view.attrs.clone(); let width = self.text.view.wrap_width(); @@ -161,7 +138,6 @@ impl<'a> TextEditCtx<'a> { self.text.view.buf.layout() } - /// Keep the selection valid after the text underneath it changed. #[cfg_attr(target_os = "android", allow(dead_code))] fn refresh(&mut self) { if let Some(sel) = self.text.selection { @@ -183,13 +159,6 @@ impl<'a> TextEditCtx<'a> { self.text.selection = None; } - /// [`set`](Self::set) plus a fresh set of [`SpanStyle`]s in one call -- - /// what a streamed transcript row needs, since its markdown re-renders - /// to a new string *and* a new span list on every delta and the two - /// have to land together (a stale span list drawn against new text can - /// point past its end). Used by `transcript-ui`'s incremental apply - /// (RUST.md's "streaming still costs a full rebuild" fix) rather than - /// tearing the row's widget down and rebuilding it from scratch. pub fn set_with_spans(&mut self, text: &str, spans: Vec) { let text = self.string(text); self.text.view.buf.set_text(text); @@ -202,9 +171,6 @@ impl<'a> TextEditCtx<'a> { return; }; let layout = self.layout(); - // Collapsing a span with an unshifted left/right puts the caret at the - // near end rather than moving one character from the focus, which is - // what every other editor does. let sel = if !select && !sel.is_collapsed() { match motion { Motion::Left | Motion::LeftWord => { @@ -221,8 +187,6 @@ impl<'a> TextEditCtx<'a> { self.text.selection = Some(sel); } - /// Replace the `len` characters before the caret. This is the IME's - /// preedit path: it re-sends the whole composition each time. pub fn replace(&mut self, len: usize, text: &str) { let text = self.string(text); for _ in 0..len { @@ -273,7 +237,6 @@ impl<'a> TextEditCtx<'a> { self.set_caret(at + text.len()); } - /// True when there was a span to remove. pub fn clear_span(&mut self) -> bool { let Some(sel) = self.text.selection else { return false; @@ -408,27 +371,6 @@ impl<'a> TextEditCtx<'a> { let prev_sel = self.text.selection; let prev_hit = self.text.double_hit; - // The layout borrows `self`, so the whole decision is made in here and - // only the answer escapes. - // - // **A press that reaches here has already been hit-tested to this - // widget, so there is no "outside" to clear the selection for.** - // This used to compare `pos` against the *laid-out text's* box and - // set `selection = None` for anything beyond it -- but the laid-out - // text is smaller than the field (padding, and for an empty field a - // box of literally zero width), so tapping an **empty** composer - // granted focus, opened the keyboard, and left `selection` at - // `None` -- and `insert_str` returns early on `None`, so every - // keystroke after that was silently dropped and nothing ever - // appeared. That is RUST.md's P0 box item 2, "composed text never - // becomes visible at all": the buffer was empty the whole time, and - // Gboard's suggestion strip (its own composing state, not ours) is - // what made it look otherwise. Parley's `from_point`/ - // `extend_to_point` already clamp a point outside the layout to the - // nearest cursor position, which is what a tap in a field's padding - // should do anyway. Losing focus is a separate path - // (`TextEditCtx::deselect`, called from the backend's focus - // handling), not this one. let outcome = { let layout = self.layout(); if drag { @@ -436,9 +378,6 @@ impl<'a> TextEditCtx<'a> { } else { let hit = Selection::from_point(layout, pos.x, pos.y); let index = hit.focus().index(); - // A second click in the same place takes the word and a third - // the line; `double_hit` is what remembers that the previous - // click had already grown to a word. Some(if recent && prev_hit == Some(index) { (Some(Selection::line_from_point(layout, pos.x, pos.y)), None) } else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) { @@ -572,8 +511,6 @@ fn apply_motion( } } -/// The ends of a byte range as cursors, so collapsing a selection can put the -/// caret at whichever end the movement asked for. trait RangeCursors { fn start_cursor(&self, layout: &Layout) -> parley::Cursor; fn end_cursor(&self, layout: &Layout) -> parley::Cursor; @@ -649,10 +586,6 @@ mod tests { use super::*; use iris_core::{TextAttrs, TextBuffer}; - /// The editor is the one part of iris that is pure logic over a string and - /// a layout, and it was rewritten wholesale when the text stack changed -- - /// so it is the one part worth testing directly. Everything else here - /// needs a GPU and a window. fn edit(text: &str, mode: EditMode) -> (TextEdit, TextData) { let view = TextView::new(TextBuffer::new(text), TextAttrs::default(), None); (TextEdit::new(view, mode), TextData::default()) @@ -725,10 +658,6 @@ mod tests { assert_eq!(t.selection.unwrap().focus().index(), 0); } - /// The defect itself: an empty field's laid-out text is a zero-sized - /// box, so a tap anywhere in it used to land "outside" and clear the - /// selection -- leaving a focused composer that silently swallowed - /// every keystroke (RUST.md's P0 box item 2). #[test] fn tapping_an_empty_field_places_a_caret_so_typing_lands() { let (mut t, mut d) = edit("", EditMode::MultiLine); @@ -738,10 +667,6 @@ mod tests { assert_eq!(content(&t), "hi"); } - /// The half the fix had no reason to touch: a field that *does* hold - /// text, tapped past the end of it (a multi-line composer's padding - /// below the last line) keeps a caret rather than losing the one it - /// had, and the caret lands at the nearest position -- the end. #[test] fn tapping_past_the_end_of_the_text_clamps_to_the_end() { let (mut t, mut d) = edit("abc", EditMode::MultiLine); @@ -749,8 +674,6 @@ mod tests { assert_eq!(t.selection.unwrap().focus().index(), 3); } - /// A drag still needs something to extend: with no previous selection - /// there is nothing to drag from, and one must not be invented. #[test] fn dragging_without_a_previous_selection_selects_nothing() { let (mut t, mut d) = edit("abc", EditMode::MultiLine); @@ -783,8 +706,6 @@ mod tests { assert_eq!(content(&t), ""); } - /// The IME's preedit path: each keystroke resends the whole composition, - /// so `replace` has to remove exactly what it added last time. #[test] fn ime_preedit_replaces_its_own_previous_text() { let (mut t, mut d) = edit("", EditMode::SingleLine); @@ -797,10 +718,6 @@ mod tests { assert_eq!(content(&t), "に"); } - /// `android/ime.rs`'s `set_composing_text` calls `replace` and expects - /// the caret to land right after the inserted text, growing with it on - /// every re-send -- the buffer-level half of RUST.md's P0 box ("doesn't - /// enter it until I hit space, and also doesn't move cursor forward"). #[test] fn composing_advances_the_caret_with_the_growing_text() { let (mut t, mut d) = edit("", EditMode::SingleLine); @@ -815,30 +732,17 @@ mod tests { assert_eq!(t.caret(), Some(3)); } - /// The IME's `commitText` (`android_view::InputConnection::commit_text`'s - /// default body): finish a composition in place, same as a real word - /// boundary (a space) landing after Gboard's composing span. #[test] fn committing_composed_text_leaves_it_in_place_with_the_caret_after_it() { let (mut t, mut d) = edit("say ", EditMode::SingleLine); ctx(&mut t, &mut d).set_caret(4); ctx(&mut t, &mut d).replace(0, "hi"); assert_eq!(content(&t), "say hi"); - // `finish_composing_text`/`commit_text` do not themselves touch the - // buffer -- only the IME's own `compose_len` bookkeeping resets, in - // `android/ime.rs`. Confirms the buffer already holds committed - // text as plain, uncomposed content: a further `replace(0, " ")` - // (the space that ends the word) appends rather than overwriting. ctx(&mut t, &mut d).replace(0, " "); assert_eq!(content(&t), "say hi "); assert_eq!(t.caret(), Some(7)); } - /// `TextEditCtx::delete_byte_range` is `deleteSurroundingText`'s entry - /// point once `android/ime.rs` has converted UTF-16 code units to - /// bytes -- exercised directly here in bytes, since the UTF-16 math - /// itself is `android/ime.rs`'s own `byte_to_utf16`/`utf16_to_byte`, - /// outside this widget-only test module. #[test] fn delete_byte_range_removes_exactly_that_range() { let (mut t, mut d) = edit("hello world", EditMode::SingleLine); @@ -847,8 +751,6 @@ mod tests { assert_eq!(t.caret(), Some(5)); } - /// `set_cursor_byte` is `setSelection`'s entry point -- collapses to a - /// caret at the given byte offset regardless of any span that was there. #[test] fn set_cursor_byte_collapses_to_a_caret_there() { let (mut t, mut d) = edit("hello world", EditMode::SingleLine); @@ -868,8 +770,6 @@ mod tests { assert_eq!(t.selected_text().as_deref(), Some("b")); } - /// Collapsing a span with an unshifted arrow goes to the near end rather - /// than stepping one character from the focus. #[test] fn an_unshifted_arrow_collapses_a_span_to_its_edge() { let (mut t, mut d) = edit("abcdef", EditMode::SingleLine); @@ -882,8 +782,6 @@ mod tests { assert_eq!(t.selection.unwrap().focus().index(), 6); } - /// Byte offsets, not character counts: a caret placed after a multi-byte - /// character must not split it. #[test] fn multibyte_text_is_edited_by_byte_offset() { let (mut t, mut d) = edit("aé", EditMode::SingleLine); diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 685c95c..5e85524 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -16,7 +16,6 @@ pub struct Text { pub struct TextView { pub attrs: MutDetect, pub buf: MutDetect, - // cache tex: Option, width: Option, pub hint: Option, @@ -27,14 +26,10 @@ impl TextView { self.buf.is_empty() } - /// The width the text was last laid out against, so an editor asking for - /// the layout gets the same wrapping the last draw used. pub fn wrap_width(&self) -> Option { self.width } -} -impl TextView { pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option) -> Self { Self { attrs: attrs.into(), @@ -45,8 +40,6 @@ impl TextView { } } - /// region where the text should be draw - /// does not include extra height or width from weird unicode pub fn region(&self) -> UiRegion { self.tex() .map(|t| t.size) @@ -60,13 +53,6 @@ impl TextView { } else { None }; - // The atlas generation is part of the cache key, not a separate - // invalidation path: a `RenderedText` is only meaningful against the - // atlas its glyphs were placed in, and a renderer rebuild clears - // that atlas out from under every widget at once - // (`GlyphAtlas::clear`). Without this the text drawn before the - // rebuild is re-emitted with the old atlas's coordinates and comes - // back as fragments of whatever now occupies them. let generation = painter.atlas_generation(); if width == self.width && let Some(tex) = &self.tex @@ -78,11 +64,6 @@ impl TextView { } self.width = width; let tex = painter.render_text(&mut self.buf, &self.attrs, width); - // Gated on `iris::diagnostics::trace_enabled` since 2026-09-07 - // (docs/RUST.md's review, D1): one line per text *shape* (a cache - // miss), unconditional, is many per frame while rows compose -- - // see `android::view::IrisViewPeer::render`'s own doc for the same - // finding on its two per-frame lines. if crate::diagnostics::trace_enabled() { log::debug!( target: "iris::frame", @@ -100,12 +81,6 @@ impl TextView { pub fn tex(&self) -> Option<&RenderedText> { self.tex.as_ref() } - /// Draws within `painter.region()` and reports the size used -- what - /// `desired_width`/`desired_height` used to answer separately, folded - /// into the one draw (LAYOUT.md section 4): the shaped layout this - /// reads is already memoized by width in `render`, so a second call at - /// the same width (a redraw with nothing else changed) is a cache hit, - /// not a re-shape. pub fn draw(&mut self, painter: &mut Painter) -> Size { let tex = self.render(painter); if self.is_blank() @@ -185,19 +160,6 @@ mod tests { use crate::layout_tests::TestRsc; use crate::prelude::*; - /// A renderer rebuild empties the glyph atlas under every widget at - /// once (`iris_core::GlyphAtlas::clear`, called from - /// `IrisViewPeer::surface_changed`'s new-renderer branch). Anything - /// still holding a `RenderedText` from before then owns UV rectangles - /// into a texture that no longer exists -- what Iris photographed on - /// 2026-09-06 as every pre-resume glyph coming back as fragments while - /// the text drawn after the resume was perfect. - /// - /// The check is the atlas repopulating: `TextView::render`'s cache - /// short-circuits before `TextData::place`, so without the generation - /// in its key the second frame rasterises nothing and the atlas stays - /// empty. (`Painter::glyphs`'s `debug_assert!` fires here too, which is - /// the same finding from the submission side.) #[test] fn clearing_the_atlas_re_renders_cached_text_instead_of_reusing_it() { let mut rsc = TestRsc { @@ -215,9 +177,6 @@ mod tests { let rasterised = rsc.ui.text.atlas.glyph_count(); assert!(rasterised > 0, "the first frame rasterised no glyphs"); - // Exactly what the new-renderer branch does, in order: empty the - // atlas, then redraw everything (`resize` is what marks the tree - // for a full redraw, and a real `surface_changed` always calls it). rsc.ui.text.atlas.clear(); assert_eq!(rsc.ui.text.atlas.glyph_count(), 0); render.resize((800.0, 600.0)); diff --git a/src/widget/trait_fns.rs b/src/widget/trait_fns.rs index 9bc30fa..f4be048 100644 --- a/src/widget/trait_fns.rs +++ b/src/widget/trait_fns.rs @@ -84,25 +84,6 @@ widget_trait! { } } - /// Wrap this widget in a [`ScrollArea`] that pans along `axis`, with - /// the wheel and a finger drag both registered -- how anything with a - /// fixed layout becomes scrollable. - /// - /// `pin` says which end the area opens at and clings to as its content - /// grows, and it is spelled out rather than defaulted because the two - /// cases are not variations on each other: a composer wants the end, - /// where what is being typed is, and a code fence opened at the end of - /// its longest line, which is the middle of a word (seen in - /// `iris/run-headless.sh phone`, 2026-09-08). - /// - /// One method with the axis and the pin passed in, rather than the - /// three named variants this used to be (Iris, 2026-09-08: "can we - /// make both scroll methods become `.scrollable`, and it takes an axis - /// and a pin instead of having two?"). A code fence pans across its - /// own long lines exactly the way a transcript pans down its rows, so - /// the two are one mechanism with the direction passed in -- - /// `DragArbiter::on` is the other half. - /// /// A [`LazySpan`] has an inherent `scrollable` of its own that this /// does not reach: it owns a controller already and must not be /// wrapped in an area that would slide it about as a lump. @@ -120,12 +101,6 @@ widget_trait! { } } - /// Clip to `shape` rather than to a plain box: `shape` is drawn - /// behind this widget, filling the same region, and what clips is the - /// primitive it drew -- so a rounded background and the corner its - /// content is cut to are one rect, with no radius passed twice. - /// Replaces `.masked().background(w)`, which drew the two but clipped - /// to the box. fn masked_by(self, shape: impl WidgetLike) -> impl WidgetFn { move |state| Masked { shape: Some(shape.add_strong(state)), diff --git a/tabs-ui/Cargo.toml b/tabs-ui/Cargo.toml index 21805cb..a032c7b 100644 --- a/tabs-ui/Cargo.toml +++ b/tabs-ui/Cargo.toml @@ -3,11 +3,7 @@ name = "tabs-ui" version.workspace = true edition.workspace = true -# The tabs example's widget tree, factored out of iris/examples/tabs/main.rs -# so it can be built once and driven by either backend: the winit example -# binary, and iris/android-app's cdylib. Its own crate rather than a pub -# module of `iris` because it is demo content, not library surface -- see -# RUST.md's I2. +# Shared demo content for the desktop and Android example entry points. [dependencies] iris = { path = ".." } diff --git a/tabs-ui/src/lib.rs b/tabs-ui/src/lib.rs index f678ec4..a5a2f2a 100644 --- a/tabs-ui/src/lib.rs +++ b/tabs-ui/src/lib.rs @@ -1,13 +1,3 @@ -//! The tabs example's widget tree -- the five demo panes plus the message -//! composer that exercises `TextEdit`. Factored out of -//! `iris/examples/tabs/main.rs` (I2, RUST.md) so the same UI runs under -//! both backends: the winit example binary calls `build` from -//! `DefaultAppState::new`, and `iris-android-app`'s cdylib calls it from -//! `AndroidAppState::new`. Nothing here mentions either backend by name -- -//! it only needs `Rsc: HasEvents` (for `.on(...)`) and `Rsc::State: -//! FocusHost` (for `.attr::(())`), both of which every backend -//! implements. - use iris::prelude::*; use std::{cell::RefCell, rc::Rc}; @@ -181,12 +171,6 @@ where .on(CursorSense::HoverEnd, move |ctx, rsc| { ctx.widget(rsc).color = color; }) - // I4 (RUST.md): the tabs screen's only named controls, and the - // ones the emulator step at the bottom of that box taps by - // name -- `ui-trace record --do "tap 'pad'"` and so on. `.label` - // slots into this chain like any other widget combinator - // (`RefFnTag` in `core/src/widget/tag.rs`); it does not have to - // be the last thing before `.add`. .label(label); (rect, wtext(label).size(30).text_align(Align::CENTER)).stack() }; diff --git a/tests/mask_sdf.rs b/tests/mask_sdf.rs index 9c8d615..96002a5 100644 --- a/tests/mask_sdf.rs +++ b/tests/mask_sdf.rs @@ -1,41 +1,3 @@ -//! The CPU rounded-rect SDF and the shader's own must agree. -//! -//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips -//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the -//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with -//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped -//! and a corner that is not drawn are the same corner only while the two -//! functions answer the same. Nothing else checks that: both sides are -//! individually plausible and drift shows up as a control that is a pixel -//! or two off, which is exactly what nobody notices. -//! -//! So this runs **the real shader text**, lifted out of -//! `iris_core::SHAPE_SHADER` by name rather than copied here, over a grid -//! of points, and compares what came back with the Rust function at the -//! same points. This is the only test in the workspace that needs a GPU; -//! everything else about masks is layer 1 (docs/RUST.md's "Three test -//! layers"). It fails rather than skips when there is no adapter, because -//! a check that quietly did not run reads exactly like a check that -//! passed. -//! -//! **It is a render pass, and it asks for `iris_core::device_limits()`, -//! because those are the two things iris itself does.** The first version -//! of this test was a compute pass, which meant asking for compute limits -//! that `device_limits()` deliberately zeroes -- docs/RUST.md, 2026-09-05: -//! nothing in `iris`/`iris-core` creates a `ComputePipeline` or writes a -//! `@compute` stage, so the limits stopped being requested rather than a -//! fallback being built for a capability nothing uses. A test that needs -//! a capability the thing under test has never needed is testing the -//! wrong device, which is reason enough. -//! -//! It is **not** why that version crashed; see [`vulkan_instance`] for -//! what that crash actually was and why nothing here has to work around -//! it any more. - -// `OnceLock` needs `Instance: Sync`, and wgpu's type -// graph is deep enough that proving it overflows rustc's default trait -// recursion limit of 128. Nothing here is recursive; the limit is a -// compile-time budget, and this is the documented way to raise it. #![recursion_limit = "256"] use std::sync::OnceLock; @@ -44,35 +6,16 @@ use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2}; use pollster::FutureExt; use wgpu::util::DeviceExt; -/// The rect the grid is sampled against, in window pixels. Deliberately -/// off the whole-pixel grid: the shader floors a primitive's corners, but -/// `rounded_rect_coverage` is handed pixels either side of that and has to -/// agree at fractional positions too -- the phone's 2.55 density puts -/// nothing on a whole pixel. const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25); const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0); -/// Radii spanning what the widgets actually ask for, plus the two edges of -/// the function's own domain: a square corner, and one large enough that -/// `min(edge, radius)` stops mattering. const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0]; -/// The grid, as an attachment: one texel per probe point. `GRID_W` is a -/// multiple of 64 so that a row of `R32Float` is 256-byte aligned, which -/// is what `copy_texture_to_buffer` requires; at `STEP` this spans the -/// rect above and about four pixels of margin on every side, so the -/// feather is sampled rather than stepped over. const GRID_W: u32 = 384; const GRID_H: u32 = 192; const STEP: f32 = 0.5; const ORIGIN: Vec2 = Vec2::new(TOP_LEFT.x - 4.0, TOP_LEFT.y - 4.0); -/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and -/// `smoothstep` are each allowed a unit or two in the last place, and the -/// GPU may contract a multiply-add the CPU does not. A coverage is in -/// [0, 1], so this is about six decimal digits -- four orders of magnitude -/// tighter than the half-pixel feather the hit test reads, which is what -/// the agreement is actually for. const TOLERANCE: f32 = 1e-5; #[test] @@ -114,7 +57,6 @@ fn mask_sdf_matches_the_shader() { corner stops being tappable where it is drawn.", ); - // The half that would pass on a function returning a constant. assert!( inside > 0 && feather > 0 && outside > 0, "the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \ @@ -122,18 +64,10 @@ fn mask_sdf_matches_the_shader() { ); } -/// The probe position of texel `(x, y)` -- the one place the mapping -/// lives, so the CPU side and the fragment stage cannot walk different -/// grids. fn probe_at(x: u32, y: u32) -> Vec2 { Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP) } -/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every texel -/// of an `R32Float` attachment: one fragment per grid point, read back -/// whole. A fragment stage because that is the stage the function is -/// really called from, so what this compares is the code path that draws -/// rather than a second one built to be measurable. fn run_shader(gpu: &Gpu, radius: f32) -> Vec { let Gpu { device, queue, .. } = gpu; let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -141,10 +75,6 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec { source: wgpu::ShaderSource::Wgsl(probe_source().into()), }); - // R32Float, not an 8-bit colour format: a coverage quantised to 1/255 - // could not be compared against the CPU's at anything like TOLERANCE, - // and the comparison would then be measuring the texture rather than - // the two functions. let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("mask sdf coverage"), size: wgpu::Extent3d { @@ -205,9 +135,6 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec { }], }); - // `copy_texture_to_buffer` wants each row 256-byte aligned; GRID_W is - // chosen so that it already is, rather than padding and unpicking the - // padding on the way out. let row_bytes = GRID_W * 4; assert_eq!(row_bytes % 256, 0, "GRID_W must keep rows 256-byte aligned"); let out_size = u64::from(row_bytes) * u64::from(GRID_H); @@ -272,42 +199,17 @@ fn run_shader(gpu: &Gpu, radius: f32) -> Vec { coverage } -/// One `wgpu::Instance` for the process, created on first use and never -/// destroyed. -/// -/// **Why it is a static rather than a value the test owns.** Destroying -/// the last `VkInstance` makes the Vulkan loader `dlclose` the ICD, and -/// Mesa's ICD here registers a `pthread_key_create` destructor pointing -/// into its own text without being linked `-z nodelete`. glibc then calls -/// that destructor when the thread exits -- through an address that is no -/// longer mapped. libtest runs every `#[test]` on a spawned thread, so a -/// test that opens and closes an instance segfaults *after* printing its -/// result, which reads exactly like the test failing. Measured -/// 2026-09-08 with `scripts/rigs/gpu-probe`'s `teardown` bin: it -/// needs no wgpu (raw `ash` does it too), no GPU work, and no device -- -/// an instance created and destroyed on a spawned thread is enough, and -/// keeping any one instance alive is enough to prevent it. -/// -/// Devices, queues and everything else drop normally; only the instance -/// is held, which is what wgpu asks for anyway (one instance per -/// process). So this costs one instance for the length of a test binary -/// and buys ordinary drops everywhere else. fn vulkan_instance() -> &'static wgpu::Instance { static INSTANCE: OnceLock = OnceLock::new(); INSTANCE.get_or_init(wgpu::Instance::default) } -/// The device this test draws with. struct Gpu { device: wgpu::Device, queue: wgpu::Queue, } impl Gpu { - /// Opens the device this test draws with, and reports which adapter - /// answered, because that is not a detail here: a run on llvmpipe and - /// a run on the host's GPU are otherwise indistinguishable in the - /// log, and only one of them is a check of what the phone will do. fn open() -> Self { let instance = vulkan_instance(); let adapter = instance @@ -326,7 +228,6 @@ impl Gpu { ); let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { - // What iris itself asks for -- see this file's header. required_limits: iris_core::device_limits(), ..Default::default() }) @@ -336,8 +237,6 @@ impl Gpu { } } -/// What the fragment stage needs to turn its own texel into a probe -/// position: the rect being sampled, and where texel (0, 0) sits. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct Probe { @@ -349,11 +248,6 @@ struct Probe { _pad: [f32; 3], } -/// The probe module: the two functions **lifted from `shader.wgsl` -/// itself**, plus an entry point that calls the outer one. Lifted rather -/// than copied so there is nothing to keep in step -- an edit to the -/// shader is what this test is for, and a copy here would be edited along -/// with it. fn probe_source() -> String { format!( "{}\n{}\n\ @@ -380,9 +274,6 @@ fn probe_source() -> String { ) } -/// One WGSL function's whole text, from its `fn` keyword to the `}` that -/// closes its body, found by matching braces. Panics by name when the -/// function is not there, which is what a rename looks like from here. fn wgsl_fn(name: &str) -> &'static str { let start = SHAPE_SHADER .find(&format!("fn {name}("))