Put lengths, padding, gaps and alignment on the grid too

`Len` is `Px` beside `Rel` beside `Weight`, so the seam `4e28f10` left in
`Span` -- a float length added to a fixed-point cursor -- is gone, and the
sum a span compares against its box is exact.

`Weight` is its own scale, `Fixed<16>`, because a share of what is left over
is not a fraction of anything: a list divides its room by the total of them,
so the range has to hold a whole list's worth while the precision only has to
tell two weights apart. `Rel::ratio` turns two weights into a share on the
finer grid, which is what a span needs and what dividing them on their own
grid would round away.

`AxisAlign` holds a `Rel` rather than a float, which is what the layout was
reading out of it anyway. `Padding` and `Span::gap` hold `Px`, converted
where they are built instead of on every frame. `RegionAlign::rel` is gone;
its one caller wanted a position, and now builds one.

`Fixed` gains `from_num` for a number as it is written in source, `mul_int`
for a length repeated a whole number of times, and `ratio`.

Checked: fmt, clippy, 101 tests, 100 generated seeds in 86 s, all five
shrinker cases at 300 seeds, and all five examples byte-identical at
1920x1200 against `4e28f10`.

With the fuzzer comparing for equality rather than within 0.05 px, four of
the five cases now pass 100 seeds -- `resize-repaint` joins the other three.
`reorder` still fails one seed by one step, so the last of it is in what a
box is measured *in*: `px_len` and the window are still floats.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 01:40:42 -04:00
1 parent 4e28f1047e
commit bd6de71a55
15 files changed
+199 -158

No files matched your search

