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