Place a locally redrawn widget in its box, not just at its length

A dirty widget is asked again in the box its parent asked it in, and then
again in the box its parent chose from that answer. The second ask was
skipped whenever the two boxes were the same *length*, which is not the same
question: an offer as long as the final box but somewhere else is a different
box. `d3b0ebf` already compared whole boxes for `parent_must_place` and left
this one a length comparison, so the two halves of one decision disagreed.

It shows on a region node, which draws the box it drew in into its own move
entry. A scroll inside a scrolled span is offered the outer scroll's whole
viewport and placed 24px above it, the height of the sized child the outer
scroll snaps to the end of; redrawing only its text left it at the offer and
24px too low. `tests/cases/unsettled.rs` had that five-widget tree ignored as
a known defect and now runs it.

`px_region` names the walk both comparisons and `window_region` were writing
out.

Checked: fmt, clippy, 80 tests, the release oracle at 100 seeds, all fifteen
shrinker cases at 400 seeds of depth 5, and `tabs`, `view`, `minimal`,
`text`, `random` plus the tab replay byte-identical at 1920x1200 against
`98d4e98`. The `many` fixture's twenty-five counters are unchanged.

Fixed with it, from the handoff's unreduced leads: shrinker seeds 174 and 175
on `repaint-some` and seed 2 on `region-node`, and oracle seeds 18 and 190 at
depth 6. Still failing: shrinker seed 288 on `region-node`, and oracle seed
326 at depth 6, which reduces to 43 widgets around two `Branch`es and is not
this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 16:25:26 -04:00
1 parent 98d4e98a29
commit aea878d141
2 files changed
+32 -31

No files matched your search

