From 62a16b5608671e512c3bc47f19410f96a2fa1a35 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 20:51:36 -0400 Subject: [PATCH] Re-ask a dirty widget at its offer locally instead of deferring to its parent A local redraw refused any widget whose given box was not as long as its offer and marked its parent instead. Under the frame/extent protocol that is nearly every widget beneath a self-sized container: a span hands its children its own placement across itself, which is `FULL` while the span is measured and its answer once it is placed, so the children's offer and given frames differ on every such axis. A `many` frame at seed 13, depth 8 escalated 43 marks along chains up to seven levels and redrew 508 of 583 active widgets where e44dea3 redraws 159. Retain the offer's frame beside the given one and ask the offer question locally: the offer frame composed where the given one is, at the offer's lengths and placement, then place at the given box where the two differ. Seed 13 `many` goes from 4.73 ms to 1.28 ms against e44dea3's 0.90, and 294 distinct widgets a frame; size, scroll and repaint are unchanged. Not sound yet: the suite, the debug oracle and the shrinker at 400 trees of depth 5 pass, but the oracle at 1000 seeds of depth 6 diverges on seed 532 under reorder and seed 398 under every-size. Both reduce to a self-sized container whose answer changes under a local redraw; the reduced plans are in docs/HANDOFF.md of ai-app-2. Co-Authored-By: Claude Fable 5.1 --- core/src/ui/active.rs | 3 +++ core/src/ui/painter.rs | 10 ++++++++ core/src/ui/render_state.rs | 47 ++++++++++++++++++++++++++----------- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index b5f04a9..9c76e17 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -18,6 +18,9 @@ pub struct ActiveData { /// The original frame in its parent widget's coordinates. Recomposition /// and pixel-length evaluation both follow this chain. pub given_region: UiRegion, + /// The frame it was first asked in, in the same coordinates: the offer's + /// frame, which its parent's placing draw may since have narrowed. + pub offer_region: UiRegion, /// The lengths of the box its parent first asked about it in, as /// lengths of the box the parent was itself offered. Any later box it /// was given was decided knowing its answer, so this is the question diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index c6b06a7..0b7f755 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -271,6 +271,14 @@ impl<'a> Painter<'a> { .get(&id.id()) .map_or(given_len, |a| a.offer_len), }; + let offer_region = match first_ask { + true => local, + false => self + .state + .active + .get(&id.id()) + .map_or(local, |a| a.offer_region), + }; let offer_placement = if first_ask { placement } else { @@ -295,6 +303,7 @@ impl<'a> Painter<'a> { region_node, mask: self.mask, given_region: local, + offer_region, offer_len, offer_placement, px, @@ -427,6 +436,7 @@ impl<'a> Painter<'a> { self.offered.push(child.id()); let active = self.state.active.get_mut(&child.id()).unwrap(); active.offer_len = local.size(); + active.offer_region = local; active.offer_placement = placement; } let placement = UiRegion { diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 00359e1..3574bb8 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -24,6 +24,8 @@ pub(super) struct DrawInfo { pub given_region: UiRegion, /// The original offer's lengths relative to the parent's own offer. pub offer_len: UiVec2, + /// The offer's frame in the parent widget's coordinates. + pub offer_region: UiRegion, pub offer_placement: [Option; 2], /// This ask's box in pixels, and the offer's: one multiply from the /// parent's own, which is where every pixel length in layout comes from. @@ -134,6 +136,7 @@ impl UiRenderState { region_node: false, mask: MaskIdx::NONE, given_region: region, + offer_region: region, offer_len: UiVec2::FULL_SIZE, offer_placement: [None; 2], px, @@ -274,6 +277,7 @@ impl UiRenderState { // same question again from these. active.region = region; active.given_region = info.given_region; + active.offer_region = info.offer_region; active.offer_len = info.offer_len; if info.placement == info.offer_placement && info.px == info.offered_px { active.answer = Some(answer); @@ -478,6 +482,7 @@ impl UiRenderState { region_node: false, mask, given_region: UiRegion::FULL, + offer_region: UiRegion::FULL, offer_len: UiVec2::FULL_SIZE, offer_placement: [None; 2], px, @@ -495,6 +500,7 @@ impl UiRenderState { region, placement, given_region: info.given_region, + offer_region: info.offer_region, offer_len: info.offer_len, offer_placement: info.offer_placement, // Whoever asked writes the answer, if this was the asking. @@ -724,6 +730,7 @@ impl UiRenderState { let active = self.active.get_mut(&id).unwrap(); active.region = region; active.given_region = info.given_region; + active.offer_region = info.offer_region; active.offer_len = info.offer_len; #[cfg(feature = "layout-diagnostics")] { @@ -795,6 +802,7 @@ impl UiRenderState { region_node: active.move_idx != active.parent_move, mask, given_region: child_local, + offer_region: active.offer_region, offer_len: active.offer_len, offer_placement: active.offer_placement, px: child_local.size().to_px(info.px), @@ -934,6 +942,7 @@ impl UiRenderState { region: UiRegion::FULL, placement: UiRegion::FULL, given_region: UiRegion::FULL, + offer_region: UiRegion::FULL, offer_len: UiVec2::FULL_SIZE, offer_placement: [None; 2], answer: None, @@ -1170,16 +1179,6 @@ impl UiRenderState { return true; }; let (given_px, offered_px) = self.asked_px(id); - // Asked again in the box its parent gave it, which is the question - // its parent asked only while that box is as long as the offer. Any - // other box is a different question, so the parent asks it, with the - // mark left on. Lengths and not whole boxes: what a drawing depends - // on is its lengths, so the same lengths elsewhere is one question. - if given_px != offered_px { - self.mark(id, rsc.widgets_mut()); - self.mark(parent, rsc.widgets_mut()); - return false; - } let info = DrawInfo { layer: active.layer, parent: active.parent, @@ -1188,6 +1187,7 @@ impl UiRenderState { region_node: rsc.widgets().is_region_node(id), mask: active.parent_mask, given_region: active.given_region, + offer_region: active.offer_region, offer_len: active.offer_len, offer_placement: active.offer_placement, px: given_px, @@ -1202,14 +1202,33 @@ impl UiRenderState { diag::bump(Counter::LocalRedraws); let old = self.remove(id, false, rsc); - // Refresh the original measurement before restoring the assigned slot. - // Its lengths may differ even though the fraction reference is unchanged. + // Asked again where its parent asked: the offer's frame, composed + // where the given one is, at the offer's lengths and placement. That + // is the question its answer came from, whatever box the parent then + // chose from the answer -- which is often a different frame, since a + // span hands its children its own placement across itself. The + // parent draws in its own frame, or in `FULL` where it is a region + // node. + let parent_frame = match self.active.get(&parent) { + Some(p) if p.move_idx == p.parent_move => p.region, + _ => UiRegion::FULL, + }; + let offer_frame = match info.offer_region == UiRegion::FULL { + true => parent_frame, + false => info.offer_region.within(&parent_frame), + }; let offered = DrawInfo { placement: info.offer_placement, + given_region: info.offer_region, + px: offered_px, ..info }; - let answer = self.draw_inner(id, given, offered, old, false, rsc); - if info.placement != offered.placement { + // Where the given differs from the offer, the first draw is only the + // measurement and the second puts the drawing where the parent did. + let placed_apart = + info.placement != offered.placement || info.px != offered.px || given != offer_frame; + let answer = self.draw_inner(id, offer_frame, offered, old, placed_apart, rsc); + if placed_apart { self.draw_inner(id, given, info, None, false, rsc); } let active = self.active.get_mut(&id).unwrap();