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.
This commit is contained in:
iris-ai committed 2026-09-14 01:27:16 -04:00
1 parent 984f482a7f
commit 53e61289f5
5 files changed
+106 -77

No files matched your search

+54 -22
View File
@@ -56,13 +56,6 @@ impl UiVec2 {
}
}
pub const fn outside(&self, region: &UiRegion) -> UiVec2 {
UiVec2 {
x: self.x.outside(&region.x),
y: self.y.outside(&region.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<Self> {
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<Self> {
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<Self> {
[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!(
+14 -23
View File
@@ -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);
}
}
+10 -22
View File
@@ -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<Self>;
}
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<Output = T> + const Sub<Output = T> + const Mul<Output = T> + 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<Self> {
match to == from {
true => None,
false => Some((self - from) / (to - from)),
}
}
}
+1 -10
View File
@@ -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;
+27
View File
@@ -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));
}