From e63e923d44b75d12f00adca98d51176cf9634eea Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sun, 6 Sep 2026 18:31:58 -0400 Subject: [PATCH] iris: a size-independent widget's hit box lands where it is drawn draw_inner's third fast path -- offered region changed shape, widget's output does not depend on it -- rewrites the widget's own primitives in place and writes no move-slot delta at all. 167862c added a move_applied increment there, copied from mov, where region and the slot delta really do move together. Here only region moves, so resolved_region subtracted a distance the chain never held and every such widget's hit box sat short of its drawing by exactly the last step it took. Span reaches this on the first frame of any tree it is in: it measures each child at the full region and then places it, which for a Rect (the .background(rect(..)) idiom, list row tints) is a size change through this branch. So the hit box was wrong from the start, with the drawing correct -- nothing on screen to say so. a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at is the sibling of a_panned_widgets_own_hit_box_moves_exactly_once on the branch that fix had no reason to touch; it fails on both frames without this. Co-Authored-By: Claude Fable 5.1 --- iris/core/src/ui/render_state.rs | 17 +++++----- iris/src/layout_tests.rs | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 2429adf..f7057f0 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -210,7 +210,6 @@ impl UiRenderState { // the doubled `Compacted:` row in docs/bench/iris-phone-v2-2026-09-06.md. // The same shape reaches any dirty widget an ancestor redraws first. let dirty = rsc.widgets_mut().needs_redraw.remove(&id); - let output_size = self.output_size; if let Some(active) = self.active.get_mut(&id) && !dirty { @@ -239,13 +238,15 @@ impl UiRenderState { *r = r.outside(&from).within(®ion); self.region_mut_count += 1; } - // Same bookkeeping `mov` does below and for the same - // reason: `region` moves, this widget's own slot delta - // does not, so the part of that delta `region` accounts - // for grows by exactly this step. See - // `ActiveData::move_applied`. - active.move_applied += - region.top_left().to_abs(output_size) - from.top_left().to_abs(output_size); + // `move_applied` is deliberately **not** touched here, + // unlike in `mov`: it counts the part of this widget's own + // move-slot delta that `region` has already absorbed, and + // this branch writes no delta at all -- the primitives were + // moved directly. Counting one would make + // `resolved_region` subtract a distance the chain never + // held, putting the hit box short of the drawing by + // exactly this step. See `ActiveData::move_applied`, and + // `a_size_independent_widget_moved_by_its_parent_has_the_hit_box_it_is_drawn_at`. active.region = region; return; } diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index a1bc959..4b5ea89 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -508,3 +508,57 @@ fn a_dp_cap_is_reported_in_pixels_so_a_span_can_place_it() { "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 + ); +}