From 333220196ea32e1580bc7402d7071eec5c9de1f3 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:24:54 -0400 Subject: [PATCH 1/7] iris: a headless in-process harness, and the bench fixture as a shared crate Layer 1 of docs/RUST.md's "Three test layers": `iris::harness` opens a real screen with no window, no compositor and no GPU, on an explicit clock and a replayed touch stream -- a trivial `t_ms action x y` file, so the batched 120Hz flick shape from Iris's phone report is reproducible as a test. The emulator cannot produce that shape at all: a `ui-trace` swipe is many evenly-spaced events, a finger is five samples in 20ms. `transcript-fixture` is the fixture-loading and fold-driving half of `iris-android-app`'s `bench_client.rs`, moved out of the platform crate so the harness, a desktop window and the Android bench open the same screen from the same bytes (AGENTS.md's sharing rule). Two supporting changes in iris itself, both about reading a clock that was not handed in: `Fling::started_at` is now set on the first `tick_fling` rather than at the release, so a driver running frames on its own clock does not start every fling at the wall clock and advance it on a different one; and `List::fling_velocity` exposes what the release measured, which is where `Released(Some(v))` lands. Four tests, each confirmed to fail without its subject: dropping `animate(id)` from `Selection::drag` (the phone's own "fling does nothing" defect) and reverting `started_at` each fail the flick test alone; flinging on `Tapped` fails only the tap test; a 5s `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that ignores its argument fails only the composer/IME test. Co-Authored-By: Claude Fable 5.1 --- iris/Cargo.lock | 12 + iris/Cargo.toml | 2 +- iris/src/harness.rs | 396 ++++++++++++++++++ iris/src/lib.rs | 1 + iris/src/widget/list.rs | 26 +- iris/transcript-fixture/Cargo.toml | 24 ++ iris/transcript-fixture/src/lib.rs | 139 ++++++ iris/transcript-fixture/tests/phone_screen.rs | 176 ++++++++ .../touch/flick-120hz.touch | 21 + .../transcript-fixture/touch/long-press.touch | 11 + iris/transcript-fixture/touch/tap.touch | 5 + 11 files changed, 809 insertions(+), 4 deletions(-) create mode 100644 iris/src/harness.rs create mode 100644 iris/transcript-fixture/Cargo.toml create mode 100644 iris/transcript-fixture/src/lib.rs create mode 100644 iris/transcript-fixture/tests/phone_screen.rs create mode 100644 iris/transcript-fixture/touch/flick-120hz.touch create mode 100644 iris/transcript-fixture/touch/long-press.touch create mode 100644 iris/transcript-fixture/touch/tap.touch diff --git a/iris/Cargo.lock b/iris/Cargo.lock index df39da9..30f05a8 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -3646,6 +3646,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-fixture" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "serde_json", + "transcript-ui", + "winit", +] + [[package]] name = "transcript-ui" version = "0.1.0" diff --git a/iris/Cargo.toml b/iris/Cargo.toml index c431c37..bdd3481 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -89,7 +89,7 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui", "transcript-ui", "desktop-app"] +members = ["core", "macro", "tabs-ui", "transcript-ui", "transcript-fixture", "desktop-app"] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate diff --git a/iris/src/harness.rs b/iris/src/harness.rs new file mode 100644 index 0000000..9b05871 --- /dev/null +++ b/iris/src/harness.rs @@ -0,0 +1,396 @@ +//! Layer 1 of docs/RUST.md's "Three test layers": a whole screen driven +//! in-process with **no window, no compositor and no GPU**, on an +//! explicit clock and a replayed touch stream. +//! +//! `layout_tests.rs` and `sense_tests.rs` already build trees over +//! `UiRenderState` with a hand-rolled `Rsc` each; this is the same idea +//! carried far enough to open a real app screen (`transcript-ui`'s, over +//! the bench fixture -- see the `transcript-fixture` crate) at the +//! phone's size and density, feed it a recorded flick, and assert on +//! where the list ended up. What it answers that the emulator cannot: +//! Android batches a 120Hz flick into one or two `MotionEvent`s +//! (`CursorState::time`), and a `ui-trace` swipe is many evenly-spaced +//! ones -- so the gesture shape a finger actually makes is only +//! reproducible from a *file* of timestamped samples. +//! +//! It is a third backend in the sense `default/` and `android/` are, and +//! deliberately the smallest one: the platform half of each of those +//! (a surface, an IME, a URL opener) becomes a recorded fact here -- +//! [`HarnessState::keyboard_shown`], [`HarnessState::opened_urls`] -- +//! so a test can assert the platform *was asked*, which is the only +//! thing either backend does with those calls anyway. +//! +//! ```ignore +//! let mut h = Harness::new(phone_size(), PHONE_SCALE); +//! let screen = transcript_ui::build(&mut h.rsc, &mut h.state, rows); +//! h.frame(0); +//! h.replay(&TouchScript::parse(include_str!("flick.touch"))?); +//! h.frames_until(20, 2_000, 8); +//! ``` + +use crate::prelude::*; +use std::marker::PhantomData; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +/// One replayed pointer sample: what Android's `MotionEvent` carries, cut +/// down to the part iris reads (`IrisViewPeer::on_touch_event`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TouchAction { + Down, + Move, + Up, + /// The gesture taken away by the system (a parent view claiming it, a + /// call arriving). It ends the press exactly as `Up` does -- a + /// release that never arrives leaves pointer capture held forever -- + /// which is why a replay file can say it. + Cancel, +} + +impl TouchAction { + fn parse(word: &str) -> Option { + match word { + "down" => Some(Self::Down), + "move" => Some(Self::Move), + "up" => Some(Self::Up), + "cancel" => Some(Self::Cancel), + _ => None, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct TouchSample { + /// Milliseconds since the start of the recording -- the sample's own + /// time, which becomes `CursorState::time`. See that field's doc for + /// why a replay may not date its samples by when the loop got to + /// them. + pub t_ms: u64, + pub action: TouchAction, + pub pos: Vec2, +} + +/// A recorded gesture: one `t_ms action x y` line per sample, `#` and +/// blank lines ignored. Deliberately a plain text file rather than a +/// serialisation format -- it is written by hand as often as it is +/// recorded, and a diff of one has to be readable. +pub struct TouchScript { + pub samples: Vec, +} + +impl TouchScript { + /// Parses a script, naming the line and what was wrong with it: these + /// are hand-written files, so a typo is the ordinary case and + /// "expected 4 fields" without a line number is not enough to fix it. + pub fn parse(text: &str) -> Result { + let mut samples: Vec = Vec::new(); + for (i, line) in text.lines().enumerate() { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let at = |what: &str| format!("touch script line {}: {what}: {line:?}", i + 1); + let mut words = line.split_whitespace(); + let (Some(t), Some(action), Some(x), Some(y), None) = ( + words.next(), + words.next(), + words.next(), + words.next(), + words.next(), + ) else { + return Err(at("expected `t_ms action x y`")); + }; + let t_ms: u64 = t.parse().map_err(|_| at("t_ms is not a whole number"))?; + let action = TouchAction::parse(action) + .ok_or_else(|| at("action is not down/move/up/cancel"))?; + let x: f32 = x.parse().map_err(|_| at("x is not a number"))?; + let y: f32 = y.parse().map_err(|_| at("y is not a number"))?; + if let Some(last) = samples.last() + && t_ms < last.t_ms + { + return Err(at("samples must be in time order")); + } + samples.push(TouchSample { + t_ms, + action, + pos: Vec2::new(x, y), + }); + } + Ok(Self { samples }) + } + + /// The last sample's time, i.e. how long the recording runs. + pub fn end_ms(&self) -> u64 { + self.samples.last().map(|s| s.t_ms).unwrap_or(0) + } +} + +/// Counts the frames something asked for without drawing any -- the +/// harness's `RequestRedraw`. A `List` coasting through a fling asks for +/// the next frame through this (`List::set_redraw_handle`), so a test can +/// tell "nothing moved" from "nothing was even asked to move". +#[derive(Default)] +pub struct RedrawCounter(AtomicUsize); + +impl RedrawCounter { + pub fn count(&self) -> usize { + self.0.load(Ordering::Relaxed) + } +} + +impl RequestRedraw for RedrawCounter { + fn request_redraw(&self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// The harness's app state: what each real backend keeps for the platform +/// half, recorded instead of performed. +pub struct HarnessState { + pub root: Option, + pub focus: Option>, + last_click: Instant, + /// How many times a tap asked for the keyboard (`FocusHost:: + /// focus_gained` with a region -- `showSoftInput` on Android, + /// `set_ime_cursor_area` on winit). The platform's own answer is not + /// available here, so this says what was *asked*, and a test must not + /// read it as "the IME is up". + pub keyboard_shown: usize, + /// Every URL a tapped link asked the platform to open, in order. + pub opened_urls: Vec, +} + +impl HarnessState { + fn new() -> Self { + Self { + root: None, + focus: None, + last_click: Instant::now(), + keyboard_shown: 0, + opened_urls: Vec::new(), + } + } +} + +impl HasRoot for HarnessState { + fn set_root(&mut self, root: StrongWidget) { + self.root = Some(root); + } +} + +impl FocusHost for HarnessState { + fn recent_click(&mut self) -> bool { + crate::attr::recent_click(&mut self.last_click) + } + fn set_focus(&mut self, id: Option>) { + self.focus = id; + } + fn is_focused(&self, id: WeakWidget) -> bool { + self.focus == Some(id) + } + fn focus_gained(&mut self, region: Option) { + if region.is_some() { + self.keyboard_shown += 1; + } + } +} + +impl OpenUrl for HarnessState { + fn open_url(&mut self, url: &str) { + self.opened_urls.push(url.to_string()); + } +} + +/// The harness's `Rsc` -- identical in substance to `DefaultRsc`/ +/// `AndroidRsc` minus the windowing, for the same reason those two are +/// separate types (`AndroidRsc`'s own doc). +pub struct HarnessRsc { + pub ui: UiData, + pub events: EventManager, + pub tasks: Tasks, + pub state: WidgetState, + _state: PhantomData, +} + +impl UiRsc for HarnessRsc { + fn ui(&self) -> &UiData { + &self.ui + } + fn ui_mut(&mut self) -> &mut UiData { + &mut self.ui + } + fn on_draw(&mut self, active: &ActiveData) { + self.events.draw(active); + } + fn on_undraw(&mut self, active: &ActiveData) { + self.events.undraw(active); + } + fn on_remove(&mut self, id: WidgetId) { + self.events.remove(id); + self.state.remove(id); + } +} + +impl HasState for HarnessRsc { + type State = HarnessState; +} + +impl HasEvents for HarnessRsc { + fn events(&self) -> &EventManager { + &self.events + } + fn events_mut(&mut self) -> &mut EventManager { + &mut self.events + } +} + +impl HasTasks for HarnessRsc { + fn tasks_mut(&mut self) -> &mut Tasks { + &mut self.tasks + } +} + +impl HasWidgetState for HarnessRsc { + fn widget_state(&self) -> &WidgetState { + &self.state + } + fn widget_state_mut(&mut self) -> &mut WidgetState { + &mut self.state + } +} + +impl> std::ops::Index for HarnessRsc { + type Output = I::Output; + fn index(&self, index: I) -> &Self::Output { + index.get(self) + } +} + +impl> std::ops::IndexMut for HarnessRsc { + fn index_mut(&mut self, index: I) -> &mut Self::Output { + index.get_mut(self) + } +} + +/// A screen running with no window: the widget tree, the frame loop and +/// the pointer, all advanced by the caller. See the module doc. +pub struct Harness { + pub rsc: HarnessRsc, + pub render: UiRenderState, + pub state: HarnessState, + task_recv: TaskMsgReceiver, + redraws: Arc, + cursor: CursorState, + /// Time zero. Every `t_ms` in this harness is an offset from here, so + /// nothing reads the wall clock -- see [`Self::at`]. + base: Instant, + size: Vec2, +} + +impl Harness { + /// `size` is in physical pixels and `density` is physical pixels per + /// dp, the pair Android reads from the surface and + /// `DisplayMetrics.density` (`AndroidUiState::content_scale`). The + /// phone's own numbers are `transcript_fixture::PHONE_SIZE`/ + /// `PHONE_SCALE`. + pub fn new(size: Vec2, density: f32) -> Self { + let redraws = Arc::new(RedrawCounter::default()); + let (tasks, task_recv) = Tasks::init(redraws.clone()); + let mut rsc = HarnessRsc { + ui: UiData::default(), + events: EventManager::default(), + tasks, + state: WidgetState::default(), + _state: PhantomData, + }; + rsc.ui.text.density = density; + let mut render = UiRenderState::new(); + render.set_density(density); + render.resize(size); + Self { + rsc, + render, + state: HarnessState::new(), + task_recv, + redraws, + cursor: CursorState::default(), + base: Instant::now(), + size, + } + } + + /// The `Instant` this harness means by `t_ms`. Public because a + /// caller driving `List::tick_fling` or `DragGesture` by hand needs + /// to date those calls on the same clock the touch samples use. + pub fn at(&self, t_ms: u64) -> Instant { + self.base + Duration::from_millis(t_ms) + } + + pub fn size(&self) -> Vec2 { + self.size + } + + /// How many frames were asked for so far -- see [`RedrawCounter`]. + pub fn redraws(&self) -> usize { + self.redraws.count() + } + + /// One frame at `t_ms`: drain finished tasks, advance anything + /// animating, lay out and "draw". The same three steps + /// `DefaultApp::window_event`'s `RedrawRequested` arm and + /// `IrisViewPeer::render` take, minus handing primitives to a GPU. + pub fn frame(&mut self, t_ms: u64) { + while let Ok(update) = self.task_recv.try_recv() { + update(&mut self.state, &mut self.rsc); + } + let now = self.at(t_ms); + self.rsc.ui.tick_animations(now); + self.render.update(&self.state.root, &mut self.rsc); + } + + /// Frames every `step_ms` up to and including `end_ms` -- what a + /// fling needs, since it moves only while something ticks it + /// (`List::fling`'s doc). Returns the time of the last frame run. + pub fn frames_until(&mut self, from_ms: u64, end_ms: u64, step_ms: u64) -> u64 { + debug_assert!(step_ms > 0, "a frame loop with no step never ends"); + let mut t = from_ms; + while t <= end_ms { + self.frame(t); + t += step_ms; + } + t - step_ms + } + + /// One pointer sample through the sensors, then the frame it belongs + /// to -- `IrisViewPeer::on_touch_event` and `after_input`, in one + /// call. Each sample is its own input frame, dated by the sample + /// rather than by when this ran. + pub fn touch(&mut self, action: TouchAction, pos: Vec2, t_ms: u64) { + self.cursor.time = self.at(t_ms); + self.cursor.pos = pos; + match action { + TouchAction::Down => { + self.cursor.exists = true; + self.cursor.buttons.left.update(true); + } + TouchAction::Move => {} + TouchAction::Up | TouchAction::Cancel => self.cursor.buttons.left.update(false), + } + let cursor = self.cursor.clone(); + self.render + .run_sensors(&mut self.rsc, &mut self.state, cursor, self.size); + self.frame(t_ms); + self.cursor.end_frame(); + } + + /// Replays a whole recorded gesture. Nothing is inserted between the + /// samples: a file with three lines produces three input frames, so + /// the batched shape a real flick arrives in is preserved exactly as + /// recorded rather than smoothed into evenly-spaced motion. + pub fn replay(&mut self, script: &TouchScript) { + for sample in &script.samples { + self.touch(sample.action, sample.pos, sample.t_ms); + } + } +} diff --git a/iris/src/lib.rs b/iris/src/lib.rs index 1dda0fe..1473f70 100644 --- a/iris/src/lib.rs +++ b/iris/src/lib.rs @@ -21,6 +21,7 @@ pub mod default; pub mod attr; pub mod event; +pub mod harness; pub mod platform; pub mod sense; pub mod state; diff --git a/iris/src/widget/list.rs b/iris/src/widget/list.rs index 50f758e..6241083 100644 --- a/iris/src/widget/list.rs +++ b/iris/src/widget/list.rs @@ -259,7 +259,16 @@ pub struct List { struct Fling { calc: FlingCalculator, velocity: f32, - started_at: Instant, + /// When the fling's own curve begins -- **the first `tick_fling`, + /// not the release**. It is set there rather than in `fling` so the + /// only clock this widget reads is the one its driver hands it: a + /// caller running frames on an explicit clock (`iris::harness`, and + /// `bench_client.rs`'s scripted phases) would otherwise start every + /// fling at the wall clock and advance it on a different one, and a + /// fling released at t=500ms would arrive already over. The + /// difference in a running app is at most one frame, since that is + /// how soon the fling is first ticked. + started_at: Option, applied: f32, } @@ -474,7 +483,7 @@ impl List { self.fling = Some(Fling { calc: FlingCalculator::new(self.density), velocity: velocity_px_per_s, - started_at: Instant::now(), + started_at: None, applied: 0.0, }); } @@ -487,6 +496,17 @@ impl List { self.fling.is_some() } + /// The velocity a fling in progress is coasting at, in this list's + /// own pixel space -- `None` when nothing is flinging. What a + /// release's decision looks like from the outside: a + /// `GestureOutcome::Released(Some(v))` is the only thing that puts a + /// value here, so a test (or a diagnostic) can read what the gesture + /// measured at the place it landed, rather than re-timing the + /// gesture itself. + pub fn fling_velocity(&self) -> Option { + self.fling.as_ref().map(|f| f.velocity) + } + /// Cancel any fling in progress with no further movement -- the next /// touch-down's job, per `fling`'s own doc. pub fn cancel_fling(&mut self) { @@ -508,7 +528,7 @@ impl List { let Some(f) = &mut self.fling else { return false; }; - let elapsed = now.saturating_duration_since(f.started_at); + let elapsed = now.saturating_duration_since(*f.started_at.get_or_insert(now)); let target = f.calc.position_at(f.velocity, elapsed); let delta = target - f.applied; f.applied = target; diff --git a/iris/transcript-fixture/Cargo.toml b/iris/transcript-fixture/Cargo.toml new file mode 100644 index 0000000..236c4e9 --- /dev/null +++ b/iris/transcript-fixture/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "transcript-fixture" +version.workspace = true +edition.workspace = true + +# The bench fixture, opened as a real transcript screen with no server -- +# docs/RUST.md's "Three test layers". It was `iris-android-app`'s +# `bench_client.rs` alone until 2026-09-07; the fixture-loading and +# fold-driving half moved here so the headless harness (layer 1), the +# phone-shaped desktop window (layer 2) and the Android bench (layer 3) +# all open the *same* screen from the same bytes, per AGENTS.md's rule +# that nothing UI-shaped lives in a platform crate. + +[dependencies] +iris = { path = ".." } +transcript-ui = { path = "../transcript-ui" } +client-core = { path = "../../client-core" } +event-model = { path = "../../event-model" } +# `float_roundtrip` for the same reason `server/Cargo.toml` has it: a `ts` +# read back must be the one that was written (AGENTS.md). +serde_json = { version = "1", features = ["float_roundtrip"] } + +[dev-dependencies] +winit = { workspace = true } diff --git a/iris/transcript-fixture/src/lib.rs b/iris/transcript-fixture/src/lib.rs new file mode 100644 index 0000000..4ba65d3 --- /dev/null +++ b/iris/transcript-fixture/src/lib.rs @@ -0,0 +1,139 @@ +//! The checked-in bench fixture, opened as a real transcript screen with +//! no server -- shared by every layer of docs/RUST.md's test rig. +//! +//! The bytes are `app/bench-fixture/assets/transcript.jsonl` (1,915,760 +//! bytes, generated by `app/bench-fixture/generate.py`, never a real +//! transcript -- that file's own README), embedded with `include_str!`. +//! The first [`BACKLOG_COUNT`] non-blank lines are the opening window, +//! folded once through `client_core::transcript_fold::fold_page` exactly +//! as a real `/transcript` page would be; the rest are the streaming +//! tail, replayed one at a time through `fold_event` the way a live SSE +//! frame arrives. +//! +//! This half used to live in `iris-android-app`'s `bench_client.rs`, and +//! moved here on 2026-09-07 so the headless harness and a desktop window +//! open the same screen from the same bytes (AGENTS.md: nothing +//! UI-shaped in a platform crate). What stayed there is the JNI half -- +//! the clipboard, the battery sampler, the IME calls and the report. + +use client_core::transcript_fold::{TranscriptItem, TranscriptRow, fold_page, group_tool_runs}; +use event_model::SeqEvent; +use iris::prelude::*; + +/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are +/// the opening window; the rest are the streaming tail. Kept in sync with +/// `BenchFixture.kt`'s identical constant by hand -- both read the same +/// checked-in file, so a mismatch would only mean the two apps' bench +/// builds open a different split of it, not a wrong-vs-right answer. +pub const BACKLOG_COUNT: usize = 3200; + +const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); + +/// Iris's phone as `docs/bench/iris-phone-v2-2026-09-06.md` and +/// `docs/IRIS_TODO.md` record it: a 1080x2424 surface at +/// `content_scale: 2.55`, 120Hz. Read from those reports, never typed +/// from memory -- every layer of the rig lays out at this size and +/// density so a screenshot and a headless assertion are about the same +/// screen. +pub const PHONE_WIDTH: f32 = 1080.0; +pub const PHONE_HEIGHT: f32 = 2424.0; +pub const PHONE_SCALE: f32 = 2.55; +/// 120Hz, the refresh rate that report ran at: 8.3ms a frame. +pub const PHONE_FRAME_MS: u64 = 8; + +pub fn phone_size() -> Vec2 { + Vec2::new(PHONE_WIDTH, PHONE_HEIGHT) +} + +/// The fixture split the way the wire delivers it: raw JSON values for +/// the opening page (`fold_page` takes a page of wire JSON, same as a +/// real `/transcript` response) and parsed `SeqEvent`s for the tail +/// (`fold_event` takes one live event at a time, same as an SSE frame). +pub struct Fixture { + pub backlog: Vec, + pub stream_tail: Vec, +} + +impl Fixture { + /// Parses the whole fixture. Panics on malformed input: this is a + /// generated file compiled into the binary, so a parse failure is a + /// broken build rather than a condition a caller could recover from + /// (CODE_RULES: separate recoverable conditions from programmer + /// error). + pub fn parse() -> Self { + let mut backlog = Vec::with_capacity(BACKLOG_COUNT); + let mut stream_tail = Vec::new(); + for (i, line) in FIXTURE_JSONL + .lines() + .filter(|line| !line.trim().is_empty()) + .enumerate() + { + let value: serde_json::Value = + serde_json::from_str(line).expect("bench fixture is generated JSON, always valid"); + if i < BACKLOG_COUNT { + backlog.push(value); + } else { + stream_tail.push( + serde_json::from_value(value) + .expect("bench fixture event matches event-model's SeqEvent"), + ); + } + } + Self { + backlog, + stream_tail, + } + } + + /// The opening page folded into transcript items -- the same + /// `fold_page` a real first load runs. `Err` carries the fold's own + /// message, which a caller shows on screen rather than panicking, so + /// a fixture that stops folding is visible in the app instead of + /// being a crash on launch. + pub fn backlog_items(&self) -> Result, String> { + fold_page(&self.backlog) + } +} + +/// The fixture's opening page as the rows a screen is built from. +pub fn rows(items: &[TranscriptItem]) -> Vec { + group_tool_runs(items) +} + +/// Build the transcript screen over the fixture's opening page and make +/// it the root -- what every layer of the rig opens. Returns the screen +/// and the items behind it, so a caller can go on streaming the tail +/// through `fold_event`/`TranscriptScreen::apply` as the Android bench +/// does. +pub fn open( + rsc: &mut Rsc, + ui_state: &mut impl HasRoot, +) -> Result<(transcript_ui::TranscriptScreen, Vec), String> +where + Rsc::State: FocusHost + OpenUrl, +{ + let fixture = Fixture::parse(); + let items = fixture.backlog_items()?; + let screen = transcript_ui::build(rsc, ui_state, rows(&items)); + Ok((screen, items)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The split is what both bench clients assume; a fixture that + /// stopped having a streaming tail would make the Android bench's + /// stream phase silently measure nothing. + #[test] + fn the_fixture_has_a_backlog_and_a_streaming_tail() { + let fixture = Fixture::parse(); + assert_eq!(fixture.backlog.len(), BACKLOG_COUNT); + assert!( + fixture.stream_tail.len() >= 400, + "the stream phase replays 400 events; the fixture has {}", + fixture.stream_tail.len() + ); + assert!(!fixture.backlog_items().expect("the page folds").is_empty()); + } +} diff --git a/iris/transcript-fixture/tests/phone_screen.rs b/iris/transcript-fixture/tests/phone_screen.rs new file mode 100644 index 0000000..206a45a --- /dev/null +++ b/iris/transcript-fixture/tests/phone_screen.rs @@ -0,0 +1,176 @@ +//! Layer 1 of docs/RUST.md's "Three test layers": the real transcript +//! screen, over the real bench fixture, at the phone's size and density, +//! driven by `iris::harness` with no window, no compositor and no GPU. +//! +//! Every gesture here is a file under `touch/` -- see +//! `flick-120hz.touch` for why the *shape* of the delivery is the whole +//! point, and why the emulator cannot produce it (a `ui-trace` swipe is +//! many evenly-spaced events; a finger at 120Hz is five samples in +//! 20ms). + +use iris::harness::{Harness, TouchScript}; +use iris::prelude::*; +use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; + +/// The screen open on the fixture, framed twice: once to draw, once for +/// `List::repair_anchor` to resolve the opening `snap_end` into a real +/// anchor, which is what every assertion about scroll position reads. +fn opened() -> (Harness, transcript_ui::TranscriptScreen) { + let mut h = Harness::new(phone_size(), PHONE_SCALE); + let (screen, _items) = + transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); + h.frame(0); + h.frame(PHONE_FRAME_MS); + (h, screen) +} + +fn script(name: &str, text: &str) -> TouchScript { + TouchScript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}")) +} + +fn offset(h: &mut Harness, screen: &transcript_ui::TranscriptScreen) -> String { + (screen.list)(&mut h.rsc).anchor_position_display() +} + +/// (a) and (b) together, because the second is only meaningful if the +/// first happened: the recorded flick must release with a real velocity +/// (`GestureOutcome::Released(Some(v))`, which is the only thing that +/// puts a value in `List::fling_velocity`), and the list must then +/// actually travel and stop on the spline's own schedule. +#[test] +fn a_recorded_flick_releases_with_a_velocity_and_flings_the_list() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + let flick = script("flick-120hz", include_str!("../touch/flick-120hz.touch")); + h.replay(&flick); + + let velocity = (screen.list)(&mut h.rsc) + .fling_velocity() + .expect("the flick must release as a pan with a velocity, not a tap"); + assert!( + velocity.abs() > 1_000.0, + "a 188px, 16ms flick is thousands of px/s; got {velocity}" + ); + + // Android's own spline says how long a fling at this speed runs. The + // list learns its density from the painter, so this is the same + // curve it is using. + let expected = FlingCalculator::new(PHONE_SCALE).duration(velocity); + let end = flick.end_ms() + expected.as_millis() as u64 * 2; + let mut settled_at = None; + let mut t = flick.end_ms(); + while t <= end { + h.frame(t); + if settled_at.is_none() && !(screen.list)(&mut h.rsc).is_scrolling() { + settled_at = Some(t); + } + t += PHONE_FRAME_MS; + } + + let after = offset(&mut h, &screen); + assert_ne!( + before, after, + "the fling ticks must have moved the list off where the flick left it" + ); + let settled_at = settled_at.expect("the fling must stop on its own, not run forever"); + let ran_for = settled_at - flick.end_ms(); + assert!( + ran_for <= expected.as_millis() as u64 + PHONE_FRAME_MS * 2, + "the fling ran {ran_for}ms against the spline's own {}ms", + expected.as_millis() + ); +} + +/// The half the flick fix had no reason to touch: a tap must decide +/// `Tapped`, which means no velocity anywhere and nothing moved. +#[test] +fn a_tap_on_a_row_moves_nothing() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + h.replay(&script("tap", include_str!("../touch/tap.touch"))); + + assert_eq!( + (screen.list)(&mut h.rsc).fling_velocity(), + None, + "a tap must not fling" + ); + // Frames it would have moved in, had anything been moving. + h.frames_until(100, 400, PHONE_FRAME_MS); + assert_eq!(before, offset(&mut h, &screen), "a tap must scroll nothing"); + assert_eq!( + h.state.opened_urls, + Vec::::new(), + "no link was under this tap" + ); +} + +/// A press held past `LONG_PRESS` and then dragged selects text rather +/// than panning -- the other branch of the same arbiter the flick goes +/// through. +#[test] +fn a_long_press_and_drag_selects_text() { + let (mut h, screen) = opened(); + let before = offset(&mut h, &screen); + + h.replay(&script( + "long-press", + include_str!("../touch/long-press.touch"), + )); + + let selected = screen + .selected_text(&mut h.rsc) + .expect("a long-press then drag must leave text selected"); + assert!( + !selected.trim().is_empty(), + "the selection covered no characters: {selected:?}" + ); + assert_eq!( + before, + offset(&mut h, &screen), + "a selection must not also pan the list" + ); +} + +/// The composer sits on whatever the platform says the bottom of usable +/// space is -- the keyboard's inset while it is open +/// (`Composer::set_bottom_inset`, the path Android's +/// `on_insets_changed` feeds). Checked here rather than on the emulator +/// because it is a layout fact, and the emulator costs minutes. +#[test] +fn the_composer_sits_above_a_simulated_ime_inset() { + let (mut h, screen) = opened(); + let height = h.size().y; + let field_bottom = |h: &mut Harness| { + h.render + .window_region(&screen.composer.field, &h.rsc) + .expect("the composer field is on screen") + .bot_right + .y + }; + + let closed = field_bottom(&mut h); + assert!( + closed <= height, + "the composer is off the bottom of the window even with no keyboard: {closed} > {height}" + ); + + // A Gboard-sized keyboard on this surface. Any real number would do; + // what matters is that the bar clears it. + let ime = 1000.0; + screen.composer.set_bottom_inset(&mut h.rsc, ime); + h.frame(PHONE_FRAME_MS * 2); + + let open = field_bottom(&mut h); + assert!( + open <= height - ime, + "the keyboard covers the composer: its bottom is at {open}, the IME starts at {}", + height - ime + ); + assert!( + (closed - open - ime).abs() < 1.0, + "the composer moved {} for a {ime}px inset", + closed - open + ); +} diff --git a/iris/transcript-fixture/touch/flick-120hz.touch b/iris/transcript-fixture/touch/flick-120hz.touch new file mode 100644 index 0000000..1e69abb --- /dev/null +++ b/iris/transcript-fixture/touch/flick-120hz.touch @@ -0,0 +1,21 @@ +# A finger flick the shape Iris's phone delivers one, from +# docs/bench/iris-phone-v2-2026-09-06.md and docs/IRIS_TODO.md's +# "From the phone, 2026-09-06, 22:16": at 120Hz a flick reaches the app +# as DOWN, one or two MOVEs and UP inside a few frames, with the +# intermediate positions batched inside those MOVEs as historical +# samples (~4ms apart, the touch digitiser's own rate) rather than +# arriving as separate events. Each line here is one such sample, which +# is exactly what `IrisViewPeer::on_touch_event` replays through the +# sensors one at a time -- so the whole gesture is 20ms and five +# samples, and the velocity has to come out of *those*. +# +# Downward (increasing y) on purpose: the screen opens pinned to the +# newest end, so a flick the other way has nothing left to scroll to and +# the fling clamps on its first tick -- a pass that would prove nothing. +# Coordinates are physical pixels on a 1080x2424 surface. +0 down 540 1000 +4 move 540 1040 +8 move 540 1086 +12 move 540 1138 +16 move 540 1196 +20 up 540 1196 diff --git a/iris/transcript-fixture/touch/long-press.touch b/iris/transcript-fixture/touch/long-press.touch new file mode 100644 index 0000000..d0a254f --- /dev/null +++ b/iris/transcript-fixture/touch/long-press.touch @@ -0,0 +1,11 @@ +# A long-press then a drag across the text: held past LONG_PRESS +# (500ms) without moving, which is what starts a selection rather than a +# pan, then dragged sideways so the selection actually covers +# something. A press alone leaves a collapsed caret and no selected +# text (`Selection::begin`), which is why this file does not stop at the +# hold. +0 down 300 1000 +520 move 300 1000 +560 move 700 1000 +600 move 900 1000 +640 up 900 1000 diff --git a/iris/transcript-fixture/touch/tap.touch b/iris/transcript-fixture/touch/tap.touch new file mode 100644 index 0000000..da89399 --- /dev/null +++ b/iris/transcript-fixture/touch/tap.touch @@ -0,0 +1,5 @@ +# The case the flick had no reason to touch: a press and release in one +# place, well inside DRAG_SLOP and well under LONG_PRESS. It must be a +# tap -- no pan, no velocity, nothing moved. +0 down 540 1000 +80 up 540 1000 From 6840edf61e9f39ad4ff81b1d35d7a76ab9455576 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:27:04 -0400 Subject: [PATCH 2/7] iris-android-app: the bench's fixture half comes from transcript-fixture The fixture bytes, the backlog/tail split and the fold into a screen were `bench_client.rs`'s alone; they are `transcript-fixture`'s now, so the Android bench, the headless harness and the phone-shaped desktop window open one screen from one copy (AGENTS.md: nothing UI-shaped in a platform crate). What stays here is the JNI half -- clipboard, battery, IME, the report and the four phases. Built with `cargo ndk -t arm64-v8a -P 29 build --features "transcript-screen bench"`; the two warnings it prints (bench_jni's unused overlay methods, the unused `tabs-ui` dependency under this feature set) predate this change. Co-Authored-By: Claude Fable 5.1 --- iris/android-app/Cargo.lock | 12 ++++ iris/android-app/Cargo.toml | 6 +- iris/android-app/src/bench_client.rs | 64 ++++--------------- iris/transcript-fixture/src/lib.rs | 49 ++++++++++---- iris/transcript-fixture/tests/phone_screen.rs | 5 +- 5 files changed, 70 insertions(+), 66 deletions(-) diff --git a/iris/android-app/Cargo.lock b/iris/android-app/Cargo.lock index 9e5a6c7..6c4a19c 100644 --- a/iris/android-app/Cargo.lock +++ b/iris/android-app/Cargo.lock @@ -1774,6 +1774,7 @@ dependencies = [ "serde_json", "tabs-ui", "tokio", + "transcript-fixture", "transcript-ui", ] @@ -3864,6 +3865,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "transcript-fixture" +version = "0.1.0" +dependencies = [ + "client-core", + "event-model", + "iris", + "serde_json", + "transcript-ui", +] + [[package]] name = "transcript-ui" version = "0.1.0" diff --git a/iris/android-app/Cargo.toml b/iris/android-app/Cargo.toml index d0551bd..11f1664 100644 --- a/iris/android-app/Cargo.toml +++ b/iris/android-app/Cargo.toml @@ -29,6 +29,10 @@ log = "0.4.28" # which Cargo's `unused_dependencies` lint (on by default) correctly flags. tabs-ui = { path = "../tabs-ui", optional = true } transcript-ui = { path = "../transcript-ui", optional = true } +# P0's bench build only: the fixture and the folded screen both bench +# clients open, shared with the headless harness and the desktop window +# (docs/RUST.md's "Three test layers"). +transcript-fixture = { path = "../transcript-fixture", optional = true } client-core = { path = "../../client-core", optional = true } event-model = { path = "../../event-model", optional = true } serde_json = { version = "1", features = ["float_roundtrip"], optional = true } @@ -65,7 +69,7 @@ force-gles = ["iris/force-gles"] # `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature # priority over `transcript-screen`'s own `TranscriptClient` when both are # listed, which is how this crate's build command names both explicitly. -bench = ["transcript-screen", "dep:libc", "dep:tokio"] +bench = ["transcript-screen", "dep:transcript-fixture", "dep:libc", "dep:tokio"] [profile.release] panic = "abort" diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs index 212b2c7..9544a2d 100644 --- a/iris/android-app/src/bench_client.rs +++ b/iris/android-app/src/bench_client.rs @@ -6,14 +6,11 @@ //! //! **Reuses `transcript_client.rs`'s shape** (folded items, the same //! `TranscriptScreen::apply` incremental update on every event) with the -//! network half replaced by the checked-in fixture, embedded with -//! `include_str!` -- `app/bench-fixture/assets/transcript.jsonl`, -//! 1,915,760 bytes, generated by `app/bench-fixture/generate.py` and never -//! a real transcript (that file's own README). The first 3,200 lines are -//! the opening backlog, folded once through -//! `client_core::transcript_fold::fold_page` exactly as a real -//! `/transcript` page would be (then a full `transcript_ui::build_tree`, -//! same as any first load); the remaining ~400 are the streaming tail, +//! network half replaced by the checked-in fixture. Reading that fixture +//! and folding it into a screen is **`transcript-fixture`'s** job, not +//! this file's -- the same crate the headless harness and the +//! phone-shaped desktop window open, so all three measure one screen +//! (AGENTS.md's sharing rule; moved out of here 2026-09-07). The tail is //! replayed one at a time through `fold_event` -- the same fold path a //! live SSE reply arrives on -- by the "Run benchmark" control below. //! Streaming through `apply` rather than a full rebuild per event is what @@ -22,7 +19,7 @@ use crate::bench_jni::PlatformHandle; use android_view::jni::{JavaVM, objects::GlobalRef}; -use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; +use client_core::transcript_fold::{TranscriptItem, fold_event}; use event_model::SeqEvent; use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; use iris::prelude::*; @@ -30,13 +27,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are -/// the opening window; the rest are the streaming tail. Kept in sync with -/// `BenchFixture.kt`'s identical constant by hand -- both read the same -/// checked-in file, so a mismatch would only mean the two apps' bench -/// builds open a different split of it, not a wrong-vs-right answer. -const BACKLOG_COUNT: usize = 3200; - /// RUST.md's "Benchmark v2" spec, written once so both apps' bench clients /// implement the identical four phases -- see that box before changing any /// constant here, since a mismatch would make the two reports stop @@ -90,8 +80,6 @@ const KEYBOARD_WAIT_MS: u64 = 1_000; /// when a later step in the same phase needs to read state back. const ANIM_STEP_MS: u64 = 16; -const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); - /// How much of the screen a *filled* benchmark report may take before it /// scrolls instead of growing -- roughly a third of a phone screen, the /// share the pane used to reserve unconditionally. An empty report takes @@ -158,31 +146,6 @@ impl HasAndroidUiState for BenchClient { } } -/// Parses the fixture once: `serde_json::Value`s for the backlog -/// (`fold_page` takes a page of raw wire JSON, same as a real -/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event` -/// takes one live wire event at a time, same as a real SSE frame). -fn parse_fixture() -> (Vec, Vec) { - let lines: Vec<&str> = FIXTURE_JSONL - .lines() - .filter(|line| !line.trim().is_empty()) - .collect(); - let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len())); - let mut stream_tail = Vec::new(); - for (i, line) in lines.iter().enumerate() { - let value: serde_json::Value = - serde_json::from_str(line).expect("bench fixture is generated JSON, always valid"); - if i < BACKLOG_COUNT { - backlog.push(value); - } else { - let event: SeqEvent = serde_json::from_value(value) - .expect("bench fixture event matches event-model's SeqEvent"); - stream_tail.push(event); - } - } - (backlog, stream_tail) -} - fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { wtext(message.to_string()) .color(Color::WHITE) @@ -322,12 +285,12 @@ impl AndroidAppState for BenchClient { last_top_pad: 0.0, }; - let (backlog, stream_tail) = parse_fixture(); - client.stream_tail = stream_tail; - match fold_page(&backlog) { - Ok(items) => { - client.items = items; - client.rebuild_transcript(rsc); + match transcript_fixture::build_screen(rsc) { + Ok((opened, tree)) => { + client.items = opened.items; + client.stream_tail = opened.stream_tail; + (client.content)(rsc).set(tree); + client.screen = Some(opened.screen); } Err(message) => { client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}")) @@ -546,8 +509,7 @@ impl BenchClient { } fn rebuild_transcript(&mut self, rsc: &mut Rsc) { - let rows = group_tool_runs(&self.items); - let (screen, tree) = transcript_ui::build_tree(rsc, rows); + let (screen, tree) = transcript_ui::build_tree(rsc, transcript_fixture::rows(&self.items)); (self.content)(rsc).set(tree); self.screen = Some(screen); } diff --git a/iris/transcript-fixture/src/lib.rs b/iris/transcript-fixture/src/lib.rs index 4ba65d3..3da670a 100644 --- a/iris/transcript-fixture/src/lib.rs +++ b/iris/transcript-fixture/src/lib.rs @@ -100,22 +100,49 @@ pub fn rows(items: &[TranscriptItem]) -> Vec { group_tool_runs(items) } -/// Build the transcript screen over the fixture's opening page and make -/// it the root -- what every layer of the rig opens. Returns the screen -/// and the items behind it, so a caller can go on streaming the tail -/// through `fold_event`/`TranscriptScreen::apply` as the Android bench -/// does. -pub fn open( - rsc: &mut Rsc, - ui_state: &mut impl HasRoot, -) -> Result<(transcript_ui::TranscriptScreen, Vec), String> +/// Everything a caller needs to run the fixture as an app screen would: +/// the screen, the folded items behind it, and the events not yet +/// streamed. The tree itself comes back separately from +/// [`build_screen`], since whoever takes it owns it. +pub struct Opened { + pub screen: transcript_ui::TranscriptScreen, + pub items: Vec, + /// The tail, for a caller that goes on replaying it one event at a + /// time through `fold_event`/`TranscriptScreen::apply` -- the + /// streaming phase of either app's benchmark. + pub stream_tail: Vec, +} + +/// Build the transcript screen over the fixture's opening page, without +/// claiming the window's root -- `transcript_ui::build_tree`'s own split, +/// for a caller (the Android bench) that puts the screen inside a shell +/// of its own. +pub fn build_screen(rsc: &mut Rsc) -> Result<(Opened, StrongWidget), String> where Rsc::State: FocusHost + OpenUrl, { let fixture = Fixture::parse(); let items = fixture.backlog_items()?; - let screen = transcript_ui::build(rsc, ui_state, rows(&items)); - Ok((screen, items)) + let (screen, tree) = transcript_ui::build_tree(rsc, rows(&items)); + Ok(( + Opened { + screen, + items, + stream_tail: fixture.stream_tail, + }, + tree, + )) +} + +/// [`build_screen`] with the screen as the window's root -- what the +/// headless harness and the desktop window open. +pub fn open(rsc: &mut Rsc, ui_state: &mut impl HasRoot) -> Result +where + Rsc::State: FocusHost + OpenUrl, +{ + let (opened, tree) = build_screen(rsc)?; + ui_state.set_root(tree); + Ok(opened) } #[cfg(test)] diff --git a/iris/transcript-fixture/tests/phone_screen.rs b/iris/transcript-fixture/tests/phone_screen.rs index 206a45a..c94f8e2 100644 --- a/iris/transcript-fixture/tests/phone_screen.rs +++ b/iris/transcript-fixture/tests/phone_screen.rs @@ -17,11 +17,10 @@ use transcript_fixture::{PHONE_FRAME_MS, PHONE_SCALE, phone_size}; /// anchor, which is what every assertion about scroll position reads. fn opened() -> (Harness, transcript_ui::TranscriptScreen) { let mut h = Harness::new(phone_size(), PHONE_SCALE); - let (screen, _items) = - transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); + let opened = transcript_fixture::open(&mut h.rsc, &mut h.state).expect("the fixture folds"); h.frame(0); h.frame(PHONE_FRAME_MS); - (h, screen) + (h, opened.screen) } fn script(name: &str, text: &str) -> TouchScript { From a999bd106a23c74940548ba846b72c9961a5f3e3 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:34:19 -0400 Subject: [PATCH 3/7] docs: masks with a shape (LAYOUT.md, decided 2026-09-07) and the orchestrator queue in RUST.md Iris: masks should carry a shape, rounded rectangle first, or take a container widget as the mask, with corner alpha multiplied rather than cut. Design: the mask evaluates the same SDF draw_rounded_rect uses, nested masks chain and multiply like moves, and a rounded Rect's .masked() makes the container the mask with one radius by construction. Co-Authored-By: Claude Fable 5.1 --- docs/LAYOUT.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/RUST.md | 19 ++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index f13af43..5c71a77 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -947,3 +947,72 @@ When this lands, copy this entry into `IRIS.md` (newest first): > `SizeCtx` and `Cache` are gone with it — see `LAYOUT.md` for the full > design, the move-offset mechanism this shipped alongside, and the file > list. + +## Masks with a shape (decided 2026-09-07, not yet built) + +Iris, on the code block's scrolling: "the code block scrolling currently +masks in an inner rectangle. Ideally masks should have a shape +associated with them, rounded rectangle being one of them, and/or +another widget you can select, so that the mask becomes the parent +container with rounded edges. Make sure alpha works properly with it, +eg. on the corners where alpha should be decreased / multiplied." + +**What exists.** `Mask` in `shader.wgsl`/`data.rs` is two `UiSpan`s and +a `move_idx`; `fs_main` resolves it and does `color *= 0.0` outside the +rectangle -- a hard cut on a pixel boundary. `Masked` (`widget/mask.rs`) +sets the painter's mask to its own region. Separately, `draw_rounded_rect` +already produces an anti-aliased rounded edge from +`distance_from_rect(pos, center, corner, radius)` with a half-pixel +`smoothstep`, and the border variant multiplies a second coverage in. + +**Design.** + +1. **A mask is a shape, and the shape is the same SDF the `Rect` + primitive draws with.** `Mask` gains `radius: f32` (one uniform + corner radius, matching `Rect.radius`; per-corner radii only when a + concrete need appears). The fragment stage computes coverage as + `1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))` + -- the *same expression* `draw_rounded_rect` uses, factored into one + function both call -- and does `color.a *= coverage`. So a mask whose + region and radius equal a rounded container's are clipped to exactly + the pixels that container fills, corner alpha included, because they + are the same arithmetic. `radius = 0` becomes a half-pixel + anti-aliased edge instead of today's hard cut, which is what `Rect` + does already, so a masked rect and an unmasked one look the same. +2. **Nested masks chain and multiply, like moves.** Today one + `mask_idx` per primitive; nesting two rectangles could be handled by + intersecting spans on the CPU, but the intersection of two rounded + rectangles is not a rounded rectangle. So `Mask` gains `parent: u32` + (the enclosing mask's slot, or the sentinel), the shader walks the + chain multiplying coverage, and the walk is bounded the way + `resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow + in debug, print the chain). The painter's `set_mask` records the + current mask as the parent. Alpha multiplies rather than takes a + minimum, so a pixel in two feathered corners is dimmed by both -- + that is what "alpha should be multiplied" asks for and what a real + compositor does. +3. **The widget API: the container is the mask.** `Masked` takes a + `MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect` + widget, the thing a code block or card is already inside, gets a + `.masked()` builder that wraps its children in a `Masked` carrying + *its own* radius. One value, by construction, never a radius on the + container and a second one on the mask to keep in sync. The code + block in `transcript-ui/src/row.rs` (`.masked()` at the inner + rectangle) moves to masking at the rounded container instead. +4. **Hit-testing keeps the rectangle.** Input outside the rounded + corner but inside the box is a few pixels; not worth a second SDF + walk on the CPU. State this in the `Masked` doc so nobody "fixes" it. + +**Rejected.** A stencil buffer (a second pass per mask level and no +anti-aliasing); the scissor rectangle (rectangles only, no alpha); +rendering a masked subtree to an offscreen texture and compositing +(a texture allocation per mask, every frame it scrolls, on the phone). + +**Pass conditions.** A headless test draws a rounded container with a +masked child that overhangs all four sides and asserts the child's +coverage at a corner pixel equals the container's own coverage there +(same SDF, so exactly equal, not approximately); a nested-mask test +asserts the product at a pixel inside both feathers; a +`run-headless.sh --phone` screenshot of a scrolled code block shows +rounded corners with no square pixels poking out at the top and bottom +of the scrolled content. Record the commands in RUST.md when it lands. diff --git a/docs/RUST.md b/docs/RUST.md index 1ff8281..8c09760 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -43,6 +43,25 @@ gated on her verdict**, so this pass works the P0 defects and the pure prerequisites in this order. Each item is ticked here by the agent that closes it. +### Queue, 2026-09-07 (orchestrator) + +In order; two builders at a time. Each is ticked here by the agent that +closes it. + +- [ ] Test rig, layers 1 and 2 ("Three test layers" below). Running. +- [ ] Fling parity with Compose, and the phone's keyboard push-up, with + insets shown in the diagnostics overlay. Running, in a worktree. +- [ ] Phone logging through Dev Updater (Iris has no logcat; see + docs/TODO.md and the memory note): research how Dev Updater shows an + app's runtime log, design the smallest route (the app keeps its own + recent log; a debug button copies it; Dev Updater reads it), write + the decision in docs/DECISIONS.md, build it. +- [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided + 2026-09-07)". Rounded masks through the `Rect` SDF, chained and + multiplied, the container as the mask. +- [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` + (docs/TODO.md). Main branch, not rustify. + ### Desktop and phone share the code (Iris, 2026-09-07) Iris plans to develop a desktop app as well, and asked that most code be From e430880cdef060262e68060147eab531493b5233 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:35:20 -0400 Subject: [PATCH 4/7] docs: phone report 2026-09-07, rows at the transcript's top edge culled early or drawn through the header Co-Authored-By: Claude Fable 5.1 --- docs/IRIS_TODO.md | 24 ++++++++++++++++++++++++ docs/RUST.md | 3 +++ 2 files changed, 27 insertions(+) diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 154bd24..47b69da 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -898,3 +898,27 @@ do not duplicate it there. p50 dropping below Compose's on the phone. Do this after the four bench v2 defects (stale primitives, finger fling, decay curve, IME show) are closed, since they are what make the run unrepresentative today. + +## From the phone, 2026-09-07 (build from ed04d4c) + +- [ ] **"Some transcript blocks will be hidden until I uncover enough of + them."** Two screenshots of the bench app's transcript at the top + edge, both wrong in opposite directions: in one, rows scrolled above + the viewport are still drawn and bleed *through* the header bar + (`version = "0.1.0"` and a paragraph visible behind "Run benchmark / + Copy report / Diagnostics"), so the list's mask is not clipping at + the header's bottom edge; in the other, scrolled a little further, + the row that straddles the top edge is not drawn at all -- black from + the header down to "You", where the previous shot showed a paragraph + -- so a row is culled as soon as its *top* leaves the viewport rather + than when its *bottom* does. Suspects: the list's visible-range test + (`iris/src/widget/list.rs`) comparing a row's top against the + viewport top; the mask region for the transcript set from the + window rather than from the area under the header; and the two-phase + provisional/real draw noted in `03c6be8`'s header-duplicate + investigation, which was never root-caused and has the same shape. + Reproduce at layer 1 of the test rig: a headless screen with a row + straddling the top edge must place that row, and a primitive above + the header's bottom must be masked. Fix both with one rule: a row is + drawn if any part of it intersects the viewport, and the viewport is + the list's own region. diff --git a/docs/RUST.md b/docs/RUST.md index 8c09760..c193372 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -51,6 +51,9 @@ closes it. - [ ] Test rig, layers 1 and 2 ("Three test layers" below). Running. - [ ] Fling parity with Compose, and the phone's keyboard push-up, with insets shown in the diagnostics overlay. Running, in a worktree. +- [ ] Rows at the transcript's top edge: culled too early in one state, + drawn through the header in the other (docs/IRIS_TODO.md, 2026-09-07). + First after the rig lands, using its layer-1 harness. - [ ] Phone logging through Dev Updater (Iris has no logcat; see docs/TODO.md and the memory note): research how Dev Updater shows an app's runtime log, design the smallest route (the app keeps its own From 232de0ec53fd3c19b4cafb494af137d148c4fb1d Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:38:19 -0400 Subject: [PATCH 5/7] iris: a phone-shaped desktop window, driven by the same touch recordings Layer 2 of docs/RUST.md's "Three test layers": ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture opens `transcript-fixture`'s screen -- the same fixture and the same fold the headless tests and the Android bench use -- in a window at the phone's own 1080x2424 and `content_scale` 2.55, and screenshots it. 15 seconds, warm. `--replay FILE` drives one of the `.touch` recordings into it and writes `-before.png` too, so "the list moved" is two pictures: the flick carries it back about seven turns of the fixture. Two things this needed. **The desktop backend now lays out in physical pixels with a density, exactly as Android does** (`default::content_scale`, overridable with `IRIS_SCALE`, which is how `--phone` hands it the phone's). It used to divide winit's coordinates into a separate "logical" space, which left `UiRenderState::resize` (physical, from `WindowEvent::Resized`) and the window uniform (logical) disagreeing on any display whose scale factor is not 1.0, and rasterised glyphs at one resolution to show them at another. At 1.0 -- every display here -- the numbers are unchanged, and the `tabs` screenshot is identical. **`rig-input`'s `replay-touch`** puts a gesture on screen. This machine's compositor has no pointer to move: sway runs on the headless backend with no input devices, so `swaymsg seat - cursor press` reports success and `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 not reading libinput, so the virtual-pointer protocol is what is left. It parses the *same* `TouchScript` the harness does, so one recording drives both layers. Co-Authored-By: Claude Fable 5.1 --- iris/Cargo.lock | 41 +++--- iris/Cargo.toml | 10 +- iris/rig-input/Cargo.toml | 32 +++++ iris/rig-input/src/main.rs | 164 ++++++++++++++++++++++ iris/run-headless.sh | 66 ++++++++- iris/src/default/attr.rs | 8 +- iris/src/default/input.rs | 29 ++-- iris/src/default/mod.rs | 45 +++++- iris/src/default/render.rs | 31 ++-- iris/transcript-fixture/examples/phone.rs | 73 ++++++++++ 10 files changed, 437 insertions(+), 62 deletions(-) create mode 100644 iris/rig-input/Cargo.toml create mode 100644 iris/rig-input/src/main.rs create mode 100644 iris/transcript-fixture/examples/phone.rs diff --git a/iris/Cargo.lock b/iris/Cargo.lock index 30f05a8..4be3e00 100644 --- a/iris/Cargo.lock +++ b/iris/Cargo.lock @@ -965,9 +965,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -2875,9 +2875,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -3058,6 +3058,15 @@ version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +[[package]] +name = "rig-input" +version = "0.1.0" +dependencies = [ + "iris", + "wayland-client", + "wayland-protocols-wlr", +] + [[package]] name = "ring" version = "0.17.14" @@ -3906,9 +3915,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -3920,9 +3929,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.10.0", "rustix 1.1.3", @@ -3954,9 +3963,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -3979,9 +3988,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -3992,9 +4001,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -4003,9 +4012,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", diff --git a/iris/Cargo.toml b/iris/Cargo.toml index bdd3481..f747e52 100644 --- a/iris/Cargo.toml +++ b/iris/Cargo.toml @@ -89,7 +89,15 @@ name = "message_list" harness = false [workspace] -members = ["core", "macro", "tabs-ui", "transcript-ui", "transcript-fixture", "desktop-app"] +members = [ + "core", + "macro", + "tabs-ui", + "transcript-ui", + "transcript-fixture", + "rig-input", + "desktop-app", +] # android-app pulls in android-view, which needs the NDK sysroot to link # -- excluded so `cargo build --workspace --all-targets` on the host stays # buildable. Cross-compile it from its own directory (its own single-crate diff --git a/iris/rig-input/Cargo.toml b/iris/rig-input/Cargo.toml new file mode 100644 index 0000000..d63ab26 --- /dev/null +++ b/iris/rig-input/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "rig-input" +version.workspace = true +edition.workspace = true + +# Layer 2's input half (docs/RUST.md's "Three test layers"): replays one +# of the `.touch` files the headless tests use into whatever window is +# under a Wayland compositor, so the *same recording* drives the +# assertion layer and the layer a person looks at. +# +# It exists because this machine's compositor has no pointer to move. +# `run-headless.sh` starts sway on the headless backend with no input +# devices at all (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`), +# so `swaymsg seat - cursor press` reports success and nothing reaches +# the client -- `swaymsg -t get_seats` shows `capabilities: 0`. wlroots +# 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's uinput device would +# be ignored by a compositor that is not reading libinput. The +# virtual-pointer protocol is what is left, and it is a client protocol, +# so it needs no devices and no root. + +# Named for what it does rather than for the crate, since the crate may +# grow a keyboard replay beside it. +[[bin]] +name = "replay-touch" +path = "src/main.rs" + +[dependencies] +# `TouchScript` -- the same parser the harness uses, so a file that +# replays here and one that replays headless can never disagree. +iris = { path = ".." } +wayland-client = "0.31.15" +wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } diff --git a/iris/rig-input/src/main.rs b/iris/rig-input/src/main.rs new file mode 100644 index 0000000..a200b3f --- /dev/null +++ b/iris/rig-input/src/main.rs @@ -0,0 +1,164 @@ +//! Replays a `.touch` file into the compositor as a left-button drag -- +//! see this crate's `Cargo.toml` for why it exists rather than +//! `swaymsg seat - cursor`. +//! +//! WAYLAND_DISPLAY=… replay-touch WIDTH HEIGHT FILE +//! +//! `WIDTH`/`HEIGHT` are the output's own size, because the virtual +//! pointer protocol positions absolutely against an extent rather than +//! in pixels; passing the output size makes a script's coordinates mean +//! the same pixels they mean in the headless tests. +//! +//! Replayed in real time (the sleeps between samples are the gaps in the +//! file), because winit has no timestamp on a pointer event and dates +//! each one when it arrives -- so a 20ms flick has to actually take +//! 20ms here, unlike layer 1 where the sample carries its own time. + +use iris::harness::{TouchAction, TouchScript}; +use std::time::Duration; +use wayland_client::protocol::wl_pointer::ButtonState; +use wayland_client::protocol::{wl_registry, wl_seat}; +use wayland_client::{Connection, Dispatch, QueueHandle, delegate_noop}; +use wayland_protocols_wlr::virtual_pointer::v1::client::{ + zwlr_virtual_pointer_manager_v1::ZwlrVirtualPointerManagerV1, + zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1, +}; + +/// `linux/input-event-codes.h`. The protocol takes the kernel's own +/// button code, not a wayland enum. +const BTN_LEFT: u32 = 0x110; + +/// How long the pointer sits at the gesture's first position before the +/// script starts -- see the comment at the pre-step in `main`. +const SETTLE: Duration = Duration::from_millis(200); + +#[derive(Default)] +struct Globals { + seat: Option, + manager: Option, +} + +impl Dispatch for Globals { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + "zwlr_virtual_pointer_manager_v1" => { + state.manager = Some(registry.bind(name, version.min(2), qh, ())); + } + _ => {} + } + } +} + +delegate_noop!(Globals: ignore wl_seat::WlSeat); +delegate_noop!(Globals: ZwlrVirtualPointerManagerV1); +delegate_noop!(Globals: ZwlrVirtualPointerV1); + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let [width, height, path] = args.as_slice() else { + eprintln!("usage: replay-touch WIDTH HEIGHT FILE"); + std::process::exit(2); + }; + let (width, height) = (parse(width, "WIDTH"), parse(height, "HEIGHT")); + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| fail(&format!("could not read {path}: {e}"))); + let script = TouchScript::parse(&text).unwrap_or_else(|e| fail(&e)); + + let conn = Connection::connect_to_env().unwrap_or_else(|e| { + fail(&format!( + "no wayland display ({e}); is WAYLAND_DISPLAY set?" + )) + }); + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + let display = conn.display(); + display.get_registry(&qh, ()); + let mut globals = Globals::default(); + queue + .roundtrip(&mut globals) + .unwrap_or_else(|e| fail(&format!("wayland roundtrip failed: {e}"))); + + let manager = globals.manager.as_ref().unwrap_or_else(|| { + fail( + "this compositor does not offer zwlr_virtual_pointer_manager_v1, so a pointer cannot \ + be synthesised; sway and every wlroots compositor do", + ) + }); + let pointer = manager.create_virtual_pointer(globals.seat.as_ref(), &qh, ()); + + // Put the pointer where the gesture starts and let the compositor + // settle before anything is pressed. Without this the press is + // dropped: sway has just learned about this pointer, and a button + // sent in the same breath as the motion that first puts it over a + // window arrives before there is a focused surface to send it to -- + // winit sees `CursorEntered`, the moves and the *release*, never the + // press, so the gesture reads as a hover and nothing scrolls. Found + // by printing winit's own events; the settle is what fixed it. + if let Some(first) = script.samples.first() { + pointer.motion_absolute(0, first.pos.x as u32, first.pos.y as u32, width, height); + pointer.frame(); + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + std::thread::sleep(SETTLE); + } + + let mut previous = 0; + for sample in &script.samples { + std::thread::sleep(Duration::from_millis(sample.t_ms - previous)); + previous = sample.t_ms; + let t = sample.t_ms as u32; + pointer.motion_absolute(t, sample.pos.x as u32, sample.pos.y as u32, width, height); + // One frame per sample, so the compositor delivers them as + // separate pointer frames rather than coalescing the whole + // gesture -- the shape the file recorded is the point. + pointer.frame(); + // The button goes in a frame of its own, *after* the motion has + // been committed. Sent in the same frame as the motion that + // first puts the pointer over the window, sway drops it: the + // client sees `CursorEntered` and the moves but never a + // `MouseInput { state: Pressed }`, so the whole gesture reads as + // a hover and nothing scrolls. Found exactly that way, by + // printing winit's events. + let state = match sample.action { + TouchAction::Down => Some(ButtonState::Pressed), + TouchAction::Up | TouchAction::Cancel => Some(ButtonState::Released), + TouchAction::Move => None, + }; + if let Some(state) = state { + pointer.button(t, BTN_LEFT, state); + pointer.frame(); + } + conn.flush() + .unwrap_or_else(|e| fail(&format!("flush: {e}"))); + } + pointer.destroy(); + conn.flush().ok(); +} + +fn parse(text: &str, what: &str) -> u32 { + text.parse() + .unwrap_or_else(|_| fail(&format!("{what} is not a whole number: {text:?}"))) +} + +fn fail(message: &str) -> ! { + eprintln!("replay-touch: {message}"); + std::process::exit(1); +} diff --git a/iris/run-headless.sh b/iris/run-headless.sh index 3daa305..f86062b 100755 --- a/iris/run-headless.sh +++ b/iris/run-headless.sh @@ -3,6 +3,25 @@ # # ./run-headless.sh tabs [-- cargo args] # ./run-headless.sh tabs --shot /tmp/tabs.png --seconds 4 +# ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture +# ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \ +# --shot /tmp/p.png -- -p transcript-fixture +# +# `--phone` is layer 2 of docs/RUST.md's "Three test layers": the output +# and the window take Iris's phone's own size and density (1080x2424 at +# `content_scale` 2.55, from docs/bench/iris-phone-v2-2026-09-06.md, +# carried in `transcript_fixture::PHONE_*`), and `IRIS_SCALE` hands that +# density to iris the way `DisplayMetrics.density` does on Android +# (`iris::default::content_scale`). So a screenshot from here and one +# from the phone are the same layout at the same density, and what +# differs is only the renderer. Without it the output stays desktop- +# shaped, which is what every other example wants. +# +# `--replay FILE` drives one of the `.touch` recordings the headless +# tests use (`iris/transcript-fixture/touch/`) into the window through +# `rig-input`'s `replay-touch` -- one recording, both layers. With +# `--shot` it also writes `-before.png` from just before the +# gesture, since "the list moved" is a claim about two pictures. # # `--bin` runs a real crate binary instead of an example (E4's # `desktop-app`, which is a window a person runs, not a demo) -- @@ -29,19 +48,31 @@ here=$(cd "$(dirname "$0")" && pwd) run="${XDG_RUNTIME_DIR:-/tmp}/iris-headless" seconds=3 shot="" +replay="" example="" kind=example +phone=no + +# The phone Iris runs the bench on. Not typed from memory: these are +# `transcript_fixture::PHONE_WIDTH`/`PHONE_HEIGHT`/`PHONE_SCALE`, which +# in turn come from her own reports -- keep the three in step. +PHONE_MODE=1080x2424@120Hz +PHONE_SCALE=2.55 +DESKTOP_MODE=1920x1200@60Hz while [ $# -gt 0 ]; do case "$1" in --shot) shot=$2; shift 2 ;; --seconds) seconds=$2; shift 2 ;; --bin) kind=bin; shift ;; + --phone) phone=yes; shift ;; + --replay) replay=$2; shift 2 ;; --) shift; break ;; *) example=$1; shift ;; esac done -[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } +[ -n "$example" ] || { echo "usage: $0 NAME [--bin] [--phone] [--replay TOUCH] [--shot PNG] [--seconds N] [-- cargo args]" >&2; exit 2; } +[ -z "$replay" ] || [ -f "$replay" ] || { echo "run-headless: no touch script at $replay" >&2; exit 2; } mkdir -p "$run" export SWAYSOCK="$run/sway.sock" @@ -78,6 +109,27 @@ export WAYLAND_DISPLAY echo "run-headless: $WAYLAND_DISPLAY (sway $(swaymsg -t get_version --raw | sed -n 's/.*"human_readable":"\([^"]*\)".*/\1/p'))" >&2 +# Set every run rather than only when it changes: this compositor is +# reused across runs (see the socket comment above), so a desktop-shaped +# run after a phone-shaped one would otherwise inherit the phone's output +# and silently screenshot the wrong size. +if [ "$phone" = yes ]; then + mode=$PHONE_MODE + export IRIS_SCALE="$PHONE_SCALE" + echo "run-headless: phone-shaped output $PHONE_MODE at IRIS_SCALE=$PHONE_SCALE" >&2 +else + mode=$DESKTOP_MODE +fi +swaymsg output HEADLESS-1 mode "$mode" >/dev/null +# The extent `replay-touch` positions against, so a script's coordinates +# are the output's own pixels. +out_w=${mode%x*} +out_h=${mode#*x}; out_h=${out_h%@*} + +# Built before the app starts, so a compile error is not reported as a +# window that failed to move. +[ -z "$replay" ] || cargo build --bin replay-touch -p rig-input >&2 + cd "$here" if [ "$kind" = bin ]; then cargo build --bin "$example" "$@" >&2 @@ -111,6 +163,18 @@ while [ $i -lt "$((seconds * 2))" ]; do i=$((i + 1)); sleep 0.5 done +if [ -n "$replay" ] && kill -0 "$pid" 2>/dev/null; then + if [ -n "$shot" ]; then + grim "${shot%.png}-before.png" + echo "run-headless: wrote ${shot%.png}-before.png (before the gesture)" >&2 + fi + "$here/target/debug/replay-touch" "$out_w" "$out_h" "$replay" + # A fling outlives the finger: the gesture's own last sample is not + # when the list stops. Long enough for Android's spline to settle + # (`FlingCalculator::duration` tops out around a second and a half). + sleep 2 +fi + if kill -0 "$pid" 2>/dev/null; then [ -n "$shot" ] && grim "$shot" && echo "run-headless: wrote $shot" >&2 kill "$pid" 2>/dev/null || true diff --git a/iris/src/default/attr.rs b/iris/src/default/attr.rs index d089d45..9d7484d 100644 --- a/iris/src/default/attr.rs +++ b/iris/src/default/attr.rs @@ -1,5 +1,5 @@ use crate::prelude::*; -use winit::dpi::{LogicalPosition, LogicalSize}; +use winit::dpi::{PhysicalPosition, PhysicalSize}; impl FocusHost for T { fn recent_click(&mut self) -> bool { @@ -18,9 +18,11 @@ 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( - LogicalPosition::::from(region.top_left.tuple()), - LogicalSize::::from(region.size().tuple()), + PhysicalPosition::::from(region.top_left.tuple()), + PhysicalSize::::from(region.size().tuple()), ); } } diff --git a/iris/src/default/input.rs b/iris/src/default/input.rs index 61cfa2e..45d2297 100644 --- a/iris/src/default/input.rs +++ b/iris/src/default/input.rs @@ -17,15 +17,14 @@ pub struct Input { } impl Input { - /// `scale_factor` converts winit's physical-pixel event coordinates - /// into the same logical units `UiRenderNode`'s window uniform now uses - /// (`default::render::UiRenderer::new`'s doc comment) -- without it, - /// a cursor position and the widget tree it's tested against would be - /// in two different units on any monitor whose scale factor isn't 1.0. - pub fn event(&mut self, event: &WindowEvent, scale_factor: f32) -> bool { + /// winit's pointer coordinates are physical pixels, which is the + /// space the whole tree is laid out and hit-tested in -- see + /// `default::content_scale`. Nothing is converted here; `dp(...)` + /// resolves against the density at layout time instead. + pub fn event(&mut self, event: &WindowEvent) -> bool { match event { WindowEvent::CursorMoved { position, .. } => { - self.cursor.pos = Vec2::new(position.x as f32, position.y as f32) / scale_factor; + self.cursor.pos = Vec2::new(position.x as f32, position.y as f32); self.cursor.exists = true; self.cursor.time = Instant::now(); } @@ -43,9 +42,7 @@ impl Input { WindowEvent::MouseWheel { delta, .. } => { let mut delta = match *delta { MouseScrollDelta::LineDelta(x, y) => Vec2::new(x, y), - MouseScrollDelta::PixelDelta(pos) => { - Vec2::new(pos.x as f32, pos.y as f32) / scale_factor - } + MouseScrollDelta::PixelDelta(pos) => Vec2::new(pos.x as f32, pos.y as f32), }; if delta.x == 0.0 && self.modifiers.shift { delta.x = delta.y; @@ -83,14 +80,12 @@ impl Input { } impl DefaultUiState { + /// Physical pixels, matching `WindowEvent::Resized` (what + /// `UiRenderState::resize` is given) and the swapchain -- see + /// `default::content_scale`. pub fn window_size(&self) -> Vec2 { - let window = self.renderer.window(); - let size = window.inner_size(); - let scale_factor = window.scale_factor() as f32; - Vec2::new( - size.width as f32 / scale_factor, - size.height as f32 / scale_factor, - ) + let size = self.renderer.window().inner_size(); + Vec2::new(size.width as f32, size.height as f32) } pub fn cursor_state(&self) -> &CursorState { diff --git a/iris/src/default/mod.rs b/iris/src/default/mod.rs index d8b6750..5b62373 100644 --- a/iris/src/default/mod.rs +++ b/iris/src/default/mod.rs @@ -25,6 +25,38 @@ pub use render::*; pub type Proxy = EventLoopProxy; +/// The desktop's `content_scale`: physical pixels per dp, the same +/// quantity Android reads from `DisplayMetrics.density` and feeds to +/// `UiRenderState::set_density` (`android::view::AndroidUiState:: +/// content_scale`'s field comment). Everything in this backend is +/// physical pixels -- the window size, the pointer, the widget tree -- +/// and `dp(...)` is what resolves against this at layout time, exactly +/// as on the phone. That is a correction from an earlier version that +/// divided winit's coordinates into a separate "logical" space instead: +/// it left `UiRenderState::resize` (physical, from `WindowEvent:: +/// Resized`) and the window uniform (logical) disagreeing on any +/// display whose scale factor is not 1.0, and it rasterised glyphs at +/// one resolution to display them at another -- the blur the phone's own +/// stopgap produced before `dp` existed. +/// +/// **`IRIS_SCALE` overrides it**, which is how a phone-shaped desktop +/// window runs the phone's density (`run-headless.sh --phone`, +/// docs/RUST.md's layer 2). An unparsable value is a typo in a command +/// somebody just typed, so it says so and uses the window's own answer +/// rather than silently laying out at the wrong density. +pub fn content_scale(window: &Window) -> f32 { + match std::env::var("IRIS_SCALE") { + Err(_) => window.scale_factor() as f32, + Ok(text) => match text.trim().parse::() { + Ok(scale) if scale > 0.0 => scale, + _ => { + log::warn!("IRIS_SCALE={text:?} is not a positive number; using the window's own"); + window.scale_factor() as f32 + } + }, + } +} + pub struct DefaultUiState { pub root: Option, pub renderer: UiRenderer, @@ -214,8 +246,16 @@ impl AppState for DefaultApp { window.set_visible(true); let default_state = DefaultUiState::new(window, access_adapter); let (mut rsc, task_recv) = DefaultRsc::init(default_state.window.clone()); + // Both copies of the density, set before the first widget is + // built so text shapes at the right size on the opening frame -- + // the same pair `android::view::new_peer` sets from + // `content_scale`. See `iris_core::TextData::density` for why the + // shaper keeps its own. + let scale = content_scale(default_state.window.as_ref()); + rsc.ui.text.density = scale; let state = State::new(default_state, &mut rsc, proxy); - let render = UiRenderState::new(); + let mut render = UiRenderState::new(); + render.set_density(scale); Self { rsc, state, @@ -247,8 +287,7 @@ impl AppState for DefaultApp { ui_state .access_adapter .process_event(&ui_state.window, &event); - let scale_factor = ui_state.renderer.window().scale_factor() as f32; - let input_changed = ui_state.input.event(&event, scale_factor); + let input_changed = ui_state.input.event(&event); let cursor_state = ui_state.cursor_state().clone(); let old = ui_state.focus; if cursor_state.buttons.left.is_start() { diff --git a/iris/src/default/render.rs b/iris/src/default/render.rs index d0cc245..9cac43d 100644 --- a/iris/src/default/render.rs +++ b/iris/src/default/render.rs @@ -66,13 +66,11 @@ impl UiRenderer { self.config.width = size.width; self.config.height = size.height; self.surface.configure(&self.device, &self.config); - // Logical, matching `new`'s own seed -- see the comment there. - let scale_factor = self.window.scale_factor() as f32; - let logical = Vec2::new( - size.width as f32 / scale_factor, - size.height as f32 / scale_factor, + // 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, ); - self.ui.resize(logical, &self.queue); } fn create_encoder(device: &Device) -> CommandEncoder { @@ -162,21 +160,12 @@ impl UiRenderer { // by:" chain as the message, since `UiRenderNode::new` returns it // rather than letting wgpu's own default handler panic first (see // that function's doc comment). - // Logical size (physical / `scale_factor`), matching what the - // Android backend now reports too (`android::render:: - // AndroidRenderer::new`, `content_scale`) -- the swapchain still - // configures at the real physical resolution above; only the - // window uniform layout/hit-testing agree on is scaled. Without - // this a window on any monitor whose scale factor isn't 1.0 would - // have the identical "everything too small" bug RUST.md's P0 box - // found on Iris's phone, just never noticed here because this - // crate's own dev monitors happen to run at 1.0. - let scale_factor = window.scale_factor() as f32; - let logical_size = Vec2::new( - size.width as f32 / scale_factor, - size.height as f32 / scale_factor, - ); - let ui = UiRenderNode::new(&device, &queue, &config, logical_size) + // Physical size, the same units the swapchain, `WindowEvent:: + // Resized`, the pointer and the widget tree all use -- see + // `default::content_scale` for why this backend stopped dividing + // into a separate logical space, and what disagreed while it did. + let physical_size = Vec2::new(size.width as f32, size.height as f32); + let ui = UiRenderNode::new(&device, &queue, &config, physical_size) .expect("Could not create iris render node!"); Self { diff --git a/iris/transcript-fixture/examples/phone.rs b/iris/transcript-fixture/examples/phone.rs new file mode 100644 index 0000000..4e26974 --- /dev/null +++ b/iris/transcript-fixture/examples/phone.rs @@ -0,0 +1,73 @@ +//! Layer 2 of docs/RUST.md's "Three test layers": the fixture-backed +//! transcript screen in a phone-shaped window, for looking at. +//! +//! iris/run-headless.sh phone --phone --shot /tmp/phone.png -- -p transcript-fixture +//! +//! `--phone` sets the headless sway output to the phone's own 1080x2424 +//! and exports `IRIS_SCALE=2.55`, so this draws at the density Iris's +//! phone reports (`transcript_fixture::PHONE_SCALE`) rather than the +//! desktop's 1.0 -- same screen, same fixture and the same folding as +//! the Android bench and the headless tests, so what differs between a +//! screenshot here and one from the phone is the renderer, never the +//! data. +//! +//! No server: `transcript-fixture` embeds the transcript. Colour, +//! spacing, type and anything a person has to *see* is answered here; +//! anything with an assertion behind it belongs in `tests/ +//! phone_screen.rs` one layer down. + +use iris::prelude::*; +use winit::{dpi::PhysicalSize, window::WindowAttributes}; + +fn main() { + DefaultApp::::run(); +} + +#[derive(DefaultUiState)] +pub struct Client { + ui_state: DefaultUiState, + #[allow(dead_code)] + screen: Option, +} + +impl DefaultAppState for Client { + fn window_attributes() -> WindowAttributes { + WindowAttributes::default() + .with_title("iris transcript (bench fixture)") + .with_inner_size(PhysicalSize::new( + transcript_fixture::PHONE_WIDTH, + transcript_fixture::PHONE_HEIGHT, + )) + } + + fn new( + mut ui_state: DefaultUiState, + rsc: &mut DefaultRsc, + _: Proxy, + ) -> Self { + let screen = match transcript_fixture::open(rsc, &mut ui_state) { + Ok(opened) => { + // A fling coasts only while something asks for the next + // frame; on the desktop that is the window's own redraw + // request (`List::fling`'s doc). + let handle = rsc.tasks.redraw_handle(); + (opened.screen.list)(rsc).set_redraw_handle(handle); + Some(opened.screen) + } + // On screen rather than a panic: this window exists to be + // looked at, and "the fixture stopped folding" is something + // to read, not a process that vanished (UI_RULES.md). + Err(message) => { + let text = wtext(format!("Couldn't fold the bench fixture: {message}")) + .color(Color::WHITE) + .wrap(true) + .pad(dp(16)) + .add_strong(rsc) + .any(); + ui_state.set_root(text); + None + } + }; + Self { ui_state, screen } + } +} From 1121d7cc839c9ebf546fc3f7c4a24a4062668c8c Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:38:55 -0400 Subject: [PATCH 6/7] docs/LAYOUT.md: masks reference a drawn primitive instead of copying a shape, and hit-testing applies the shape (Iris, 2026-09-07) Co-Authored-By: Claude Fable 5.1 --- docs/IRIS.md | 37 +++++++++++++++++++ docs/LAYOUT.md | 99 +++++++++++++++++++++++++++++++------------------- docs/RUST.md | 5 ++- 3 files changed, 101 insertions(+), 40 deletions(-) diff --git a/docs/IRIS.md b/docs/IRIS.md index 8dd1e74..7ed31af 100644 --- a/docs/IRIS.md +++ b/docs/IRIS.md @@ -8,6 +8,43 @@ capability that moved. Small and trivial changes do not go here. An entry gives the date, what changed, why, and a short before/after where it helps judge the change without the session that made it. Newest first. +## 2026-09-07: a headless harness, replayed touch, and physical-pixel desktop layout + +Layer 1 and 2 of docs/RUST.md's "Three test layers". + +**New: `iris::harness`** -- a screen driven in-process with no window, no +compositor and no GPU, on a clock the caller advances. `Harness::new(size, +density)` gives you an `Rsc`, a `UiRenderState` and a state that +implements `FocusHost`/`OpenUrl` by *recording* what the platform was +asked for (`keyboard_shown`, `opened_urls`) rather than doing it; +`frame(t_ms)`/`frames_until(..)` run frames, `touch(action, pos, t_ms)` +feeds one pointer sample the way Android's `on_touch_event` does, and +`replay(&TouchScript)` runs a whole recorded gesture. `TouchScript` parses +a plain `t_ms action x y` file (`down`/`move`/`up`/`cancel`), so the +batched 120Hz flick shape your phone actually delivers is a file that +`cargo test` can replay -- something the emulator cannot produce at all. + +**New: `List::fling_velocity() -> Option`**, what the release +measured, readable where it landed rather than by re-timing the gesture. + +**Changed: `List` starts a fling's curve at its first `tick_fling`, not +at the release.** The only clock it reads is now the one its driver hands +it; in a running app the difference is at most a frame. + +**Changed: the desktop backend lays out in physical pixels with a +density, exactly as Android does.** `iris::default::content_scale(window)` +is the desktop's `content_scale` -- winit's scale factor, overridable with +the `IRIS_SCALE` environment variable -- and it now feeds +`UiRenderState::set_density`/`TextData::density` instead of dividing +coordinates into a separate "logical" space. That division had +`UiRenderState::resize` (physical) and the window uniform (logical) +disagreeing on any display whose scale factor is not 1.0, and rasterised +glyphs at one resolution to display them at another. `Input::event` lost +its `scale_factor` parameter as a result, and `DefaultUiState:: +window_size()` now answers physical pixels. On a 1.0 display nothing +changes. The override is what lets `run-headless.sh --phone` open a window +at your phone's own 1080x2424 and 2.55. + ## 2026-09-07: widgets can animate, and a fling finally moves Iris's phone said "fling still doesn't work" twice. The velocity was only diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 5c71a77..83db8d2 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -965,43 +965,62 @@ already produces an anti-aliased rounded edge from `distance_from_rect(pos, center, corner, radius)` with a half-pixel `smoothstep`, and the border variant multiplies a second coverage in. -**Design.** +**Design** (revised the same day on Iris's two corrections: hit-testing +applies the shape too, and a mask should reference a primitive rather +than carry a copy of its shape). -1. **A mask is a shape, and the shape is the same SDF the `Rect` - primitive draws with.** `Mask` gains `radius: f32` (one uniform - corner radius, matching `Rect.radius`; per-corner radii only when a - concrete need appears). The fragment stage computes coverage as - `1.0 - smoothstep(-min(edge, radius), edge, distance_from_rect(...))` - -- the *same expression* `draw_rounded_rect` uses, factored into one - function both call -- and does `color.a *= coverage`. So a mask whose - region and radius equal a rounded container's are clipped to exactly - the pixels that container fills, corner alpha included, because they - are the same arithmetic. `radius = 0` becomes a half-pixel - anti-aliased edge instead of today's hard cut, which is what `Rect` - does already, so a masked rect and an unmasked one look the same. -2. **Nested masks chain and multiply, like moves.** Today one - `mask_idx` per primitive; nesting two rectangles could be handled by - intersecting spans on the CPU, but the intersection of two rounded - rectangles is not a rounded rectangle. So `Mask` gains `parent: u32` - (the enclosing mask's slot, or the sentinel), the shader walks the - chain multiplying coverage, and the walk is bounded the way - `resolve_move` is (`MOVE_CHAIN_LIMIT`'s sibling; assert on overflow - in debug, print the chain). The painter's `set_mask` records the - current mask as the parent. Alpha multiplies rather than takes a - minimum, so a pixel in two feathered corners is dimmed by both -- - that is what "alpha should be multiplied" asks for and what a real - compositor does. -3. **The widget API: the container is the mask.** `Masked` takes a - `MaskShape` (`Rect`, `Rounded(radius)`); and the rounded `Rect` - widget, the thing a code block or card is already inside, gets a - `.masked()` builder that wraps its children in a `Masked` carrying - *its own* radius. One value, by construction, never a radius on the - container and a second one on the mask to keep in sync. The code - block in `transcript-ui/src/row.rs` (`.masked()` at the inner - rectangle) moves to masking at the rounded container instead. -4. **Hit-testing keeps the rectangle.** Input outside the rounded - corner but inside the box is a few pixels; not worth a second SDF - walk on the CPU. State this in the `Masked` doc so nobody "fixes" it. +1. **A mask is a reference to a primitive already drawn, plus how to + use it.** `Mask { kind, idx, flags, parent }`: the primitive's + binding (`RECT`, `TEXTURE`, `GLYPH`) and slot, flags (today one: + *alpha only* -- take the primitive's coverage and ignore its colour, + which is the default and the only mode until a need for another + appears), and the enclosing mask's slot for nesting. The fragment + stage evaluates the referenced primitive *at the masked pixel* -- + for a `Rect`, the same `draw_rounded_rect` coverage from the same + SDF; for a texture or glyph, the sampled alpha -- and does + `color.a *= coverage`. Nothing about the shape is copied: a rounded + container's corner and its children's clipped corner are the same + primitive's arithmetic, and a texture mask (an alpha image as the + clip) works with no new shader path. + What this needs from the data layout: evaluating a primitive at an + arbitrary pixel means its placement (its spans and `move_idx`, today + vertex attributes) has to be readable from a storage buffer in the + fragment stage. If it is not already there, put it there once, for + every primitive, rather than keeping a second copy for masks -- the + vertex stage can read the same buffer. Textures: the shader binds one + image at a time (see `masks_layout`'s comment on why an image's own + bind group must not name the masks buffer), so a texture mask is + limited to what the fragment can sample without a bind-group switch: + the atlas, and the primitive's own bound image when the masked + primitive is drawn in the same image's batch. Say so at the flag. +2. **Nested masks chain and multiply, like moves.** `parent` walks up + the chain, bounded like `resolve_move` (`MOVE_CHAIN_LIMIT`'s sibling; + debug-assert on overflow and print the chain); coverages multiply, + so a pixel inside two feathered corners is dimmed by both, which is + what a compositor does and what "alpha should be multiplied" asks. +3. **`.masked()` points the mask at the current widget's own + primitives.** `Masked` stops describing a region: it records which + primitive(s) the wrapping widget drew this frame (the painter knows + -- it just allocated the slots) and sets the mask to reference them. + So a rounded `Rect` widget's `.masked()` clips its children to + itself by pointing at the rect it already draws; an image widget's + `.masked()` clips to its alpha. No radius or shape argument exists to + fall out of sync. When a widget draws more than one primitive (a + bordered rect is one primitive; a card with a stripe is two), the + mask references the *first* and the doc says so; a widget that wants + another names it. +4. **Hit-testing applies the shape.** A press is inside a masked + subtree only if the mask's coverage at that point is above one half. + For a `Rect` that is the same rounded-rect SDF evaluated on the CPU + -- one function in the shared crate, with the WGSL a transliteration + of it and a test that compares the two at a grid of points + (`headless` renders to a buffer and reads back, or the Rust version + is checked against the values the shader produced once and recorded). + For a texture, the CPU needs the alpha: keep the alpha channel of an + image used as a mask readable on the CPU (it was uploaded from CPU + memory; keeping the alpha plane is a quarter of the image), and read + it at the point. A masked corner that cannot be tapped and a masked + corner that is not drawn are then the same corner. **Rejected.** A stencil buffer (a second pass per mask level and no anti-aliasing); the scissor rectangle (rectangles only, no alpha); @@ -1011,8 +1030,12 @@ rendering a masked subtree to an offscreen texture and compositing **Pass conditions.** A headless test draws a rounded container with a masked child that overhangs all four sides and asserts the child's coverage at a corner pixel equals the container's own coverage there -(same SDF, so exactly equal, not approximately); a nested-mask test -asserts the product at a pixel inside both feathers; a +(same primitive evaluated, so exactly equal, not approximately); a +nested-mask test asserts the product at a pixel inside both feathers; a +texture-mask test clips a rect to an alpha image and asserts a +transparent texel masks fully; a hit-test asserts a press in a +container's clipped corner misses and one just inside the curve hits, +and that the CPU SDF and the shader agree at a grid of points; a `run-headless.sh --phone` screenshot of a scrolled code block shows rounded corners with no square pixels poking out at the top and bottom of the scrolled content. Record the commands in RUST.md when it lands. diff --git a/docs/RUST.md b/docs/RUST.md index c193372..f6445d1 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -60,8 +60,9 @@ closes it. recent log; a debug button copies it; Dev Updater reads it), write the decision in docs/DECISIONS.md, build it. - [ ] Masks with a shape -- docs/LAYOUT.md "Masks with a shape (decided - 2026-09-07)". Rounded masks through the `Rect` SDF, chained and - multiplied, the container as the mask. + 2026-09-07)". A mask references a primitive already drawn + (rect SDF, texture or glyph alpha), chained and multiplied; `.masked()` + points at the widget's own primitives; hit-testing applies the shape. - [ ] Compose app: the `Reversed range` crash in `ToolInput.highlighted` (docs/TODO.md). Main branch, not rustify. From 038f6a3832c1b2878411a89fc8b626365cfb240f Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 7 Sep 2026 12:39:53 -0400 Subject: [PATCH 7/7] docs: the test rig's layers 1 and 2, with their commands and their limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUST.md's "Three test layers" section rewritten in place with what was built: the `cargo test -p transcript-fixture` command and the five assertions with the mutation that fails each, the `run-headless.sh --phone [--replay …]` commands and the 15s/18s they take, and a paragraph on what still cannot be answered below layer 3 (anything about pixels, any frame time, anything JNI). Also the two traps that cost time -- `swaymsg seat - cursor` reaching nothing on a compositor with no input devices, and a leftover window tiling beside the new one so a screenshot looks like a duplicated-primitive bug. IRIS.md gains the public surface: `iris::harness`, `TouchScript`, `List::fling_velocity`, the fling's clock, and the desktop backend's move to physical-pixel layout with `content_scale`/`IRIS_SCALE`. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 14 +++++++ docs/RUST.md | 114 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 95 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3481aa3..20786bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,6 +283,20 @@ Each exists because something was invisible without it. checkout's own emulator, taps "Run benchmark" by label, and prints the report -- written so the P0 build/install/tap/read-report cycle stops being retyped by hand each time (docs/RUST.md's P0 box). +- **iris's three test layers** (docs/RUST.md's "Three test layers" has + the commands and what each cannot answer): test at the cheapest one + that can answer the question. `cargo test -p transcript-fixture` runs + the real transcript screen over the bench fixture with **no window, no + compositor and no GPU** (`iris::harness`), on a clock the test owns and + a gesture replayed from a `t_ms action x y` file under + `iris/transcript-fixture/touch/` -- which is how the batched 120Hz + flick a finger actually makes is testable at all, since a `ui-trace` + swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone + --shot …` opens the same screen in a window at the phone's own size and + density for looking at, and `--replay FILE` drives the same recording + into it. The emulator is for JNI, the IME, insets, the surface + lifecycle and one verification run before a build goes to the phone -- + not for iterating on layout. ### Driving the UI diff --git a/docs/RUST.md b/docs/RUST.md index f6445d1..75874df 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -48,7 +48,7 @@ closes it. In order; two builders at a time. Each is ticked here by the agent that closes it. -- [ ] Test rig, layers 1 and 2 ("Three test layers" below). Running. +- [x] Test rig, layers 1 and 2 ("Three test layers" below), landed 2026-09-07. - [ ] Fling parity with Compose, and the phone's keyboard push-up, with insets shown in the diagnostics overlay. Running, in a worktree. - [ ] Rows at the transcript's top edge: culled too early in one state, @@ -93,7 +93,7 @@ The bench client (`android-app/src/bench_client.rs`, ~1000 lines) is the first thing to look at moving, since a desktop bench on the same fixture is layer 2 of the test rig below. -### Three test layers, cheapest first (decided 2026-09-07, rig not yet built) +### Three test layers, cheapest first (decided 2026-09-07; layers 1 and 2 built the same day) Iris's suggestion, adopted and layered: test at the cheapest layer that can answer the question, and go up only when it cannot. The emulator @@ -101,43 +101,91 @@ costs minutes a cycle; the desktop window seconds; the headless harness runs inside `cargo test`. 1. **Headless, in-process, no compositor and no GPU -- the default.** - `iris/src/layout_tests.rs` already builds a tree over `UiRenderState` - with no window, `sense.rs`'s gesture tests feed fabricated - `CursorState`s with their own times, and `List::tick_fling` is - driven by hand. Extend that into one harness that opens - `transcript-ui`'s screen on `app/bench-fixture` (as `bench_client.rs` - does on Android, no server), at the phone's logical size and - `content_scale` from `docs/bench/iris-phone-v2-2026-09-06.md` (2.55, - 120Hz -- read, never typed from memory), ticks frames, and feeds a - **replayed touch stream** from a trivial file of `(t_ms, action, x, - y)` lines with `cursor.time` taken from the file. That is what the - emulator cannot do at all: the batched 120Hz flick from the phone - report becomes a deterministic test asserting on scroll offset and on - the `iris drag release:` velocity. Anything about layout, scroll - position, selection, focus or fold state is answered here, with - assertions rather than eyes. Nothing renders; a widget's placed - rectangle is the evidence. + `iris::harness` (`iris/src/harness.rs`), plus the fixture crate it + opens. `Harness::new(size, density)` builds an `Rsc`, a + `UiRenderState` and a state whose `FocusHost`/`OpenUrl` *record* what + the platform was asked for; `frame(t_ms)`/`frames_until(..)` run + frames on a clock the test owns, and `replay(&TouchScript)` feeds a + recorded gesture one sample at a time exactly as + `IrisViewPeer::on_touch_event` replays Android's historical samples. + The recordings are plain `t_ms action x y` files under + `iris/transcript-fixture/touch/`, and `flick-120hz.touch` is the + phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total. + + cd iris && cargo test -p transcript-fixture + + runs in about a second and asserts (a) the flick releases with a real + velocity (`List::fling_velocity`, which only `Released(Some(v))` + fills), (b) the list travels and settles inside the AOSP spline's own + `FlingCalculator::duration`, (c) a tap moves nothing and opens no + link, (d) a long-press-then-drag leaves selected text and does not + pan, and (e) the composer clears a simulated 1000px IME inset + (`Composer::set_bottom_inset`). Each was confirmed to fail without + its subject rather than assumed: dropping `animate(id)` from + `Selection::drag` -- the phone's own "fling does nothing" defect -- + and starting the fling curve at the wall clock each fail only the + flick test; flinging on `Tapped` fails only the tap test; a 5s + `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that + ignores its argument fails only the composer test. + + **What still cannot be answered below layer 3**: nothing renders + here, so anything about pixels -- glyph rasterisation, the atlas, + stale or duplicated primitives, colour, the surface lifecycle, the + renderer rebuild -- is invisible to layer 1 and only *looked at* in + layer 2. Frame *times* are not measurable at either: layer 1 does no + GPU work at all and layer 2 runs a debug build on this VM's virtio + GPU, so a number from either is not the phone's. Anything JNI (the + IME, real insets, the clipboard, battery) is layer 3 by construction: + layer 1 records that the platform was asked and layer 2 has no + Android platform to ask. + 2. **A phone-shaped desktop window under headless sway -- for looking.** - `iris/run-headless.sh` already runs a winit binary under a private - sway and screenshots it with `grim`. Add a `--phone` mode (output and - window at the phone's size and scale, with the scale reaching iris - the way Android's does so dp layout runs at that density) and - touch-shaped mouse input: a left-button drag pans and flings through - `DragArbiter`, long-press selects, no hover. One input path, not a - parallel one (`CODE_RULES`). Colour, spacing, text and anything a - person has to see is answered here. `swaymsg seat - cursor - move/press/release` drives it when a gesture is needed on screen. + + cd iris && ./run-headless.sh phone --phone --shot /tmp/p.png -- -p transcript-fixture + + About 15 seconds warm. `--phone` sets the private sway output to + 1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the + way `DisplayMetrics.density` does on Android + (`iris::default::content_scale`) -- the desktop backend now lays out + in physical pixels with a density instead of dividing into a separate + logical space, so both platforms run one path. `transcript-fixture`'s + `phone` example opens the same screen from the same bytes as layer 1 + and the Android bench. + + A gesture on screen uses the *same recordings*: + + ./run-headless.sh phone --phone --replay transcript-fixture/touch/flick-120hz.touch \ + --shot /tmp/p.png -- -p transcript-fixture + + writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick; + looked at 2026-09-07, the list moved back about seven turns of the + fixture and settled. + + **`swaymsg seat - cursor` cannot drive it, and that cost an hour.** + This compositor runs the headless backend with no input devices + (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor + commands all report `success` and nothing whatever reaches the + client, with `swaymsg -t get_seats` showing `capabilities: 0` as the + only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's + uinput device would be ignored by a compositor that is not reading + libinput. `iris/rig-input`'s `replay-touch` uses the + **virtual-pointer protocol** instead, which is a client protocol and + needs neither devices nor root, and it parses `iris::harness`'s own + `TouchScript`. Two traps inside it, both found by printing winit's + events: a button sent in the same frame as the motion that first puts + the pointer over the window is dropped (the client sees the enter, + the moves and the *release*, never the press), so the pointer is + positioned and left to settle 200ms first; and a leftover window from + an earlier manual run **tiles beside the new one**, halving the width + and producing a screenshot that looks exactly like a duplicated- + primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af + examples/phone` are the check. + 3. **The Android emulator -- platform plumbing and the final pass.** JNI, IME, insets, surface lifecycle, the renderer rebuild, and one verification run before a build goes to the phone. Not for iterating on layout. -Pass condition for the rig: `cargo test` runs a fixture-backed headless -transcript screen with a replayed flick and asserts a nonzero release -velocity and a moved scroll offset; one command opens the same screen in -a phone-shaped window and screenshots it. Record the commands here when -it lands. - ### The 22:16 phone report, worked 2026-09-06/07 Iris's four items are listed in docs/IRIS_TODO.md's "From the phone,