+19 -18
View File
@@ -468,6 +468,12 @@ impl UiRenderState {
.to_px(self.output_size) .to_px(self.output_size)
} }
/// Where a region held in `slot`'s coordinates lands on screen, which is
/// the walk the vertex shader does.
fn px_region(&self, slot: MoveIdx, region: UiRegion) -> PixelRegion {
self.moves.resolve(slot, region).to_px(self.output_size)
}
/// A clean widget's retained answer, if that answer holds for a box of /// A clean widget's retained answer, if that answer holds for a box of
/// `px`. This does not move its drawing, which may already be in the box /// `px`. This does not move its drawing, which may already be in the box
/// that answer placed it in. /// that answer placed it in.
@@ -903,15 +909,12 @@ impl UiRenderState {
} }
/// Where a widget is on screen: its box composed through the boxes it /// Where a widget is on screen: its box composed through the boxes it
/// sits within, which is the walk the vertex shader does. `None` for one /// sits within. `None` for one that is not drawn.
/// that is not drawn.
pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> { pub fn window_region(&self, id: &impl IdLike) -> Option<PixelRegion> {
let active = self.active.get(&id.id())?; let active = self.active.get(&id.id())?;
if !active.drawn { active
return None; .drawn
} .then(|| self.px_region(active.parent_move, active.region))
let region = self.moves.resolve(active.parent_move, active.region);
Some(region.to_px(self.output_size))
} }
/// Settles a dirty widget: asks it again where its parent asked, and /// Settles a dirty widget: asks it again where its parent asked, and
@@ -955,17 +958,15 @@ impl UiRenderState {
None => Self::root_region(id, rsc.widgets()), None => Self::root_region(id, rsc.widgets()),
}; };
let offered_px = self.px_of(active.parent_move, asked_in); let offered_px = self.px_of(active.parent_move, asked_in);
let at_offer = same_px(self.px_of(active.parent_move, region), offered_px); // Whole boxes rather than lengths: an offer as long as the final box
let parent_must_place = active.parent.is_some() // but somewhere else is a different box, and a region node drawing at
&& (!region_node || active.align_override) // its offer writes the box it drew in into its own entry.
&& !same_pixel_region( let at_offer = same_pixel_region(
self.moves self.px_region(active.parent_move, region),
.resolve(active.parent_move, region) self.px_region(active.parent_move, asked_in),
.to_px(self.output_size), );
self.moves let parent_must_place =
.resolve(active.parent_move, asked_in) active.parent.is_some() && (!region_node || active.align_override) && !at_offer;
.to_px(self.output_size),
);
// An independently positioned region node can redraw at its offer // An independently positioned region node can redraw at its offer
// and move its slot to its own placement. Every other widget needs // and move its slot to its own placement. Every other widget needs
// its parent to reproduce a different final position. // its parent to reproduce a different final position.
+13 -13
View File
@@ -3,9 +3,10 @@
//! frame that had not settled: a wrapping text shaped at a width it was //! frame that had not settled: a wrapping text shaped at a width it was
//! measured in rather than the one it was given. The rest are a widget //! measured in rather than the one it was given. The rest are a widget
//! measured again in a box its own answer had decided, where the old answer //! measured again in a box its own answer had decided, where the old answer
//! is a fixed point whatever the content now says. The last is neither: one //! is a fixed point whatever the content now says. The last two are neither:
//! box length, composed two ways, landing either side of the boundary that //! one box length, composed two ways, landing either side of the boundary
//! decided whether a child was drawn at all. //! that decided whether a child was drawn at all, and one box as long as the
//! box a widget was offered but somewhere else.
use iris::harness::Harness; use iris::harness::Harness;
use iris::prelude::*; use iris::prelude::*;
@@ -401,9 +402,8 @@ fn a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over() {
/// Five widgets, shrunk by `tests/shrink.rs` from the 277 the oracle's seed /// Five widgets, shrunk by `tests/shrink.rs` from the 277 the oracle's seed
/// 18 grows at depth 6. A scroll inside a scroll, the inner one owning a /// 18 grows at depth 6. A scroll inside a scroll, the inner one owning a
/// movable region of its own, and only the text at the bottom marked for /// movable region of its own, and only its text marked for redraw. Nothing
/// redraw. Nothing about the tree changes, so no box may -- and the span /// about the tree changes, so no box may.
/// lands 76px further down the outer scroll warm than it does cold.
fn plant_nested_scrolls(h: &mut Harness) -> Vec<WidgetId> { fn plant_nested_scrolls(h: &mut Harness) -> Vec<WidgetId> {
let text = wtext("one line, overflowing whatever it is given") let text = wtext("one line, overflowing whatever it is given")
.size(16) .size(16)
@@ -428,14 +428,14 @@ fn plant_nested_scrolls(h: &mut Harness) -> Vec<WidgetId> {
vec![text.id(), inner.id(), filler.id(), span.id(), root.id()] vec![text.id(), inner.id(), filler.id(), span.id(), root.id()]
} }
/// **A known defect, not a passing test.** Bisected to `95fb4f9`, which made /// A local redraw asks a dirty widget in the box its parent asked it in, and
/// `Masked` report its box rather than its inner's size: `Scroll` clips /// then again in the box its parent chose from that answer. Skipping the
/// through one, so what the outer scroll is told its content measures now /// second ask because the two boxes are the same *length* left this inner
/// depends on whether the inner subtree was redrawn this frame. Warm the /// scroll, which owns a region node, drawn at its offer. The offer is the
/// span sits at the top of the outer scroll and cold it sits 24px higher, /// outer scroll's whole viewport and the final box is 24px above it -- the
/// which is exactly the sized child's height. Un-ignore it with the fix. /// height of the sized child the outer scroll snaps to the end of -- so the
/// inner scroll and its text stayed 24px too low.
#[test] #[test]
#[ignore = "known defect: a partial repaint moves a scrolled span, from 95fb4f9"]
fn redrawing_one_widget_does_not_move_what_scrolls_around_it() { fn redrawing_one_widget_does_not_move_what_scrolls_around_it() {
let mut warm = Harness::new((900, 1200)); let mut warm = Harness::new((900, 1200));
let ids = plant_nested_scrolls(&mut warm); let ids = plant_nested_scrolls(&mut warm);