Put positions on the grid, and decode them in the shader

`UiScalar` is `Rel` beside `Px` rather than two floats, so composing a
position down a chain of boxes adds exactly and rounds only at the two
multiplies `within` makes. `UiSpan`, `UiRegion` and `UiVec2` follow it, the
hand-written `Hash` goes away with the bits it hashed, and `impl_op!` grows a
`same` form for a type whose fields are not the same kind of number.

`Len` is still floats, so the seam converts: `Px::from_f32` where a span adds
a child's length to its cursor, and `to_f32` where something outside layout
wants pixels. Those go when `Len` follows.

The GPU reads what the CPU wrote: the instance attributes are `Sint32x2` and
the shader decodes by `1/64` and `1/2^24`, both exact in `f32`, then composes
the move chain in floats as before. It has to agree with itself frame to
frame rather than with the CPU to the last bit.

Two things fell out of making the numbers exact.

`floor` at the rasteriser was picking the pixel below wherever a fraction
divided a window exactly. A fifth of 1920 is 383.99998 through a rounded
`Rel` -- and was 384.0 through an `f32` that happened to round up -- so five
tabs each lost their last column. `snap_floor` takes a coordinate within half
a step of a boundary to be on it, which is the same rule as everywhere else
here: decide where values do not land.

A widget measured on one layer and drawn again on another kept the first
layer, because `try_reuse` compared everything about a retained drawing
except which list it sits in. `Stack` does exactly that for its background,
so every panel's text went under its own background. It only worked before
because the two asks differed by a rounding and forced a redraw;
`tests/retained.rs` pins it now, and `ReuseOutcome` can say `WrongLayer`.

Checked: fmt, clippy, 100 tests, 100 generated seeds in 70 s, all five
shrinker cases at 300 seeds. `tabs`, `view` and `minimal` render
byte-identical at 1920x1200; `random` differs in 36 pixels by one level;
`text` differs where glyph origins moved onto the grid -- same positions,
same spacing, different subpixel coverage, checked at 6x against the old
render.

Measured on the way: with the fuzzer comparing for *equality* rather than
within 0.05 px, `resize`, `repaint` and `size-change` already pass 100 seeds.
`reorder` fails one seed by exactly one step, which is the `Len` seam above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 01:22:12 -04:00
1 parent 7548139861
commit 4e28f1047e
19 files changed
+256 -138

No files matched your search

