From 53e61289f5eb4b897417d2a1bfaeb11f1f2188f1 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 01:27:16 -0400 Subject: [PATCH] Say when a drawing cannot be taken out of its box, rather than guessing `lerp_inv` returns `Option`. Inverting a lerp over a range of zero length has no one answer, and `div_or`'s fallback picked one: the start of the range. Measured, that is not a wrong number so much as a plausible one -- taking a part out of a box fixed at the top of the window hands back exactly what went in, and out of a box fixed at the middle hands back the parent's own `rel` of 0.5 as if it were the child's fraction. Either way the caller cannot tell that nothing was recovered, which is the defect; the previous commit's claim that it "returns a rel of 0" is right only for the first case. `UiScalar::outside` and `UiSpan::outside` follow it to `Option`, and `Remap` is the answer at the region level: `new` says whether a drawing in one box can be put in another and `apply` then cannot fail, so `try_reuse` asks once for a whole subtree and `reusable` goes back to reporting only what the widget claims. That replaces the predicate the last commit put in `reusable`, which stated the same rule in a second place. `DivOr` existed only for the fallback and is gone, along with `UiRegion::outside` and `UiVec2::outside`, which had no callers once `Remap` owned the operation. `UiRegion::axis` took `&mut self` to return a shared reference; `Remap::new` needs it on a shared one. This does not redraw less. `Remap::new` refuses exactly what the predicate refused; what it buys is one statement of the rule and a remap that cannot half-apply. Counted on a resize, with the old wipe-everything for comparison: 20 padded rows, 101 widgets 101 draws -> 0 the `tabs` example 73 draws -> 0 the `text` example 49 draws -> 48 So the saving is whole where a resize does not change any widget's size, and nil in `text`, where both paragraphs rewrap to a different height and the relayout that forces reaches the root. The last commit's message oversold that case. Checked: fmt, clippy and 35 tests. `tabs` (with the image replay), `view` and `minimal` still byte-identical to `upstream/main`, `text` unchanged, and the live sway resize round trip still matches a cold start at each size. --- core/src/orientation/pos.rs | 76 ++++++++++++++++++++++++++----------- core/src/ui/render_state.rs | 37 +++++++----------- core/src/util/math.rs | 32 +++++----------- core/src/util/vec2.rs | 11 +----- tests/remap.rs | 27 +++++++++++++ 5 files changed, 106 insertions(+), 77 deletions(-) create mode 100644 tests/remap.rs diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 466022e..70a867a 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -56,13 +56,6 @@ impl UiVec2 { } } - pub const fn outside(&self, region: &UiRegion) -> UiVec2 { - UiVec2 { - x: self.x.outside(®ion.x), - y: self.y.outside(®ion.y), - } - } - pub fn axis_mut(&mut self, axis: Axis) -> &mut UiScalar { match axis { Axis::X => &mut self.x, @@ -209,10 +202,12 @@ impl UiScalar { } } - pub const fn outside(&self, span: &UiSpan) -> Self { - let rel = self.rel.lerp_inv(span.start.rel, span.end.rel); + /// Undoes `within`, and `None` where the span has a fixed length: every + /// fraction of it lands on the same `rel`, so none can be told apart. + pub fn outside(&self, span: &UiSpan) -> Option { + let rel = self.rel.lerp_inv(span.start.rel, span.end.rel)?; let abs = self.abs - rel.lerp(span.start.abs, span.end.abs); - Self { rel, abs } + Some(Self { rel, abs }) } pub fn within_len(&self, len: UiScalar) -> Self { @@ -283,10 +278,10 @@ impl UiSpan { } } - pub const fn outside(&self, parent: &Self) -> Self { - Self { - start: self.start.outside(parent), - end: self.end.outside(parent), + pub fn outside(&self, parent: &Self) -> Option { + match (self.start.outside(parent), self.end.outside(parent)) { + (Some(start), Some(end)) => Some(Self { start, end }), + _ => None, } } @@ -324,14 +319,7 @@ impl UiRegion { y: self.y.within(&parent.y), } } - pub const fn outside(&self, parent: &Self) -> Self { - Self { - x: self.x.outside(&parent.x), - y: self.y.outside(&parent.y), - } - } - - pub const fn axis(&mut self, axis: Axis) -> &UiSpan { + pub const fn axis(&self, axis: Axis) -> &UiSpan { match axis { Axis::X => &self.x, Axis::Y => &self.y, @@ -409,6 +397,50 @@ impl UiRegion { } } +/// Taking a drawing out of one box and putting it in another, checked once +/// for a whole subtree so that applying it cannot fail. +/// +/// A box of a fixed length holds each part as an offset from its start rather +/// than as a fraction of it, so those parts can be carried to a box of the +/// same length but never stretched to a different one. +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct Remap { + from: UiRegion, + to: UiRegion, +} + +impl Remap { + pub fn new(from: UiRegion, to: UiRegion) -> Option { + [Axis::X, Axis::Y] + .into_iter() + .all(|axis| { + let (from, to) = (from.axis(axis), to.axis(axis)); + from.start.rel != from.end.rel || from.len() == to.len() + }) + .then_some(Self { from, to }) + } + + pub fn apply(&self, region: UiRegion) -> UiRegion { + UiRegion { + x: Self::span(region.x, self.from.x, self.to.x), + y: Self::span(region.y, self.from.y, self.to.y), + } + } + + fn span(span: UiSpan, from: UiSpan, to: UiSpan) -> UiSpan { + match span.outside(&from) { + Some(out) => out.within(&to), + // `new` admits this only where the two are the same length, so + // the difference between their starts is the whole move. + None => { + let mut span = span; + span.shift(to.start - from.start); + span + } + } + } +} + impl Display for UiRegion { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index d364e9d..e40a937 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,5 +1,5 @@ use crate::{ - ActiveData, Axis, DrawLayers, IdLike, MaskIdx, OnResize, Painter, PixelRegion, Size, + ActiveData, Axis, DrawLayers, IdLike, MaskIdx, OnResize, Painter, PixelRegion, Remap, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, util::{HashMap, HashSet, Vec2, forget_ref}, }; @@ -183,38 +183,29 @@ impl UiRenderState { return Some(size); } // TODO: epsilon? - if old.size() == region.size() { - self.mov(id, old, region); - return Some(size); + if old.size() != region.size() && !self.reusable(id, region, rsc) { + return None; } - if self.reusable(id, region, rsc) { - // Its drawing stands; the new box is remapped into the primitives. - self.mov(id, old, region); - return Some(size); - } - None + // Its drawing stands, if the new box can be reached from the old one. + self.mov(id, &Remap::new(old, region)?); + Some(size) } /// Whether the widget can keep the drawing it has and be given `region` /// instead, asked one axis at a time: a change on an axis it does not /// depend on costs nothing, whatever it depends on elsewhere. - fn reusable(&self, id: WidgetId, mut region: UiRegion, rsc: &dyn UiRsc) -> bool { + fn reusable(&self, id: WidgetId, region: UiRegion, rsc: &dyn UiRsc) -> bool { let Some(active) = self.active.get(&id) else { return false; }; let Some(widget) = rsc.widgets().get_dyn(id) else { return false; }; - let mut old = active.region; [Axis::X, Axis::Y].into_iter().all(|axis| { - let offered = region.axis_mut(axis).len(); - let had = old.axis_mut(axis).len(); + let offered = region.axis(axis).len(); + let had = active.region.axis(axis).len(); match widget.on_resize(axis) { - // 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, + OnResize::Scale => true, // `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 @@ -237,17 +228,17 @@ impl UiRenderState { }) } - fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) { + fn mov(&mut self, id: WidgetId, remap: &Remap) { let active = self.active.get_mut(&id).unwrap(); for h in &active.primitives { let region = self.layers[h.layer].region_mut(h); - *region = region.outside(&from).within(&to); + *region = remap.apply(*region); } - active.region = active.region.outside(&from).within(&to); + active.region = remap.apply(active.region); // SAFETY: children cannot be recursive let children = unsafe { forget_ref(&active.children) }; for child in children { - self.mov(*child, from, to); + self.mov(*child, remap); } } diff --git a/core/src/util/math.rs b/core/src/util/math.rs index 4bf4f3a..cf4187c 100644 --- a/core/src/util/math.rs +++ b/core/src/util/math.rs @@ -1,33 +1,21 @@ -use std::ops::*; - -pub const trait LerpUtil { +pub const trait LerpUtil: Sized { fn lerp(self, from: Self, to: Self) -> Self; - fn lerp_inv(self, from: Self, to: Self) -> Self; + fn lerp_inv(self, from: Self, to: Self) -> Option; } -pub const trait DivOr { - fn div_or(self, rhs: Self, other: Self) -> Self; -} - -const impl DivOr for f32 { - fn div_or(self, rhs: Self, other: Self) -> Self { - let res = self / rhs; - if res.is_nan() { other } else { res } - } -} - -const impl< - T: const Add + const Sub + const Mul + const DivOr + Copy, -> LerpUtil for T -{ +const impl LerpUtil for f32 { /// linear interpolation /// from * (1.0 - self) + to * self fn lerp(self, from: Self, to: Self) -> Self { from + (to - from) * self } - /// inverse of lerp - fn lerp_inv(self, from: Self, to: Self) -> Self { - (self - from).div_or(to - from, from) + /// inverse of lerp, and `None` where `from` and `to` are the same point: + /// every input lerps to it, so there is no one answer to come back to. + fn lerp_inv(self, from: Self, to: Self) -> Option { + match to == from { + true => None, + false => Some((self - from) / (to - from)), + } } } diff --git a/core/src/util/vec2.rs b/core/src/util/vec2.rs index ca67c68..a678216 100644 --- a/core/src/util/vec2.rs +++ b/core/src/util/vec2.rs @@ -1,4 +1,4 @@ -use crate::util::{DivOr, impl_op}; +use crate::util::impl_op; use std::{hash::Hash, ops::*}; #[repr(C)] @@ -67,15 +67,6 @@ impl_op!(Vec2 Sub sub; x y); impl_op!(Vec2 Mul mul; x y); impl_op!(Vec2 Div div; x y); -const impl DivOr for Vec2 { - fn div_or(self, rhs: Self, other: Self) -> Self { - Self { - x: self.x.div_or(rhs.x, other.x), - y: self.y.div_or(rhs.y, other.y), - } - } -} - impl Neg for Vec2 { type Output = Self; diff --git a/tests/remap.rs b/tests/remap.rs new file mode 100644 index 0000000..67cae07 --- /dev/null +++ b/tests/remap.rs @@ -0,0 +1,27 @@ +//! What a drawing can be taken out of, and what it cannot. + +use iris::core::{Remap, UiRegion, UiScalar, UiSpan}; + +/// A box `size` tall whose top is `rel` of the way down the window. +fn fixed(rel: f32, size: f32) -> UiRegion { + UiRegion::new( + UiSpan::FULL, + UiSpan::new(UiScalar { rel, abs: 0.0 }, UiScalar { rel, abs: size }), + ) +} + +#[test] +fn a_fixed_box_can_be_carried_but_not_stretched() { + let from = fixed(0.0, 164.0); + assert!(Remap::new(from, UiRegion::FULL).is_none()); + assert!(Remap::new(from, fixed(0.5, 164.0)).is_some()); + assert!(Remap::new(from, fixed(0.0, 98.0)).is_none()); +} + +#[test] +fn a_relative_box_can_be_stretched_to_any_other() { + let remap = Remap::new(UiRegion::FULL, fixed(0.0, 98.0)).expect("relative boxes remap"); + // A part that filled the window keeps filling what replaced it, which is + // exactly what `outside` could not say for a box of a fixed length. + assert_eq!(remap.apply(UiRegion::FULL), fixed(0.0, 98.0)); +}