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 + ); +}