+49 -66
View File
@@ -1,10 +1,7 @@
use std::{fmt::Display, hash::Hash, marker::Destruct};
use std::{fmt::Display, marker::Destruct};
use super::*;
use crate::{
UiNum,
util::{LerpUtil, impl_op},
};
use crate::{Px, Rel, UiNum, util::impl_op};
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)]
@@ -70,10 +67,12 @@ impl UiVec2 {
}
}
pub fn to_px(&self, rel: Vec2) -> Vec2 {
/// Resolved against a box of `size`, in whole `f32` pixels for a caller
/// outside layout -- a pointer position, or something being drawn.
pub fn to_px(&self, size: Vec2) -> Vec2 {
Vec2 {
x: self.x.to_px(rel.x),
y: self.y.to_px(rel.y),
x: self.x.to_px(Px::from_f32(size.x)).to_f32(),
y: self.y.to_px(Px::from_f32(size.y)).to_f32(),
}
}
@@ -93,18 +92,11 @@ impl UiVec2 {
}
pub fn get_px(&self) -> Vec2 {
(self.x.px, self.y.px).into()
(self.x.px.to_f32(), self.y.px.to_f32()).into()
}
pub fn get_rel(&self) -> Vec2 {
(self.x.rel, self.y.rel).into()
}
pub fn abs_mut(&mut self) -> Vec2View<'_> {
Vec2View {
x: &mut self.x.px,
y: &mut self.y.px,
}
(self.x.rel.to_f32(), self.y.rel.to_f32()).into()
}
}
@@ -114,8 +106,8 @@ impl Display for UiVec2 {
}
}
impl_op!(UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y);
impl_op!(same UiVec2 Add add; x y);
impl_op!(same UiVec2 Sub sub; x y);
const impl From<Vec2> for UiVec2 {
fn from(px: Vec2) -> Self {
@@ -132,46 +124,53 @@ where
}
}
/// A position along one axis, as a fraction of the box it sits in plus an
/// offset: `rel * len + px`. Both parts are fixed point, so composing one
/// through a chain of boxes rounds only where it multiplies and lands on the
/// same number as any other route to the same place.
#[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, bytemuck::Pod, Default, bytemuck::Zeroable)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, Default, bytemuck::Zeroable)]
pub struct UiScalar {
pub rel: f32,
pub px: f32,
pub rel: Rel,
pub px: Px,
}
impl Eq for UiScalar {}
impl Hash for UiScalar {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
state.write_u32(self.rel.to_bits());
state.write_u32(self.px.to_bits());
}
}
impl_op!(UiScalar Add add; rel px);
impl_op!(UiScalar Sub sub; rel px);
impl_op!(same UiScalar Add add; rel px);
impl_op!(same UiScalar Sub sub; rel px);
impl UiScalar {
pub const ZERO: Self = Self { rel: 0.0, px: 0.0 };
pub const FULL: Self = Self { rel: 1.0, px: 0.0 };
pub const ZERO: Self = Self {
rel: Rel::ZERO,
px: Px::ZERO,
};
pub const FULL: Self = Self {
rel: Rel::ONE,
px: Px::ZERO,
};
pub const fn new(rel: f32, px: f32) -> Self {
Self::from_parts(Rel::from_f32(rel), Px::from_f32(px))
}
/// From parts already on the grid, rather than numbers to be put on it.
pub const fn from_parts(rel: Rel, px: Px) -> Self {
Self { rel, px }
}
pub const fn rel(rel: f32) -> Self {
Self { rel, px: 0.0 }
Self::from_parts(Rel::from_f32(rel), Px::ZERO)
}
pub const fn px(px: f32) -> Self {
Self { rel: 0.0, px }
Self::from_parts(Rel::ZERO, Px::from_f32(px))
}
pub const fn rel_min() -> Self {
Self::new(0.0, 0.0)
Self::ZERO
}
pub const fn rel_max() -> Self {
Self::new(1.0, 0.0)
Self::FULL
}
pub const fn max(&self, other: Self) -> Self {
@@ -191,23 +190,22 @@ 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);
Self {
rel: self.rel * by,
px: self.px * by,
rel: self.rel.mul(by),
px: self.px.mul(by),
}
}
pub const fn offset(mut self, amt: f32) -> Self {
self.px += amt;
self.px = self.px.add(Px::from_f32(amt));
self
}
pub const fn within(&self, span: &UiSpan) -> Self {
let anchor = self.rel.lerp(span.start.rel, span.end.rel);
let offset = self.px + self.rel.lerp(span.start.px, span.end.px);
Self {
rel: anchor,
px: offset,
rel: self.rel.lerp(span.start.rel, span.end.rel),
px: self.px.add(self.rel.lerp(span.start.px, span.end.px)),
}
}
@@ -223,16 +221,18 @@ impl UiScalar {
}
pub const fn flip(&mut self) {
self.rel = 1.0 - self.rel;
self.px = -self.px;
self.rel = Rel::ONE.sub(self.rel);
self.px = self.px.neg();
}
pub const fn to(&self, end: Self) -> UiSpan {
UiSpan { start: *self, end }
}
pub const fn to_px(&self, rel: f32) -> f32 {
self.rel * rel + self.px
/// Resolved against a box of `len`, which is the only place a fraction
/// becomes a number of pixels.
pub const fn to_px(&self, len: Px) -> Px {
self.px.add(len.mul(self.rel))
}
}
@@ -427,20 +427,3 @@ impl Display for PixelRegion {
write!(f, "{} -> {}", self.top_left, self.bot_right)
}
}
pub struct Vec2View<'a> {
pub x: &'a mut f32,
pub y: &'a mut f32,
}
impl Vec2View<'_> {
pub fn set(&mut self, other: Vec2) {
*self.x = other.x;
*self.y = other.y;
}
pub fn add(&mut self, other: Vec2) {
*self.x += other.x;
*self.y += other.y;
}
}