From db1751fdfd1ec4d1b556c856c1e075f1f4031f21 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Mon, 14 Sep 2026 11:07:32 -0400 Subject: [PATCH] Retire Remap: a translation shifts, and only a stretch needs a fraction `Remap` existed to invert a composition, and a translation never needed one: shifting a box shifts everything composed into it by the same amount, because `lerp(s + d, e + d, t) == lerp(s, e, t) + d` on both channels. That holds whether or not the box has a relative extent, so the carry branch was answering a question it did not have to ask. So the decision is made once, before the walk, and neither relocation method branches. A translation is already one slot write. A change of length calls `UiRegion::stretch`, which re-expresses each part at its own fraction of the new box and needs `stretchable` -- a fixed length holds its parts as offsets from its start and keeps no fraction to stretch by. `Remap`, `UiScalar::outside`, `UiSpan::outside` and `LerpUtil::lerp_inv` are all gone with it. Nothing inverts a lerp any more: the one division is done against a denominator `stretchable` already established is not zero. What it gives up is the per-axis carry, so a box that changed length on one axis and not the other is redrawn where it used to be remapped. Counted: six of `tabs`'s fourteen relocations and five of `text`'s sixteen, and one extra redraw per frame on `replace_cost`'s 200 rows -- 354,310,889 instructions against 354,272,387, which is noise. Checked: fmt, clippy and 42 tests. `tabs` (with the image replay), `view`, `minimal` and `text` all still render byte-identical to `upstream/main`. --- core/src/orientation/pos.rs | 78 +++++++++++++------------------------ core/src/ui/render_state.rs | 22 +++++------ core/src/util/math.rs | 11 +----- tests/remap.rs | 27 ------------- tests/stretch.rs | 46 ++++++++++++++++++++++ 5 files changed, 85 insertions(+), 99 deletions(-) delete mode 100644 tests/remap.rs create mode 100644 tests/stretch.rs diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 423de52..bd96082 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -202,12 +202,15 @@ impl UiScalar { } } - /// 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); - Some(Self { rel, abs }) + /// `within` undone against `from` and redone against `to`, for one axis. + /// `from`'s relative extent is the denominator, so it must not be zero. + fn stretch(&self, from: &UiSpan, to: &UiSpan) -> Self { + let frac = (self.rel - from.start.rel) / (from.end.rel - from.start.rel); + Self { + rel: frac.lerp(to.start.rel, to.end.rel), + abs: self.abs - frac.lerp(from.start.abs, from.end.abs) + + frac.lerp(to.start.abs, to.end.abs), + } } pub fn within_len(&self, len: UiScalar) -> Self { @@ -278,13 +281,6 @@ impl UiSpan { } } - pub fn outside(&self, parent: &Self) -> Option { - Some(Self { - start: self.start.outside(parent)?, - end: self.end.outside(parent)?, - }) - } - pub const fn len(&self) -> UiScalar { self.end - self.start } @@ -395,48 +391,28 @@ 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 }) + /// Whether a stretch out of this box can be expressed. Each part inside a + /// box is held as a fraction of it, and a fixed length has no fraction to + /// hold one by -- every part of it is just an offset from its start. + pub fn stretchable(&self) -> bool { + self.x.start.rel != self.x.end.rel && self.y.start.rel != self.y.end.rel } - pub fn apply(&self, region: UiRegion) -> UiRegion { + /// Re-expresses a region inside `from` as the same fractions of `to`. + /// `from` must be `stretchable`; a translation is `shift` instead, which + /// needs no fractions and works out of any box. + pub fn stretch(&self, from: &UiRegion, to: &UiRegion) -> UiRegion { + debug_assert!(from.stretchable(), "a fixed length has no fraction"); 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 - } + x: UiSpan { + start: self.x.start.stretch(&from.x, &to.x), + end: self.x.end.stretch(&from.x, &to.x), + }, + y: UiSpan { + start: self.y.start.stretch(&from.y, &to.y), + end: self.y.end.stretch(&from.y, &to.y), + }, } } } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 37c9203..4ae56ae 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -1,6 +1,6 @@ use crate::{ ActiveData, Axis, DrawLayers, IdLike, MaskIdx, MoveIdx, Moves, OnResize, Painter, PixelRegion, - Remap, Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, + Size, StrongWidget, UiRegion, UiRsc, WidgetId, Widgets, util::{HashMap, HashSet, Vec2, forget_ref}, }; @@ -214,11 +214,11 @@ impl UiRenderState { self.moves.set(slot, moved); return Some(size); } - if !self.reusable(id, region, rsc) { + if !self.reusable(id, region, rsc) || !old.stretchable() { return None; } - // Its drawing stands, if the new box can be reached from the old one. - self.mov(id, &Remap::new(old, region)?); + // Its drawing stands, re-expressed as the same fractions of the box. + self.stretch(id, old, region); Some(size) } @@ -259,21 +259,21 @@ impl UiRenderState { }) } - /// Rewrites a subtree's regions into a new box, for a change a slot - /// cannot express. Every region it rewrites is a region some slot was a - /// delta from, so those go back to zero. - fn mov(&mut self, id: WidgetId, remap: &Remap) { + /// Rewrites a subtree's regions as the same fractions of a new box, for a + /// change of length that a slot cannot express. Every region it rewrites + /// is a region some slot was a delta from, so those go back to zero. + fn stretch(&mut self, id: WidgetId, from: UiRegion, to: UiRegion) { let active = self.active.get_mut(&id).unwrap(); for h in &active.primitives { let region = self.layers[h.layer].region_mut(h); - *region = remap.apply(*region); + *region = region.stretch(&from, &to); } - active.region = remap.apply(active.region); + active.region = active.region.stretch(&from, &to); self.moves.set(active.move_idx, Vec2::ZERO); // SAFETY: children cannot be recursive let children = unsafe { forget_ref(&active.children) }; for child in children { - self.mov(*child, remap); + self.stretch(*child, from, to); } } diff --git a/core/src/util/math.rs b/core/src/util/math.rs index cf4187c..f62ec7a 100644 --- a/core/src/util/math.rs +++ b/core/src/util/math.rs @@ -1,6 +1,5 @@ -pub const trait LerpUtil: Sized { +pub const trait LerpUtil { fn lerp(self, from: Self, to: Self) -> Self; - fn lerp_inv(self, from: Self, to: Self) -> Option; } const impl LerpUtil for f32 { @@ -9,14 +8,6 @@ const impl LerpUtil for f32 { fn lerp(self, from: Self, to: Self) -> Self { from + (to - from) * self } - /// 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)), - } - } } macro_rules! impl_op { diff --git a/tests/remap.rs b/tests/remap.rs deleted file mode 100644 index 67cae07..0000000 --- a/tests/remap.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! 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)); -} diff --git a/tests/stretch.rs b/tests/stretch.rs new file mode 100644 index 0000000..f2c3452 --- /dev/null +++ b/tests/stretch.rs @@ -0,0 +1,46 @@ +//! What a drawing can be taken out of, and what it cannot. + +use iris::core::{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_length_cannot_be_stretched_out_of() { + assert!(!fixed(0.0, 164.0).stretchable()); + assert!(!fixed(0.5, 164.0).stretchable()); + assert!(UiRegion::FULL.stretchable()); +} + +#[test] +fn a_stretch_keeps_each_part_at_its_fraction() { + let to = fixed(0.0, 98.0); + // A part filling the window fills what replaced it. + assert_eq!(UiRegion::FULL.stretch(&UiRegion::FULL, &to), to); + // And the middle half of it stays the middle half. + let half = UiRegion::new( + UiSpan::FULL, + UiSpan::new(UiScalar::rel(0.25), UiScalar::rel(0.75)), + ); + assert_eq!( + half.stretch(&UiRegion::FULL, &to), + UiRegion::new( + UiSpan::FULL, + UiSpan::new( + UiScalar { + rel: 0.0, + abs: 24.5 + }, + UiScalar { + rel: 0.0, + abs: 73.5 + } + ) + ) + ); +}