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

+37
View File
@@ -1,3 +1,4 @@
use crate::UiNum;
use std::{
fmt::{Debug, Display, Formatter},
ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign},
@@ -28,6 +29,12 @@ pub struct Fixed<const SHIFT: u32>(i32);
/// the same number reach the GPU.
pub type Px = Fixed<6>;
/// A share of what a box has left over, which is a weight beside its
/// siblings rather than a fraction of anything: a list divides its room by
/// the total of these, so the range has to hold a whole list's worth and the
/// precision only has to tell two weights apart.
pub type Weight = Fixed<16>;
/// A fraction of a box. Twenty-four bits of it, which matches `f32` around a
/// half and beats it above one -- where anchors actually sit -- and leaves
/// +/-128 of range, enough to sum a hundred children each asking for a whole
@@ -86,6 +93,12 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
})
}
/// From a number as it is written in source -- `16`, `1.5` -- which is
/// the other place a value enters the grid.
pub fn from_num(v: impl UiNum) -> Self {
Self::from_f32(v.to_f32())
}
pub const fn to_f32(self) -> f32 {
self.0 as f32 / Self::one().0 as f32
}
@@ -117,6 +130,11 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
Self(narrow(shift_round(self.0 as i64 * by.0 as i64, BY)))
}
/// Repeated a whole number of times, which no grid rounds.
pub const fn mul_int(self, by: i32) -> Self {
Self(narrow(self.0 as i64 * by as i64))
}
/// Divided by a number on any grid. A zero divisor is a caller bug -- a
/// box of no length has no fraction of itself -- and saturates so that a
/// release build lays out something absurd rather than dying.
@@ -131,6 +149,16 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
Self(narrow(div_round((self.0 as i64) << BY, by.0 as i64)))
}
/// `num / den` on *this* grid rather than on theirs, for weights coarser
/// than the share they divide.
pub const fn ratio<const OF: u32>(num: Fixed<OF>, den: Fixed<OF>) -> Self {
debug_assert!(den.0 != 0, "no part of a whole of nothing");
if den.0 == 0 {
return Self::ZERO;
}
Self(narrow(div_round((num.0 as i64) << SHIFT, den.0 as i64)))
}
/// `from` and `to` a fraction of the way apart, the fraction being the
/// receiver -- the argument order [`crate::util::LerpUtil`] already uses.
pub const fn lerp<const OF: u32>(self, from: Fixed<OF>, to: Fixed<OF>) -> Fixed<OF> {
@@ -347,6 +375,15 @@ mod tests {
assert_eq!(Rel::from_f32(0.5).lerp(to, from), Px::from_int(15));
}
#[test]
fn a_ratio_is_finer_than_the_weights_it_divides() {
let (one, three) = (Weight::ONE, Weight::from_int(3));
// A third, which the weights' own grid could only hold to 1/65536.
assert_eq!(Rel::ratio(one, three), Rel::from_raw(5592405));
assert_eq!(Rel::ratio(three, three), Rel::ONE);
assert_eq!(Rel::ratio(Weight::ZERO, three), Rel::ZERO);
}
#[test]
fn nothing_sits_between_a_value_and_the_next_one() {
let at = Px::from_int(3);
+11 -9
View File
@@ -1,4 +1,4 @@
use crate::{Px, Rel, vec2};
use crate::{Px, Rel};
use super::*;
@@ -35,7 +35,7 @@ impl Align {
/// is the near one depends on the writing system and on which way a container
/// runs, and the middle is the same either way.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AxisAlign(f32);
pub struct AxisAlign(Rel);
impl AxisAlign {
pub const NEG: Self = Self::new(0.0);
@@ -43,10 +43,12 @@ impl AxisAlign {
pub const POS: Self = Self::new(1.0);
pub const fn new(rel: f32) -> Self {
Self(rel)
Self(Rel::from_f32(rel))
}
pub const fn rel(&self) -> f32 {
/// A fraction of the room left over, which is what the layout reads: the
/// three constants are the familiar places along it, not the only ones.
pub const fn rel(&self) -> Rel {
self.0
}
}
@@ -118,9 +120,6 @@ impl RegionAlign {
pub const fn new(x: AxisAlign, y: AxisAlign) -> Self {
Self { x, y }
}
pub const fn rel(&self) -> Vec2 {
vec2(self.x.rel(), self.y.rel())
}
}
impl UiVec2 {
@@ -175,7 +174,7 @@ impl Vec2 {
impl UiScalar {
pub const fn align(&self, align: AxisAlign) -> UiSpan {
let rel = Rel::from_f32(align.rel());
let rel = align.rel();
let rest = Rel::ONE.sub(rel);
let at = UiScalar::from_parts(rel, Px::ZERO);
UiSpan {
@@ -221,7 +220,10 @@ impl From<CardinalAlign> for Align {
const impl From<RegionAlign> for UiVec2 {
fn from(align: RegionAlign) -> Self {
Self::rel(align.rel())
Self::new(
UiScalar::from_parts(align.x.rel(), Px::ZERO),
UiScalar::from_parts(align.y.rel(), Px::ZERO),
)
}
}
+38 -44
View File
@@ -1,5 +1,5 @@
use super::*;
use crate::{UiNum, util::impl_op};
use crate::{Px, Rel, UiNum, Weight, util::impl_op};
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct Size {
@@ -7,11 +7,14 @@ pub struct Size {
pub y: Len,
}
#[derive(Debug, Clone, Copy, PartialEq)]
/// What a widget asks for along one axis: pixels, a fraction of the box it
/// is given, and a share of whatever is left over once everything fixed has
/// been taken. The three add up rather than choosing between one another.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Len {
pub px: f32,
pub rel: f32,
pub leftover: f32,
pub px: Px,
pub rel: Rel,
pub leftover: Weight,
}
impl<N: UiNum> From<N> for Len {
@@ -97,41 +100,44 @@ impl Size {
impl Len {
pub const ZERO: Self = Self {
px: 0.0,
rel: 0.0,
leftover: 0.0,
px: Px::ZERO,
rel: Rel::ZERO,
leftover: Weight::ZERO,
};
pub const LEFTOVER: Self = Self {
px: 0.0,
rel: 0.0,
leftover: 1.0,
px: Px::ZERO,
rel: Rel::ZERO,
leftover: Weight::ONE,
};
/// The whole of what is left over counts as the whole box, which is what
/// a length means to something that is not dividing a box between
/// siblings -- a scroll asking how long its content is.
pub fn apply_leftover(&self) -> UiScalar {
let share = if self.leftover > 0.0 { 1.0 } else { 0.0 };
UiScalar::new(self.rel + share, self.px)
let share = match self.leftover > Weight::ZERO {
true => Rel::ONE,
false => Rel::ZERO,
};
UiScalar::from_parts(self.rel.add(share), self.px)
}
pub fn px(px: impl UiNum) -> Self {
Self {
px: px.to_f32(),
rel: 0.0,
leftover: 0.0,
px: Px::from_num(px),
..Self::ZERO
}
}
pub fn rel(rel: impl UiNum) -> Self {
Self {
px: 0.0,
rel: rel.to_f32(),
leftover: 0.0,
rel: Rel::from_num(rel),
..Self::ZERO
}
}
pub fn leftover(ratio: impl UiNum) -> Self {
Self {
px: 0.0,
rel: 0.0,
leftover: ratio.to_f32(),
leftover: Weight::from_num(ratio),
..Self::ZERO
}
}
}
@@ -140,33 +146,21 @@ pub mod len_fns {
use super::*;
pub fn px(px: impl UiNum) -> Len {
Len {
px: px.to_f32(),
rel: 0.0,
leftover: 0.0,
}
Len::px(px)
}
pub fn rel(rel: impl UiNum) -> Len {
Len {
px: 0.0,
rel: rel.to_f32(),
leftover: 0.0,
}
Len::rel(rel)
}
pub fn leftover(ratio: impl UiNum) -> Len {
Len {
px: 0.0,
rel: 0.0,
leftover: ratio.to_f32(),
}
Len::leftover(ratio)
}
}
impl_op!(Len Add add; px rel leftover);
impl_op!(Len Sub sub; px rel leftover);
impl_op!(same Len Add add; px rel leftover);
impl_op!(same Len Sub sub; px rel leftover);
impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y);
impl_op!(same Size Add add; x y);
impl_op!(same Size Sub sub; x y);
impl Default for Len {
fn default() -> Self {
@@ -182,13 +176,13 @@ impl std::fmt::Display for Size {
impl std::fmt::Display for Len {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.px != 0.0 {
if self.px != Px::ZERO {
write!(f, "{} px;", self.px)?;
}
if self.rel != 0.0 {
if self.rel != Rel::ZERO {
write!(f, "{} rel;", self.rel)?;
}
if self.leftover != 0.0 {
if self.leftover != Weight::ZERO {
write!(f, "{} leftover;", self.leftover)?;
}
Ok(())
+3 -4
View File
@@ -189,16 +189,15 @@ impl UiScalar {
/// Both channels by the same factor, which is what a fraction of a
/// length means when the length is part pixels and part a share.
pub const fn scale(&self, by: f32) -> Self {
let by = Rel::from_f32(by);
pub const fn scale(&self, by: Rel) -> Self {
Self {
rel: self.rel.mul(by),
px: self.px.mul(by),
}
}
pub const fn offset(mut self, amt: f32) -> Self {
self.px = self.px.add(Px::from_f32(amt));
pub const fn offset(mut self, amt: Px) -> Self {
self.px = self.px.add(amt);
self
}
+7 -6
View File
@@ -1,8 +1,9 @@
#[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter};
use crate::{
Axis, Holds, Len, Px, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer,
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, Widgets,
Axis, Holds, Len, Px, RegionAlign, Rel, RenderedText, Size, StrongWidget, TextAttrs,
TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Weight,
WidgetId, Widgets,
render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
PrimitiveKind, TexturePrimitive,
@@ -484,7 +485,7 @@ pub(crate) fn declared_lens(widgets: &Widgets, id: WidgetId) -> [Option<Len>; 2]
// occupies its reported size inside the box it was offered.
widget
.and_then(|widget| widget.size_hint(axis))
.filter(|len| len.leftover == 0.0)
.filter(|len| len.leftover == Weight::ZERO)
})
})
}
@@ -509,11 +510,11 @@ pub(crate) fn placed_box(
let mut placed = region;
for (axis, declared) in AXES.into_iter().zip(declared) {
let reported = size.axis(axis);
if reported.leftover != 0.0 || declared.is_some() {
if reported.leftover != Weight::ZERO || declared.is_some() {
continue;
}
let span = placed.axis_mut(axis);
let len = span.len().scale(reported.rel) + UiScalar::px(reported.px);
let len = span.len().scale(reported.rel) + UiScalar::from_parts(Rel::ZERO, reported.px);
span.start += (span.len() - len).scale(align.axis(axis).rel());
span.end = span.start + len;
}
@@ -532,7 +533,7 @@ pub(crate) fn declared_box(
for (axis, len) in AXES.into_iter().zip(declared) {
let Some(len) = len else { continue };
let span = region.axis_mut(axis);
let len = UiScalar::new(len.rel, len.px);
let len = UiScalar::from_parts(len.rel, len.px);
span.start += (span.len() - len).scale(align.axis(axis).rel());
span.end = span.start + len;
}
+7 -6
View File
@@ -3,8 +3,8 @@ use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::ui::painter::{declared_box, declared_lens, placed_box};
use crate::{
ActiveData, Axis, DrawLayers, Holds, IdLike, Len, MaskIdx, MoveIdx, Moves, Painter,
PixelRegion, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId,
Widgets,
PixelRegion, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, Weight,
WidgetId, Widgets,
util::{HashMap, Vec2},
};
@@ -245,10 +245,11 @@ impl UiRenderState {
let mut settled = answer;
for axis in AXES {
let reported = answer.0.axis(axis);
let placed_len = match reported.leftover != 0.0 || declared[axis as usize].is_some() {
true => UiScalar::FULL,
false => UiScalar::new(reported.rel, reported.px),
};
let placed_len =
match reported.leftover != Weight::ZERO || declared[axis as usize].is_some() {
true => UiScalar::FULL,
false => UiScalar::from_parts(reported.rel, reported.px),
};
settled.1[axis as usize] =
settled.1[axis as usize].and(drawing_holds[axis as usize].through(placed_len));
}
+2 -2
View File
@@ -1,4 +1,4 @@
use crate::{Axis, Len};
use crate::{Axis, Len, Weight};
/// What a widget's length on one axis is, as a rule its parent applies where
/// it draws it rather than an answer the widget gives about itself.
@@ -24,7 +24,7 @@ impl SizeRule {
/// is resolved there.
pub fn declared(&self) -> Option<Len> {
match self {
Self::Exact(len) if len.leftover == 0.0 => Some(*len),
Self::Exact(len) if len.leftover == Weight::ZERO => Some(*len),
_ => None,
}
}