Files
iris/src/layout_tests.rs
T
irisandClaude Opus 5 a9312e9431 iris is the framework alone; the app is one crate in app-rust/
Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:36:38 -04:00

1080 lines
42 KiB
Rust

//! Pass conditions for LAYOUT.md section 8, exercised as plain unit tests
//! rather than through `run-headless.sh`: `UiRenderState` and `Widgets` do
//! not touch a GPU or a window, so a tree can be built and driven directly.
//! No GPU-backed rendering (`UiRenderNode`) is exercised here -- only the
//! CPU-side layout/move machinery LAYOUT.md is about.
use crate::prelude::*;
/// The minimal `UiRsc` a test needs: just the shared `UiData`, none of the
/// event/window/state plumbing `DefaultRsc` carries. `pub(crate)` so
/// `access_tests.rs` (I4, RUST.md) can reuse it rather than keeping a
/// second copy of the same harness.
pub(crate) struct TestRsc {
pub(crate) ui: UiData,
}
impl UiRsc for TestRsc {
fn ui(&self) -> &UiData {
&self.ui
}
fn ui_mut(&mut self) -> &mut UiData {
&mut self.ui
}
}
/// A `ScrollArea` over a `Span` of `n` fixed-height rects -- N primitives large
/// enough that an O(N) regression in the move path would show up as a
/// non-trivial counter rather than being lost in noise (LAYOUT.md section
/// 8, condition 3, using rects rather than glyphs to avoid pulling the font
/// stack into a plain unit test). Returns the scroll widget (weak, for
/// mutating it later), the erased root to draw, and the rows (weak, for
/// hit-testing one of them).
fn scrolled_rects(
rsc: &mut TestRsc,
n: usize,
) -> (WeakWidget<ScrollArea>, StrongWidget, Vec<WeakWidget<Rect>>) {
let mut span = Span::empty(Dir::DOWN);
let mut rects = Vec::with_capacity(n);
for _ in 0..n {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
// Each row gets a fixed height so the span's total content is
// genuinely taller than the viewport -- rest-sized rows would just
// divide whatever space is offered and never need scrolling.
let row = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(10.0)),
});
span.push(row.any());
}
let span = rsc.ui.widgets.add_strong(span);
let scroll = rsc
.ui
.widgets
// Anchored at the *start*: every test below scrolls down from
// the top and states its sign convention against that. An
// end-anchored area now sits at its end from its first drawn
// frame (`Scroll::draw` measures and places in the same frame),
// so `Pin::End` here would mean scrolling down from a
// position that is already the bottom -- a clamped no-op, which
// reads as "the move path is broken" rather than as the test
// starting somewhere it did not mean to.
.add_strong(ScrollArea::new(span.any(), Axis::Y, Pin::Start));
let weak = scroll.weak();
(weak, scroll.any(), rects)
}
#[test]
fn an_unchanged_frame_draws_and_rewrites_nothing() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 20000.0));
render.update(&root, &mut rsc);
// Two, not one: the first offers `ScrollArea`'s content the container's
// own length as a placeholder (nothing has been measured yet) and
// `Scroll::draw` asks to be drawn again once it knows the real one,
// which the second update is. Only after that is the tree settled --
// see `scrolling_moves_in_o1_without_a_redraw`'s own note on the
// same first draw.
render.update(&root, &mut rsc);
render.take_counters(); // discard the first, real draws
render.update(&root, &mut rsc);
let (draws, rewrites, moves, _shapes) = render.take_counters();
assert_eq!((draws, rewrites, moves), (0, 0, 0));
}
#[test]
fn scrolling_moves_in_o1_without_a_redraw() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, _rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// The first draw offers `ScrollArea`'s content a zero-height region
// (nothing has been measured yet) and learns the real content length
// from what comes back; `update()` only redraws widgets actually
// marked dirty, so that corrected length is not reflected in the
// content's own *active* region until something -- here a no-op
// scroll tick -- actually asks `ScrollArea` to redraw again. Only after
// that warm-up does the content's offered size stop changing between
// draws, which is what makes a further, real scroll tick a same-size
// move instead of a resize. See scroll.rs.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
render.take_counters();
// Negative: `scroll`'s sign convention subtracts from `amt`, and
// `amt` starts at (and is clamped to) 0 at the top of the content, so
// a *positive* argument here would be scrolling further up (a no-op,
// already clamped) rather than actually moving anything.
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let (draws, _rewrites, moves, _shapes) = render.take_counters();
// The pass condition (LAYOUT.md section 8, condition 3) is 0 draws and
// 1 move_offsets write, independent of how many rects are in the
// scrolled subtree. `draws` here is exactly 1: `ScrollArea` itself is
// marked dirty by `scroll()` and its own body is cheap arithmetic with
// no primitives of its own, so it is the one real `Widget::draw` this
// counts -- the 500 rects underneath move via the O(1) chain and are
// never revisited.
assert_eq!(draws, 1, "only Scroll itself should redraw");
assert_eq!(moves, 1, "the scrolled subtree should move in one write");
}
#[test]
fn hit_testing_follows_a_scrolled_widget() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, root, rects) = scrolled_rects(&mut rsc, 500);
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let target = &rects[2];
let before = render.resolved_region(target, &rsc).unwrap();
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.0);
render.update(&root, &mut rsc);
let after = render.resolved_region(target, &rsc).unwrap();
let before_px = before.to_px((800.0, 600.0).into());
let after_px = after.to_px((800.0, 600.0).into());
// Scrolling by -37 moves `amt` from 0 to 37, sliding the content's
// top-left up by 37px -- `resolved_region` (the CPU twin of the vertex
// shader's chain walk) must reflect that immediately, not the
// pre-scroll position, or a tap routed through it would land on
// whatever is now at the old coordinates instead of this widget.
assert!(
(after_px.top_left.y - (before_px.top_left.y - 37.0)).abs() < 0.01,
"before={before_px:?} after={after_px:?}"
);
}
/// `ActiveData::mask` is the mask a widget was drawn **under**, not the one
/// it set for itself -- `redraw` feeds it straight back in as the inherited
/// mask, so storing the set one hands a `Masked` its own mask the second
/// time round -- which `Painter::set_mask` asserts against, since a mask
/// that chains to itself is a clip loop. That was an abort the first time
/// the composer's new scroll area was redrawn on the emulator; a targeted
/// redraw of a `Masked` is what any real screen does whenever anything
/// inside it changes.
#[test]
fn redrawing_a_masked_widget_does_not_nest_its_own_mask() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
render.redraw(masked_id, &mut rsc);
render.redraw(masked_id, &mut rsc);
assert_eq!(
render.active.get(&masked_id).unwrap().mask,
MaskIdx::NONE,
"a `Masked` at the root is drawn under no mask of its own"
);
}
#[test]
fn a_mask_stays_put_while_its_scrolled_content_moves() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 500);
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
let root = masked.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let masked_slot_before = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_before = rsc.ui.move_offsets[masked_slot_before.idx()].delta;
rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-40.0);
render.update(&root, &mut rsc);
let masked_slot_after = render.active.get(&masked_id).unwrap().move_slot;
let mask_delta_after = rsc.ui.move_offsets[masked_slot_after.idx()].delta;
// `Masked` itself is never the target of a `mov`/`reposition` here --
// only its scrolled child is -- so the slot its own mask references
// (`Painter::set_mask` bakes in `self.move_slot`, i.e. this one) must
// still read zero after the scroll. The visible counterpart of this
// (the clipped edge follows the scroll while the viewport border does
// not) is `iris/run-headless.sh`'s job to catch in a real frame; this
// is the numeric half, on the same data the fragment shader's
// `resolve_move` reads. See LAYOUT.md section 2b.
assert_eq!(mask_delta_before, [0.0, 0.0]);
assert_eq!(mask_delta_after, [0.0, 0.0]);
}
/// Reproduces `transcript_ui::composer::build_composer`'s exact tree shape
/// (a `Rect` background stacked behind a `Span::RIGHT`-wrapped, padded,
/// `rest`-width `TextEdit`, itself the second child of an outer
/// `Span::DOWN` beside a `rest(1)`-height sibling) without the event/
/// resource plumbing `composer.rs`'s builders need, to isolate whether the
/// bug Iris reported on 2026-09-06 ("text seems to not appear in box")
/// is this crate's layout engine or something specific to the real
/// composer/screen. `TextEditable::edit` only needs `UiRsc`, so a plain
/// insert exercises the exact redraw path a keystroke does.
fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget) {
let field = wtext("")
.editable(EditMode::MultiLine)
.text_align(Align::LEFT)
.wrap(true)
.size(18)
.color(UiColor::WHITE)
.add(rsc);
let bar = (field.pad(dp(12)).width(rest(1)),)
.span(Dir::RIGHT)
.background(rect(UiColor::new(40, 40, 46, 255)))
.add(rsc);
let list_stand_in = rect(UiColor::BLACK).height(rest(1)).add(rsc);
let tree = (list_stand_in, bar).span(Dir::DOWN).add_strong(rsc).any();
(field, tree)
}
/// The reproduction itself. A window this tall stands in for the keyboard
/// closed; the second, shorter `resize` stands in for `adjustResize`
/// shrinking the surface when the IME opens -- exactly the sequence
/// `IrisViewPeer::surface_changed` drives on a real keyboard open. Typing
/// happens both before and after, since Iris's report was specifically
/// that text typed *after* the keyboard was already up did not appear.
#[test]
fn composing_text_after_a_keyboard_resize_lands_in_the_bars_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (field, root) = composer_like_tree(&mut rsc);
let mut render = UiRenderState::new();
render.resize((1080.0, 2298.0));
render.update(&root, &mut rsc);
// Focusing a field is what places its caret on a real tap
// (`attr.rs`'s `on_press` -> `TextEditCtx::select`), and an insert
// with no caret is a routing bug rather than a state to simulate --
// `insert_str`'s own `debug_assert!` says so, and caught this test
// typing into an unfocused field when it was added.
field
.edit(&mut rsc)
.select(vec2(40.0, 2250.0), vec2(1080.0, 2298.0), false, false);
field.edit(&mut rsc).insert("a");
render.update(&root, &mut rsc);
let before_px = render.window_region(&field, &rsc).unwrap();
// The field is one line plus 12dp of padding on a 2298-tall window --
// nowhere near the whole window's height, and anchored at the bottom.
assert!(
before_px.bot_right.y - before_px.top_left.y < 200.0,
"before a resize: {before_px:?}"
);
assert!(
before_px.top_left.y > 1800.0,
"expected the bar near the bottom before a resize: {before_px:?}"
);
// The keyboard opens: a real `surface_changed`/`resize` to a shorter
// window, then a further keystroke -- the redraw that must land in the
// bar's new (also short) region, not whatever region a provisional
// measurement pass used along the way.
render.resize((1080.0, 1478.0));
render.update(&root, &mut rsc);
field.edit(&mut rsc).insert("b");
render.update(&root, &mut rsc);
let after_px = render.window_region(&field, &rsc).unwrap();
assert!(
after_px.bot_right.y - after_px.top_left.y < 200.0,
"after a resize + keystroke: {after_px:?}"
);
assert!(
after_px.top_left.y > 1200.0,
"expected the bar near the bottom of the shorter window: {after_px:?}"
);
}
/// `ScrollArea` used to be documented as resolving its own lengths against
/// `Painter::output_size` -- the window -- which read as if a scroll area
/// smaller than the screen could not work, and cost a session's
/// investigation before the composer was wired up (docs/RUST.md,
/// 2026-09-06). It measures `painter.px_size()` now, so this pins the
/// three numbers that follow from the offered box: what it reports
/// upward, what its capping parent reports, and how far it can pan.
#[test]
fn a_scroll_measures_the_box_it_was_offered_not_the_window() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let scroll_id = scroll.id();
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: scroll.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let capped_id = capped.id();
let root = capped.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
// Two passes: the first offers the content a zero-length region
// (nothing measured yet) and learns the real content length from what
// comes back -- see `scrolling_moves_in_o1_without_a_redraw` for why
// that warm-up is deliberate rather than a bug.
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
// Reports the *content*, so the cap above it has something to cap;
// reporting the container instead would make the answer a function of
// itself, since the container is sized from this very number.
assert_eq!(
render.active.get(&scroll_id).unwrap().size.y,
Len::abs(1000.0)
);
assert_eq!(
render.active.get(&capped_id).unwrap().size.y,
Len::abs(100.0),
"the cap, not the content and not the window"
);
// Panning is bounded by content minus *container*: 900, not the 400
// a 600px window would give. The draw is what spends the delta -- a
// controller banks it until the layout that knows where the content
// ends (`ScrollController::take_delta`).
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-10_000.0);
render.update(&root, &mut rsc);
assert!(
(rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt() - 900.0).abs() < 0.01,
"amt={}",
rsc.ui.widgets.get_mut(&scroll_w).unwrap().amt()
);
}
/// The half `hit_testing_follows_a_scrolled_widget` could not see: it
/// checks a *descendant* of the widget `ScrollArea` actually moves, whose own
/// `region` is stale and is corrected entirely by the move chain. The
/// moved widget itself had its `region` updated *and* the chain delta
/// added on top, so its hit box sat at twice the pan -- which is why a
/// finger pan of the composer left its field untappable. See
/// `ActiveData::move_applied`.
#[test]
fn a_panned_widgets_own_hit_box_moves_exactly_once() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let tall_w = tall.weak();
let scroll = rsc
.ui
.widgets
// Start-anchored, so the `scroll(-37.0)` below has somewhere to
// go -- see `scrolled_rects`' note on the same choice.
.add_strong(ScrollArea::new(tall.any(), Axis::Y, Pin::Start));
let scroll_w = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(0.0);
render.update(&root, &mut rsc);
let before = render.window_region(&tall_w, &rsc).unwrap();
rsc.ui.widgets.get_mut(&scroll_w).unwrap().scroll(-37.0);
render.update(&root, &mut rsc);
let after = render.window_region(&tall_w, &rsc).unwrap();
assert!(
(after.top_left.y - (before.top_left.y - 37.0)).abs() < 0.01,
"the pan was applied twice: before={before:?} after={after:?}"
);
}
/// A `Masked` used to allocate a **new** mask slot on every draw, and
/// `draw_inner`'s unchanged-region fast path means its descendants are
/// mostly *not* redrawn with it -- so they went on referencing the slot
/// they were first drawn under, whose region had since stopped being the
/// widget's. Measured 2026-09-06 on the composer's tree: four live mask
/// entries, none of them the `Masked`'s current box, and the field it was
/// meant to clip drew nothing at all on the emulator. The slot is
/// allocated once and rewritten in place now (`ActiveData::own_mask`), so
/// this pins both halves: one entry, and that entry is the widget's own
/// region.
#[test]
fn a_masked_widget_keeps_one_mask_slot_that_is_always_its_own_region() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let masked = rsc.ui.widgets.add_strong(Masked {
shape: None,
inner: inner_root,
});
let masked_id = masked.id();
// Placed at the bottom of a `Span::DOWN` behind a `rest(1)` sibling,
// which is what moves the bar away from the provisional slot it is
// first drawn at -- the move that left the stale mask behind.
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: masked.any(),
x: None,
y: Some(Len::abs(60.0)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
for _ in 0..3 {
render.update(&root, &mut rsc);
render.redraw(masked_id, &mut rsc);
}
assert_eq!(
rsc.ui.masks.iter().count(),
1,
"one `Masked` must own exactly one mask slot, however often it is redrawn"
);
let mask = *rsc.ui.masks.iter().next().unwrap();
assert_eq!(
render.primitives.instance(mask.primitive).region,
render.active.get(&masked_id).unwrap().region,
"the mask a descendant clips against must be this widget's current box"
);
}
/// A `dp` cap that has done its job must be reported in pixels. `Span`
/// places a child using the `abs`/`rel` of the length it reported, so a
/// `MaxSize` handing back the caller's own `dp(168)` gave the composer's
/// bar a slot of **zero** the moment its content grew past six lines --
/// and the `ScrollArea` inside then measured its container at -63px (the
/// padding, subtracted from nothing) and panned the whole message out of
/// view. Measured on this checkout's emulator, 2026-09-06:
/// `container=-63 content=415.8 amt=478.8`. See `Len::fold_dp`.
#[test]
fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let tall = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(1000.0)),
});
let capped = rsc.ui.widgets.add_strong(MaxSize {
inner: tall.any(),
x: None,
y: Some(Len::dp(100.0)),
});
let capped_w = capped.weak();
let filler = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLACK));
let filler = rsc.ui.widgets.add_strong(Sized {
inner: filler.any(),
x: None,
y: Some(rest(1)),
});
let mut span = Span::empty(Dir::DOWN);
span.push(filler.any());
span.push(capped.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.set_density(2.5);
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let box_px = render.window_region(&capped_w, &rsc).unwrap();
let height = box_px.bot_right.y - box_px.top_left.y;
assert!(
(height - 250.0).abs() < 0.01,
"expected the 100dp cap at density 2.5 to be a 250px slot, got {height} ({box_px:?})"
);
}
/// The sibling of `a_panned_widgets_own_hit_box_moves_exactly_once`, on
/// the branch that fix had no reason to touch: `draw_inner`'s
/// size-independent fast path rewrites a widget's primitives *in place*
/// and leaves its move slot alone, so unlike `mov` there is no slot delta
/// for `region` to have absorbed. Counting one there anyway makes
/// `resolved_region` subtract a delta the chain never held, and the
/// widget's hit box lands short of where it is drawn by exactly the
/// distance it just moved -- with nothing on screen to say so, since the
/// primitives are in the right place.
#[test]
fn a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let top = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let spacer = rsc.ui.widgets.add_strong(Sized {
inner: top.any(),
x: None,
y: Some(Len::abs(100.0)),
});
let spacer_w = spacer.weak();
// `Rect` is `is_size_independent`, so growing the spacer above it
// offers this one a region that changed *both* position and size --
// the one shape that reaches the branch under test.
let below = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let below_w = below.weak();
let mut span = Span::empty(Dir::DOWN);
span.push(spacer.any());
span.push(below.any());
let root = rsc.ui.widgets.add_strong(span).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
// `Span` draws each child once at the full region to measure it and
// then places it, so this widget has already been through the branch
// once by the end of the very first frame.
let first = render.window_region(&below_w, &rsc).unwrap();
assert!(
(first.top_left.y - 100.0).abs() < 0.01,
"hit box at {:?}, drawn at y=100",
first.top_left
);
rsc.ui.widgets.get_mut(&spacer_w).unwrap().y = Some(Len::abs(250.0));
render.update(&root, &mut rsc);
let after = render.window_region(&below_w, &rsc).unwrap();
assert!(
(after.top_left.y - 250.0).abs() < 0.01,
"hit box at {:?}, drawn at y=250",
after.top_left
);
}
/// A parent that both `mov`s a child (its own layout moved the box it
/// offers) and `reposition`s it inside that box in the same frame -- what
/// `LazySpan::place`'s Bottom-known branch does once a row's cached height
/// stops matching what the row reports, which is reachable as soon as a
/// transcript row's blocks wrap (docs/IRIS_TODO.md's "Found by P1a").
struct MoveThenPlace {
inner: StrongWidget,
/// Where the child is *offered* a (constant-size) box, moved between
/// frames by the test.
offer_top: f32,
/// Where the child is then placed within this widget's own region.
place_top: f32,
}
impl Widget for MoveThenPlace {
fn draw(&mut self, painter: &mut Painter) -> Size {
let offer = UiRegion::new(
UiSpan::FULL,
UiSpan::new(
UiScalar::abs(self.offer_top),
UiScalar::abs(self.offer_top + 40.0),
),
);
painter.widget_within(&self.inner, offer);
let place = UiRegion::new(
UiSpan::FULL,
UiSpan::new(
UiScalar::abs(self.place_top),
UiScalar::abs(self.place_top + 40.0),
),
);
painter.reposition(&self.inner, place);
Size::default()
}
}
/// `mov` accumulates a delta onto a widget's move slot and `reposition`
/// overwrites it, and both can legitimately land on one widget in one
/// frame (see `MoveThenPlace`). `reposition` used to write its own delta
/// alone, which dropped the move and put the child back at the position
/// the offered box had *before* it moved; a `debug_assert!` that
/// `move_applied` was zero hid that behind a panic instead of fixing it.
/// The slot has one owner and one meaning now --
/// `move_applied + repositioned` -- so the child stays where it was
/// placed however its offered box moves. Fails at the offer's position
/// (200) rather than the placement's (100) without that.
#[test]
fn a_widget_moved_by_its_parent_and_then_placed_inside_it_lands_at_the_placement() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(40.0)),
});
let child_w = child.weak();
let parent = rsc.ui.widgets.add_strong(MoveThenPlace {
inner: child.any(),
offer_top: 0.0,
place_top: 100.0,
});
let parent_w = parent.weak();
let root = parent.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
let before = render.window_region(&child_w, &rsc).unwrap();
assert!(
(before.top_left.y - 100.0).abs() < 0.01,
"the child should be drawn where it was placed, not where it was offered: {before:?}"
);
// Move the offered box without changing its size (the `mov` fast path)
// and place the child at the same spot as before. Marking the parent
// dirty is what a real container's own content change does; the child
// itself is untouched, which is the case `mov` exists for.
{
let parent = rsc.ui.widgets.get_mut(&parent_w).unwrap();
parent.offer_top = 200.0;
}
rsc.ui.widgets.needs_redraw.insert(parent_w.id());
render.update(&root, &mut rsc);
let after = render.window_region(&child_w, &rsc).unwrap();
assert!(
(after.top_left.y - 100.0).abs() < 0.01,
"the placement did not change, so neither should the child: before={before:?} \
after={after:?}"
);
}
// ---------------------------------------------------------------------
// LAYOUT.md's "Masks with a shape" -- its pass conditions, at layer 1.
//
// The shape a mask clips to is a *primitive already drawn*, never a copy
// of one, so "the child's clipped corner" and "the container's own corner"
// are the same arithmetic. These say so by evaluating both and demanding
// exact equality: an approximate assertion would also pass a second copy
// of the radius that merely happened to agree.
// ---------------------------------------------------------------------
const RADIUS: f32 = 20.0;
/// A rounded container with `.masked_by` it, holding a `Rect::REST` child
/// that fills it -- so the child's own corners are exactly the corners
/// being clipped away. Returns the drawn state, the mask, the child, and
/// the shape primitive the mask points at.
fn rounded_container(rsc: &mut TestRsc) -> (UiRenderState, MaskIdx, WidgetId, u32) {
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child_id = child.id();
let shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let shape_id = shape.id();
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: Some(shape.any()),
inner: child.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, rsc);
let mask = render
.active
.get(&child_id)
.expect("the child is drawn")
.mask;
assert_ne!(
mask,
MaskIdx::NONE,
"the child was drawn with no clip at all"
);
let slot = render
.first_primitive(shape_id)
.expect("the shape widget drew a rect");
(render, mask, child_id, slot)
}
/// The pass condition: the child's coverage at a corner pixel *equals*
/// the container's own coverage there. Exactly equal, because it is the
/// same primitive evaluated once -- LAYOUT.md's point 1.
#[test]
fn a_masked_child_is_clipped_by_its_container_s_own_corner() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (render, mask, _child, slot) = rounded_container(&mut rsc);
let corners = render.primitive_corners(slot, &rsc);
let radius = render
.primitives
.primitive_data::<RectPrimitive>(slot)
.expect("a mask's shape is a rect")
.radius;
// Across the whole corner arc, not one point on it: a single sample
// is satisfied by a mask that clips to the box and happens to agree
// where the two coincide. Swept from the arc's own centre -- the
// straight chord between the two ends of the arc lies *inside* the
// circle everywhere, so a walk along it never leaves the shape and
// the `outside` count below is what caught that.
let arc_center = corners.top_left + Vec2::new(radius, radius);
let (mut outside, mut inside) = (0, 0);
for i in 0..=20 {
let angle = std::f32::consts::FRAC_PI_2 * i as f32 / 20.0;
let dir = Vec2::new(-angle.cos(), -angle.sin());
for out in [-1.5f32, 0.0, 1.5] {
let pos = arc_center + dir * (radius + out);
let container = rounded_rect_coverage(pos, corners.top_left, corners.bot_right, radius);
assert_eq!(
render.mask_coverage(mask, pos, &rsc),
container,
"at {pos:?} the child's clip and the container's own edge disagree",
);
if container < 0.5 {
outside += 1;
} else {
inside += 1;
}
}
}
assert!(
outside > 0 && inside > 0,
"the sweep stayed on one side of the curve ({outside} out, {inside} in), so it proved \
nothing about the corner"
);
}
/// A hit test asks the same question the pixels do: the corner the
/// container rounded away is not there to be pressed, and a point just
/// inside the curve is. LAYOUT.md's point 4.
#[test]
fn a_mask_s_shape_decides_what_can_be_pressed() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (render, mask, _child, slot) = rounded_container(&mut rsc);
let corners = render.primitive_corners(slot, &rsc);
// The very corner of the box, which the radius cut off.
let cut = corners.top_left + Vec2::new(1.0, 1.0);
assert!(
!render.mask_admits(mask, cut, &rsc),
"the corner the container rounded away is still pressable",
);
// The same distance in along the diagonal, past the curve.
let inside = corners.top_left + Vec2::new(RADIUS, RADIUS);
assert!(
render.mask_admits(mask, inside, &rsc),
"a point well inside the curve is not pressable",
);
// And the middle of an edge, which no radius touches -- the half the
// rounding had no reason to change.
let edge = Vec2::new(
(corners.top_left.x + corners.bot_right.x) / 2.0,
corners.top_left.y + 1.0,
);
assert!(
render.mask_admits(mask, edge, &rsc),
"a straight edge between two corners is not pressable",
);
}
/// Nested masks multiply, so a pixel inside two feathered corners is
/// dimmed by both -- LAYOUT.md's point 2, and the "alpha should be
/// decreased / multiplied" Iris asked for. Written as a product of the
/// two the shader would compute separately, which is what "multiply"
/// means and what an intersection test would get wrong.
#[test]
fn nested_masks_multiply_their_coverage() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let child = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
let child_id = child.id();
let inner_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let inner_shape_id = inner_shape.id();
let inner = rsc.ui.widgets.add_strong(Masked {
shape: Some(inner_shape.any()),
inner: child.any(),
});
let outer_shape = rsc
.ui
.widgets
.add_strong(Rect::new(UiColor::BLACK).radius(Len::abs(RADIUS)));
let outer_shape_id = outer_shape.id();
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: Some(outer_shape.any()),
inner: inner.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, &mut rsc);
let mask = render.active.get(&child_id).expect("drawn").mask;
let one = |render: &UiRenderState, rsc: &TestRsc, id, pos| {
let slot = render.first_primitive(id).expect("a shape rect");
let c = render.primitive_corners(slot, rsc);
let radius = render
.primitives
.primitive_data::<RectPrimitive>(slot)
.unwrap()
.radius;
rounded_rect_coverage(pos, c.top_left, c.bot_right, radius)
};
// A point on the corner arc, where both feathers are partial -- the
// only place a product and a minimum differ.
let slot = render.first_primitive(inner_shape_id).unwrap();
let corners = render.primitive_corners(slot, &rsc);
let pos = corners.top_left + Vec2::new(RADIUS * 0.3, RADIUS * 0.3);
let inner_cov = one(&render, &rsc, inner_shape_id, pos);
let outer_cov = one(&render, &rsc, outer_shape_id, pos);
assert!(
inner_cov > 0.0 && inner_cov < 1.0,
"the sample point is not inside a feather ({inner_cov}), so this proves nothing"
);
assert_eq!(
render.mask_coverage(mask, pos, &rsc),
inner_cov * outer_cov,
"two nested masks must multiply, not intersect",
);
}
/// A plain `.masked()` -- no shape given -- still clips to the widget's
/// own box with square corners, which is what every list and scroll area
/// relies on. The half the shape work had no reason to touch, and the one
/// that would silently round every existing clip if `set_mask` ever wrote
/// a radius of its own.
#[test]
fn a_plain_mask_still_clips_to_a_square_box() {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let (_scroll, inner_root, _rects) = scrolled_rects(&mut rsc, 8);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: inner_root,
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 100.0));
render.update(&root, &mut rsc);
let mask = *rsc.ui.masks.iter().next().expect("one mask");
let corners = render.primitive_corners(mask.primitive, &rsc);
let mask_idx = MaskIdx::preset(0);
assert!(
render.mask_admits(mask_idx, corners.top_left + Vec2::new(0.5, 0.5), &rsc),
"a square clip must admit its own corner pixel",
);
assert!(
!render.mask_admits(mask_idx, corners.top_left - Vec2::new(2.0, 2.0), &rsc),
"a square clip must reject a point outside it",
);
}
/// A scroll area created to be *read* opens at the beginning of its
/// content, however many frames it takes to learn how long that content
/// is.
///
/// The bug this pins: `content_len` was `0.0` both for "nothing here" and
/// for "not drawn yet", so the first frame's clamp found a range of zero,
/// read `amt == len` as "sitting at the end", and set `snap_end` -- and
/// the frame after, now knowing the real length, jumped to it. On screen
/// that was a code fence opening at the end of its longest line, in the
/// middle of a word (`iris/run-headless.sh phone`, 2026-09-08).
#[test]
fn a_scroll_area_opens_at_the_start_of_content_it_has_not_measured_yet() {
for (name, pin, want) in [("read", Pin::Start, 0.0), ("written", Pin::End, 4900.0)] {
let mut rsc = TestRsc {
ui: UiData::default(),
};
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE)).any();
let tall = rsc.ui.widgets.add_strong(Sized {
inner: fill,
x: None,
y: Some(Len::abs(5000.0)),
});
let scroll = rsc
.ui
.widgets
.add_strong(ScrollArea::new(tall.any(), Axis::Y, pin));
let weak = scroll.weak();
let root = scroll.any();
let mut render = UiRenderState::new();
render.resize((800.0, 100.0));
// Twice: the first draw is the one that measures the content, and
// the defect only showed on the second. The touch in between is
// what asks for that second draw -- an unchanged frame draws
// nothing at all, which is the point of the frame before it.
render.update(&root, &mut rsc);
let _ = rsc.ui.widgets.get_mut(&weak);
render.update(&root, &mut rsc);
let amt = rsc.ui.widgets.get(&weak).unwrap().amt();
assert!(
(amt - want).abs() < 0.01,
"an area to be {name} should have opened at {want}, got {amt}"
);
}
}
/// docs/IRIS_TODO.md's "A `Span` of `Pad`ded children inside another
/// `Span` places those children a slot out of step", worked around in
/// `transcript-ui/src/tool.rs` by flattening the two spans into one --
/// which costs a tool group the inset its cards should sit inside.
///
/// The shape is the smallest one that reproduced it there: an outer
/// `Span(DOWN)` whose second child is another `Span(DOWN)` whose children
/// are each a `Pad` around a fixed-height rect. Each rect is asserted to
/// be *drawn* where its own box is -- `primitive_corners` rather than
/// `window_region`, since the report is about what is on screen and the
/// two resolve the move chain differently.
#[test]
fn a_span_of_padded_children_inside_a_span_draws_each_where_its_box_is() {
const PAD: f32 = 4.0;
const ROW: f32 = 20.0;
const HEADER: f32 = 30.0;
let mut rsc = TestRsc {
ui: UiData::default(),
};
let header_fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::RED)).any();
let header_id = header_fill.id();
let header = rsc.ui.widgets.add_strong(Sized {
inner: header_fill,
x: None,
y: Some(Len::abs(HEADER)),
});
let mut inner = Span::empty(Dir::DOWN);
let mut rects = Vec::new();
for _ in 0..3 {
let rect = rsc.ui.widgets.add_strong(Rect::new(UiColor::WHITE));
rects.push(rect.weak());
let sized = rsc.ui.widgets.add_strong(Sized {
inner: rect.any(),
x: None,
y: Some(Len::abs(ROW)),
});
let padded = rsc.ui.widgets.add_strong(Pad {
padding: Padding::uniform(PAD),
inner: sized.any(),
});
let fill = rsc.ui.widgets.add_strong(Rect::new(UiColor::BLUE)).any();
let card = rsc.ui.widgets.add_strong(Stack {
children: vec![fill, padded.any()],
size: StackSize::Child(1),
});
let wide = rsc.ui.widgets.add_strong(Sized {
inner: card.any(),
x: Some(Len::rest(1.0)),
y: None,
});
inner.push(wide.any());
}
let inner = rsc.ui.widgets.add_strong(inner);
let outer = rsc.ui.widgets.add_strong(Span {
children: vec![header.any(), inner.any()],
dir: Dir::DOWN,
gap: Len::ZERO,
});
let mut list = LazySpan::new(Dir::DOWN, Pin::End);
list.push_back(LazyItem::new(0, outer.any()));
let list = rsc.ui.widgets.add_strong(list);
let root = rsc
.ui
.widgets
.add_strong(Masked {
shape: None,
inner: list.any(),
})
.any();
let mut render = UiRenderState::new();
render.resize((200.0, 400.0));
render.update(&root, &mut rsc);
render.update(&root, &mut rsc);
let head_slot = render
.first_primitive(header_id)
.expect("the header drew a primitive");
let head_top = render.primitive_corners(head_slot, &rsc).top_left.y;
for (i, rect) in rects.iter().enumerate() {
let want = head_top + HEADER + (ROW + 2.0 * PAD) * i as f32 + PAD;
let slot = render
.first_primitive(rect.id())
.expect("each rect drew a primitive");
let drawn = render.primitive_corners(slot, &rsc);
assert!(
(drawn.top_left.y - want).abs() < 0.01,
"row {i} should be drawn at y={want}, got {drawn:?}"
);
}
}