+4 -4
View File
@@ -111,7 +111,7 @@ pub struct Branch {
impl Widget for Branch {
fn draw(&mut self, painter: &mut Painter) -> Size {
let mut top = UiRegion::FULL;
top.y.end = top.y.start.offset(40.0);
top.y.end = top.y.start.offset(Px::from_int(40));
let measured = painter.widget_within(&self.probe, top).len(Axis::X);
let px = measured
.apply_leftover()
@@ -119,7 +119,7 @@ impl Widget for Branch {
.to_f32();
let mut below = UiRegion::FULL;
below.y.start = below.y.start.offset(40.0);
below.y.start = below.y.start.offset(Px::from_int(40));
match px > self.threshold {
true => painter.widget_within(&self.wide, below),
false => painter.widget_within(&self.narrow, below),
@@ -312,7 +312,7 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
let inner = self.noded(inner);
// Each side its own, since a padding that is the same all round
// hides anything that treats one edge differently from another.
let mut side = || self.rng.below(24) as f32;
let mut side = || Px::from_int(self.rng.below(24) as i32);
let padding = Padding {
left: side(),
right: side(),
@@ -360,7 +360,7 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
let id = Span {
children,
dir,
gap: self.rng.below(3) as f32 * 4.0,
gap: Px::from_int(self.rng.below(3) as i32 * 4),
// Derive this from an existing choice: a seed must keep growing
// the same tree when the generator gains another configuration.
ortho: match dir.axis {
+26 -28
View File
@@ -24,22 +24,22 @@ impl Widget for Pad {
}
pub struct Padding {
pub left: f32,
pub right: f32,
pub top: f32,
pub bottom: f32,
pub left: Px,
pub right: Px,
pub top: Px,
pub bottom: Px,
}
impl Padding {
pub const ZERO: Self = Self {
left: 0.0,
right: 0.0,
top: 0.0,
bottom: 0.0,
left: Px::ZERO,
right: Px::ZERO,
top: Px::ZERO,
bottom: Px::ZERO,
};
pub fn uniform(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
let amt = Px::from_num(amt);
Self {
left: amt,
right: amt,
@@ -49,78 +49,76 @@ impl Padding {
}
pub fn region(&self) -> UiRegion {
let mut region = UiRegion::FULL;
region.x.start.px += Px::from_f32(self.left);
region.y.start.px += Px::from_f32(self.top);
region.x.end.px -= Px::from_f32(self.right);
region.y.end.px -= Px::from_f32(self.bottom);
region.x.start.px += self.left;
region.y.start.px += self.top;
region.x.end.px -= self.right;
region.y.end.px -= self.bottom;
region
}
pub fn x(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
let amt = Px::from_num(amt);
Self {
left: amt,
right: amt,
top: 0.0,
bottom: 0.0,
..Self::ZERO
}
}
pub fn y(amt: impl UiNum) -> Self {
let amt = amt.to_f32();
let amt = Px::from_num(amt);
Self {
left: 0.0,
right: 0.0,
top: amt,
bottom: amt,
..Self::ZERO
}
}
pub fn top(amt: impl UiNum) -> Self {
let mut s = Self::ZERO;
s.top = amt.to_f32();
s.top = Px::from_num(amt);
s
}
pub fn bottom(amt: impl UiNum) -> Self {
let mut s = Self::ZERO;
s.bottom = amt.to_f32();
s.bottom = Px::from_num(amt);
s
}
pub fn left(amt: impl UiNum) -> Self {
let mut s = Self::ZERO;
s.left = amt.to_f32();
s.left = Px::from_num(amt);
s
}
pub fn right(amt: impl UiNum) -> Self {
let mut s = Self::ZERO;
s.right = amt.to_f32();
s.right = Px::from_num(amt);
s
}
pub fn with_top(mut self, amt: impl UiNum) -> Self {
self.top = amt.to_f32();
self.top = Px::from_num(amt);
self
}
pub fn with_bottom(mut self, amt: impl UiNum) -> Self {
self.bottom = amt.to_f32();
self.bottom = Px::from_num(amt);
self
}
pub fn with_left(mut self, amt: impl UiNum) -> Self {
self.left = amt.to_f32();
self.left = Px::from_num(amt);
self
}
pub fn with_right(mut self, amt: impl UiNum) -> Self {
self.right = amt.to_f32();
self.right = Px::from_num(amt);
self
}
}
impl<T: UiNum> From<T> for Padding {
fn from(amt: T) -> Self {
Self::uniform(amt.to_f32())
Self::uniform(amt)
}
}
+5 -2
View File
@@ -45,9 +45,12 @@ impl Widget for Scroll {
// sits is this widget's own alignment -- the same property that would
// have placed the whole scroll in a box longer than it.
let slack = (self.container_len - self.content_len).max(0.0);
let anchor = slack * align.rel();
let anchor = slack * align.rel().to_f32();
let mut region = UiRegion::FULL.offset(Vec2::from_axis(self.axis, anchor - self.amt, 0.0));
region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(self.content_len);
region.axis_mut(self.axis).end = region
.axis(self.axis)
.start
.offset(Px::from_f32(self.content_len));
painter.widget_aligned(&self.inner, region, RegionAlign::NEAR);
// What it occupies is its box, on both axes: it clips its content to
// that box, so it can neither take less of one nor honestly ask for
+43 -33
View File
@@ -14,7 +14,7 @@ pub enum OrthoSize {
pub struct Span {
pub children: Vec<StrongWidget>,
pub dir: Dir,
pub gap: f32,
pub gap: Px,
pub ortho: OrthoSize,
}
@@ -35,14 +35,21 @@ impl Widget for Span {
Some(len) => len,
None => painter.widget_within(child, region).len(axis),
};
// Onto the grid at the seam: `Len` is still in floats.
cursor.px += Px::from_f32(len.px + self.gap);
cursor.rel += Rel::from_f32(len.rel);
cursor.px += len.px + self.gap;
cursor.rel += len.rel;
lens.push(len);
}
let gap = self.gap * self.children.len().saturating_sub(1) as f32;
let total = lens.iter().fold(Len::px(gap), |sum, len| sum + *len);
let gaps = self
.gap
.mul_int(self.children.len().saturating_sub(1) as i32);
let total = lens.iter().fold(
Len {
px: gaps,
..Len::ZERO
},
|sum, len| sum + *len,
);
// Whether anything is left over is a question in pixels: `rel(0.5)`
// beside 300 px is full at 600 and overfull at 400. The room to divide
@@ -53,31 +60,32 @@ impl Widget for Span {
// 0.00003 px of rounding decides whether a leftover-only child is
// drawn at all. The validity range is split at the moved boundary, and
// exact there, since no box lands on it any more.
let fixed = 1.0 - total.rel;
let fixed = Rel::ONE - total.rel;
let margin = Px::from_f32(HOLDS_EPSILON_PX);
let mut shares = false;
if total.leftover > 0.0 {
let current = painter.px_len(axis);
let holds = if fixed > 0.0 {
if total.leftover > Weight::ZERO {
let current = Px::from_f32(painter.px_len(axis));
let holds = if fixed > Rel::ZERO {
// The box length at which the room reaches the margin.
let enough = (total.px + HOLDS_EPSILON_PX) / fixed;
let enough = (total.px + margin).div(fixed);
shares = current > enough;
match shares {
true => Holds::exact(enough.next_up()..=f32::INFINITY),
false => Holds::exact(f32::NEG_INFINITY..=enough),
true => Holds::exact(enough.to_f32().next_up()..=f32::INFINITY),
false => Holds::exact(f32::NEG_INFINITY..=enough.to_f32()),
}
} else if fixed < 0.0 {
} else if fixed < Rel::ZERO {
// The relative parts grow faster than the box does, so here
// a shorter box is the one that leaves room.
let enough = (total.px + HOLDS_EPSILON_PX) / fixed;
let enough = (total.px + margin).div(fixed);
shares = current < enough;
match shares {
true => Holds::exact(f32::NEG_INFINITY..=enough.next_down()),
false => Holds::exact(enough..=f32::INFINITY),
true => Holds::exact(f32::NEG_INFINITY..=enough.to_f32().next_down()),
false => Holds::exact(enough.to_f32()..=f32::INFINITY),
}
} else {
// The relative parts take exactly the box, whatever it is, so
// the only room is what negative pixels leave.
shares = total.px < -HOLDS_EPSILON_PX;
shares = total.px < margin.neg();
Holds::ANY
};
painter.holds(axis, holds);
@@ -89,21 +97,23 @@ impl Widget for Span {
// A child asking for nothing but a part of what is left over,
// when nothing is, is not drawn at all. One that also asked for
// pixels or a fraction keeps those and overflows.
if len.leftover > 0.0 && len.px == 0.0 && len.rel == 0.0 && !shares {
if len.leftover > Weight::ZERO && len.px == Px::ZERO && len.rel == Rel::ZERO && !shares
{
painter.undraw(child);
start.px += Px::from_f32(self.gap);
start.px += self.gap;
continue;
}
let mut span = UiSpan::FULL;
span.start = start;
if len.leftover > 0.0 && shares {
let offset = UiScalar::new(total.rel, total.px);
let rel_end = UiScalar::rel(len.leftover / total.leftover);
if len.leftover > Weight::ZERO && shares {
let offset = UiScalar::from_parts(total.rel, total.px);
let share = Rel::ratio(len.leftover, total.leftover);
let rel_end = UiScalar::from_parts(share, Px::ZERO);
let end = (UiScalar::rel_max() + start) - offset;
start = rel_end.within(&start.to(end));
}
start.px += Px::from_f32(len.px);
start.rel += Rel::from_f32(len.rel);
start.px += len.px;
start.rel += len.rel;
span.end = start;
let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL);
if self.dir.sign == Sign::Neg {
@@ -116,13 +126,13 @@ impl Widget for Span {
// span's own eventual width admits multiple fixed points.
// A scalable child therefore makes Children scalable too;
// only fixed children are compared with one another.
if used.rel != 0.0 || used.leftover != 0.0 {
if used.rel != Rel::ZERO || used.leftover != Weight::ZERO {
ortho = Len::LEFTOVER;
} else if ortho.leftover == 0.0 {
} else if ortho.leftover == Weight::ZERO {
ortho.px = ortho.px.max(used.px);
}
}
start.px += Px::from_f32(self.gap);
start.px += self.gap;
}
// Carried whole rather than collapsed to one share: a span that sizes
@@ -146,13 +156,13 @@ impl Span {
Self {
children: Vec::new(),
dir,
gap: 0.0,
gap: Px::ZERO,
ortho: OrthoSize::Children,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
self.gap = Px::from_num(gap);
self
}
@@ -173,7 +183,7 @@ impl Span {
pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag> {
pub children: Wa,
pub dir: Dir,
pub gap: f32,
pub gap: Px,
pub ortho: OrthoSize,
_pd: PhantomData<(State, Tag)>,
}
@@ -201,14 +211,14 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
Self {
children,
dir,
gap: 0.0,
gap: Px::ZERO,
ortho: OrthoSize::Children,
_pd: PhantomData,
}
}
pub fn gap(mut self, gap: impl UiNum) -> Self {
self.gap = gap.to_f32();
self.gap = Px::from_num(gap);
self
}