From 984f482a7f15bed011e80d93c63b5a227443c291 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 01:09:03 -0400 Subject: [PATCH] Move a resized drawing instead of redrawing it A region is a fraction of the output plus an offset and the shader resolves it against the window every frame, so a resize already moves the whole drawing without the CPU. Wiping the tree and drawing it again was throwing that away. `Painter::output_size` and `px_size` now record that a widget read pixels, the way reading a child's size records a dependency on it, and a resize marks only those. In the `text` example that is the two wrapping paragraphs out of forty-odd widgets; everything else keeps its drawing and the window uniform puts it in the right place. Two defects the change surfaced, both of which made a resize land somewhere a cold start would not: `redraw` climbed to the highest reader of the changed widget and drew from there, trusting that draw to reach back down. It does not: an intermediate whose own box has not changed is reused as it stands and the draw stops there. Everything between the two is now marked as well, which is the only thing that stops the reuse. Not resize-specific -- `a_change_two_levels_under_its_reader_still_reaches_it` fails on the mutation path too. `mov` cannot stretch a drawing out of a box with no relative extent. `UiScalar::within` puts a part into such a box as a plain offset from its start, and `lerp_inv`'s divide-by-zero fallback then returns a rel of 0 rather than saying it cannot invert, so the remap silently leaves the drawing its old size. `OnResize::Scale` now only reuses across a length change when the old box had a relative extent. The underlying loss belongs to the position chain, which separates the drawn box from the offered one; until then this is the honest predicate. Checked: fmt, clippy and 33 tests. `tabs` (with the image replay), `view`, `minimal` still byte-identical to `upstream/main`, and `text` unchanged at 1920x1200 and 900x1200. Driven live under the GPU as well: started at 1920x1200, resized to 900x1200 and back through sway, and each screenshot matches a cold start at that size byte for byte. --- core/src/ui/active.rs | 2 + core/src/ui/painter.rs | 9 +++- core/src/ui/render_state.rs | 45 ++++++++++++------- tests/layout.rs | 50 +++++++++++++++++++++ tests/retained.rs | 87 +++++++++++++++++++++++++++++++++++++ 5 files changed, 176 insertions(+), 17 deletions(-) diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 33f208b..daa6084 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -13,6 +13,8 @@ pub struct ActiveData { pub children: Vec, /// The children whose size this widget read while drawing. pub size_deps: Vec, + /// Whether it read the output's size, and so is wrong when that changes. + pub reads_output: bool, pub mask: MaskIdx, pub layer: LayerId, } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 12aa6f0..6ed9cfe 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -20,6 +20,7 @@ pub struct Painter<'a> { pub(super) children: Vec, /// The children whose size this widget read while drawing. pub(super) size_deps: Vec, + pub(super) reads_output: bool, pub layer: usize, pub(super) id: WidgetId, } @@ -163,11 +164,17 @@ impl<'a> Painter<'a> { self.region } - pub fn output_size(&self) -> Vec2 { + /// The output's size in pixels. A widget that reads it draws again when + /// the output changes, since nothing else can put that right. + pub fn output_size(&mut self) -> Vec2 { + self.reads_output = true; self.state.output_size } + /// This widget's box in pixels. Resolved against the output's size, so a + /// widget that reads it draws again when the output changes. pub fn px_size(&mut self) -> Vec2 { + self.reads_output = true; self.region.size().to_abs(self.state.output_size) } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index be6e433..d364e9d 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -53,11 +53,21 @@ impl UiRenderState { ); } let root = root.into(); - if self.needs_full_redraw(root) { + if self.root_changed(root) { self.redraw_all(root, rsc); self.old_root = root.map(|r| r.id()); - self.resized = false; - } else if rsc.widgets().has_updates() { + } else if self.resized { + // A region is a fraction of the output plus an offset, resolved + // against the window in the shader, so a resize moves the whole + // drawing on its own. Only a widget that read pixels can be wrong. + for (&id, active) in &self.active { + if active.reads_output { + rsc.widgets_mut().needs_redraw.insert(id); + } + } + } + self.resized = false; + if rsc.widgets().has_updates() { self.redraw_updates(rsc); } } @@ -106,6 +116,7 @@ impl UiRenderState { primitives: Vec::new(), children: Vec::new(), size_deps: Vec::new(), + reads_output: false, rsc, }; @@ -122,6 +133,7 @@ impl UiRenderState { primitives, children, size_deps, + reads_output, layer, id, } = painter; @@ -142,6 +154,7 @@ impl UiRenderState { primitives, children, size_deps, + reads_output, mask, layer, }; @@ -197,7 +210,11 @@ impl UiRenderState { let offered = region.axis_mut(axis).len(); let had = old.axis_mut(axis).len(); match widget.on_resize(axis) { - OnResize::Scale => true, + // Remapping out of the old box only scales while that box + // had a relative extent; inside a fixed one every part became + // an offset from its start, which `mov` carries but cannot + // stretch. + OnResize::Scale => offered == had || had.rel != 0.0, // `Translate` is not acted on yet, and cannot be until a // drawing can sit somewhere other than its box. `region` is // both the box a widget was given and the box its primitives @@ -283,17 +300,12 @@ impl UiRenderState { root.into().map(|r| r.id()) != self.old_root } - // Scheduling and drawing must use the same full-redraw predicate. - fn needs_full_redraw<'a>(&self, root: impl Into>) -> bool { - self.root_changed(root) || self.resized - } - pub fn needs_redraw<'a>( &self, root: impl Into>, widgets: &Widgets, ) -> bool { - self.needs_full_redraw(root) || widgets.has_updates() + self.root_changed(root) || self.resized || widgets.has_updates() } pub fn active_widgets(&self) -> usize { @@ -328,10 +340,10 @@ impl UiRenderState { pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) { self.draw_started.remove(&id); // Whoever read this widget's size may be a different size now, so the - // highest reader is what draws; it reaches this one on the way down, - // where the mark is what stops it being reused as it stands. Clearing - // it here would hide the change from the draw that came to apply it. - if let Some(top) = self.highest_reader(id) { + // highest reader is what draws. Everything between the two is marked + // as well: their own boxes have not changed, so the mark is the only + // thing stopping the draw reusing its way past this widget. + if let Some(top) = self.mark_readers(id, rsc) { self.redraw(top, rsc); // Cleared by that draw if it reached here; if it did not, this is // no longer drawn and asking again would not end. @@ -360,8 +372,8 @@ impl UiRenderState { } /// The furthest ancestor that read this widget's size, directly or through - /// widgets that did the same. - fn highest_reader(&self, id: WidgetId) -> Option { + /// widgets that did the same, marking everything below it on the way. + fn mark_readers(&self, id: WidgetId, rsc: &mut dyn UiRsc) -> Option { let mut top = None; let mut at = id; while let Some(active) = self.active.get(&at) @@ -371,6 +383,7 @@ impl UiRenderState { .get(&parent) .is_some_and(|p| p.size_deps.contains(&at)) { + rsc.widgets_mut().needs_redraw.insert(at); top = Some(parent); at = parent; } diff --git a/tests/layout.rs b/tests/layout.rs index 960b43e..85ee631 100644 --- a/tests/layout.rs +++ b/tests/layout.rs @@ -59,3 +59,53 @@ fn a_child_drawn_twice_moves_once() { assert_corners!(h, inner, (150, 0), (350, 200)); } + +#[test] +fn a_resize_lands_where_a_cold_start_would() { + let build = |h: &mut Harness| { + let para = wtext( + "Wrapping shapes one source into as many lines as its container leaves room \ + for, so the height of a paragraph is an answer rather than a setting.", + ) + .size(20) + .wrap(true) + .pad(16) + .add(&mut h.rsc); + let below = rect(Color::RED).add(&mut h.rsc); + let root = (para, below).span(Dir::DOWN).pad(12); + h.set_root(root); + (para, below) + }; + + let mut cold = Harness::new((900, 1200)); + let (cold_para, cold_below) = build(&mut cold); + + let mut resized = Harness::new((1920, 1200)); + let (para, below) = build(&mut resized); + resized.resize((900, 1200)); + resized.frame(); + + assert_eq!(resized.region(¶), cold.region(&cold_para), "paragraph"); + assert_eq!(resized.region(&below), cold.region(&cold_below), "below"); +} + +#[test] +fn a_fixed_box_is_drawn_again_rather_than_stretched() { + let mut h = Harness::new((400, 400)); + // The panel fills a stack sized by its sibling, so it is drawn in the + // whole box and then placed in the shorter one. Reusing it in that fixed + // box afterwards would leave it whatever height it happened to have. + let panel = rect(Color::BLUE).add(&mut h.rsc); + let leaf = rect(Color::RED).height(100).add(&mut h.rsc); + let stack = (panel, leaf) + .stack() + .size(StackSize::Child(1)) + .add(&mut h.rsc); + h.set_root(stack.align(Align::TOP)); + assert_corners!(h, panel, (0, 0), (400, 100)); + + h.rsc[leaf].y = Some(Len::abs(250)); + h.frame(); + + assert_corners!(h, panel, (0, 0), (400, 250)); +} diff --git a/tests/retained.rs b/tests/retained.rs index 8404317..6941a48 100644 --- a/tests/retained.rs +++ b/tests/retained.rs @@ -163,3 +163,90 @@ fn a_parent_that_only_read_a_hint_relays_out_when_the_hint_changes() { assert_corners!(h, inner, (0, 0), (400, 120)); } + +/// Reads the output's size, which nothing but its own draw can put right. +struct ReadsOutput { + draws: Rc>, +} + +impl Widget for ReadsOutput { + fn draw(&mut self, painter: &mut Painter) -> Size { + self.draws.set(self.draws.get() + 1); + Size::abs(painter.output_size() / 4.0) + } +} + +#[test] +fn a_resize_does_not_redraw_what_the_shader_can_move() { + let mut h = Harness::new((400, 200)); + let (leaf, draws) = counted(&mut h, Size::REST, OnResize::Redraw); + h.set_root(leaf); + let settled = draws.get(); + + h.resize((800, 100)); + assert!(h.needs_redraw()); + h.frame(); + + assert_eq!( + draws.get(), + settled, + "its box is the same fraction of a different output" + ); + assert_corners!(h, leaf, (0, 0), (800, 100)); +} + +#[test] +fn a_resize_redraws_what_read_the_output() { + let mut h = Harness::new((400, 200)); + let draws = Rc::new(Cell::new(0)); + let leaf = ReadsOutput { + draws: draws.clone(), + } + .add(&mut h.rsc); + h.set_root(leaf); + let settled = draws.get(); + + h.resize((800, 100)); + h.frame(); + + assert_eq!(draws.get(), settled + 1); +} + +#[test] +fn narrowing_the_output_reflows_text_and_relays_out_around_it() { + let mut h = Harness::new((600, 400)); + let para = wtext( + "Wrapping shapes one source into as many lines as its container leaves \ + room for, so the height of a paragraph is an answer rather than a setting.", + ) + .size(20) + .wrap(true) + .add(&mut h.rsc); + let below = rect(Color::RED).add(&mut h.rsc); + h.set_root((para, below).span(Dir::DOWN)); + let top = h.region(&below).expect("drew nothing").top_left.y; + + h.resize((300, 400)); + h.frame(); + + let lower = h.region(&below).expect("drew nothing").top_left.y; + assert!(lower > top, "same words, half the width: {top} -> {lower}"); +} + +#[test] +fn a_change_two_levels_under_its_reader_still_reaches_it() { + let mut h = Harness::new((400, 400)); + // Every wrapper up to the outer pad read the size below it, so the outer + // pad is what draws again -- and the span it hands the box to is the same + // size as before, which is what lets a draw reuse its way past the leaf. + let (leaf, _) = counted(&mut h, Size::abs((100, 100).into()), OnResize::Redraw); + let padded = leaf.pad(10).add(&mut h.rsc); + let below = rect(Color::RED).add(&mut h.rsc); + h.set_root((padded, below).span(Dir::DOWN).pad(12)); + assert_corners!(h, below, (12, 132), (388, 388)); + + h.rsc[leaf].size = Size::abs((100, 200).into()); + h.frame(); + + assert_corners!(h, below, (12, 232), (388, 388)); +}