From a0693acc56e9d953da6a6a7b5d6d130f524d967a Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Thu, 17 Sep 2026 13:00:32 -0400 Subject: [PATCH] Let a resize settle through the walk, and drop the stale-answer guard A resize drew the root outside `redraw_updates`, top-down over a tree with dirty widgets still in it, which is the one entry point `dirty_size_under` was guarding: since `a92c6ac` settles a frame strictly bottom-up, no fuzzer could tell whether that guard still did anything anywhere else. Closing the entry point retires the guard rather than keeping a check for a hole reasoned rather than measured. The root is marked instead, and only where the new output falls outside what its answer holds for. That range is the intersection of everything under it, so admitting the new output says the whole tree still stands, and nothing above the root moved -- the window is no entry to rewrite. Marking it unconditionally would have cost the root its own `Holds`: a leaf root that scales with its box was drawn again on every resize. `dirty_size_under` goes at both call sites. `resize` takes `Widgets` because a mark is what it now leaves behind. --- core/src/ui/render_state.rs | 71 ++++++++++++++----------------------- src/default/mod.rs | 2 +- src/harness.rs | 6 ++-- 3 files changed, 31 insertions(+), 48 deletions(-) diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index b245ad5..46a27a7 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -43,7 +43,9 @@ pub struct UiRenderState { old_root: Option, /// Whether the output has changed since the last update. A frame is - /// owed for that whether or not anything has to be drawn again. + /// owed for that whether or not anything has to be drawn again: every + /// fraction becomes pixels against the output, in the shader's uniform + /// as well as here. resized: bool, /// A widget's move slot, which outlives any one `ActiveData`: a redraw /// replaces that while its children go on pointing at the slot. @@ -83,13 +85,30 @@ impl UiRenderState { /// size is applied where a fraction becomes pixels -- here in `to_px`, /// and in the shader by its uniform. A resize therefore rewrites no /// retained entry at all. - pub fn resize(&mut self, size: impl Into) { + /// + /// The root is the only widget a resize marks, and only where the new + /// output is outside what its answer holds for. Its range is the + /// intersection of everything under it, so admitting the new output says + /// the whole tree still stands -- and nothing above the root moved, the + /// window being no entry to rewrite. Where it does not admit it, the + /// ordinary bottom-up walk draws the root and `Holds` decides, per widget + /// and as the walk reaches it, how far down the new length reaches. + pub fn resize(&mut self, size: impl Into, widgets: &mut Widgets) { let size = PxVec2::from_f32(size.into()); if size == self.output_size { return; } self.output_size = size; self.resized = true; + let Some(root) = self.old_root else { return }; + let Some(active) = self.active.get(&root) else { + return; + }; + let px = active.given_len.to_px(size); + let holds = active.answer.1; + if !(holds[0].contains(px.x) && holds[1].contains(px.y)) { + widgets.needs_redraw.insert(root); + } } /// The root is asked about in the output: the window is where a fraction @@ -143,17 +162,6 @@ impl UiRenderState { if self.root_changed(root) { self.redraw_all(root, rsc); self.old_root = root.map(|r| r.id()); - } else if let Some(root) = root - && self.resized - { - // The output is the root's box, so a resize is that box changing - // length, found the way every other box change is found. Before - // anything dirty settles, so that whatever a new output draws - // again is drawn once, in the box it will have. - let region = Self::root_region(root.id(), rsc.widgets()); - let info = self.root_info(region); - let answer = self.draw_inner(root.id(), region, info, None, rsc); - self.active.get_mut(&root.id()).unwrap().answer = answer; } self.resized = false; if rsc.widgets().has_updates() { @@ -196,22 +204,11 @@ impl UiRenderState { diag::draw_request(id, info.parent, region, info.px, info.region_node); } let align = rsc.widgets().alignment(id); - // Nothing this widget has is an answer while something it measured - // is dirty: settling that changes what it would report, and a widget - // settled inside its parent's draw tells nobody -- the comparison - // that marks a reader is in `redraw`, which is not what asked here. - // Both retained routes are an answer, so the question is asked once - // rather than by each of them. - // - // Since `a92c6ac` settles a frame strictly bottom-up, no fuzzer can - // tell whether the second half of this still does anything: dropping - // `dirty_size_under` passes the suite, the shrinker at 400 seeds of - // depth 5, the oracle at 1000 of depth 6 and 2000 seeds at depth 4. - // It stays because `update` draws the root for a resize before - // `redraw_updates` runs at all, which that ordering does not reach -- - // a hole that is reasoned rather than measured. - let stale = - rsc.widgets().needs_redraw.contains(&id) || self.dirty_size_under(id, rsc.widgets()); + // Only the widget's own mark is asked about. Nothing it measured can + // be dirty while it draws: layout is one bottom-up walk, so anything + // deeper has already settled or deferred to its own parent, and a + // deferred one leaves that parent marked. + let stale = rsc.widgets().needs_redraw.contains(&id); let replace_answer = self.answer_invalid.remove(&id) || (self.replace_answers && stale); let retained = match replace_answer || stale { true => None, @@ -492,7 +489,7 @@ impl UiRenderState { parent_move: MoveIdx, widgets: &Widgets, ) -> Option<(Size, [Holds; 2])> { - if widgets.needs_redraw.contains(&id) || self.dirty_size_under(id, widgets) { + if widgets.needs_redraw.contains(&id) { return None; } let active = self.active.get(&id)?; @@ -521,20 +518,6 @@ impl UiRenderState { (holds[0].contains(info.px.x) && holds[1].contains(info.px.y)).then_some((size, holds)) } - /// Whether anything whose size this widget's own size was read from is - /// dirty, which makes what it would answer not yet known. It also keeps - /// a reader that asks first from laying out twice, which is all it was - /// here for while a changed size was thought to reach its reader in any - /// order; it does not, where the change settles inside the reader's own - /// draw. - fn dirty_size_under(&self, id: WidgetId, widgets: &Widgets) -> bool { - self.active.get(&id).is_some_and(|active| { - active.size_deps.iter().any(|child| { - widgets.needs_redraw.contains(child) || self.dirty_size_under(*child, widgets) - }) - }) - } - /// The pixel lengths of the box a widget was given and of the box it was /// first asked about, which is what a local redraw needs to ask the /// question its parent asked. diff --git a/src/default/mod.rs b/src/default/mod.rs index cf25d56..7aa41a2 100644 --- a/src/default/mod.rs +++ b/src/default/mod.rs @@ -251,7 +251,7 @@ impl AppState for DefaultApp { ui_state.renderer.draw(); } WindowEvent::Resized(size) => { - render.resize((size.width, size.height)); + render.resize((size.width, size.height), rsc.widgets_mut()); ui_state.renderer.resize(size) } WindowEvent::KeyboardInput { event, .. } => { diff --git a/src/harness.rs b/src/harness.rs index 9d5d796..d5e00fe 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -144,9 +144,9 @@ impl Harness { // bound that comes with `SyncSender` is far past anything a test // leaves unread. let (send, updates) = sync_channel(1024); - let rsc = DefaultRsc::init(Arc::new(Queue(send))); + let mut rsc = DefaultRsc::init(Arc::new(Queue(send))); let mut render = UiRenderState::new(); - render.resize(size); + render.resize(size, rsc.widgets_mut()); Self { rsc, render, @@ -161,7 +161,7 @@ impl Harness { } pub fn resize(&mut self, size: impl Into) { - self.render.resize(size); + self.render.resize(size, self.rsc.widgets_mut()); } /// Changes a length rule after the fact, the way `.width()` sets one.