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

+19 -5
View File
@@ -38,6 +38,9 @@ pub type Rel = Fixed<24>;
impl<const SHIFT: u32> Fixed<SHIFT> { impl<const SHIFT: u32> Fixed<SHIFT> {
pub const ZERO: Self = Self(0); pub const ZERO: Self = Self(0);
pub const ONE: Self = Self::one(); pub const ONE: Self = Self::one();
/// The gap between neighbouring values, which is also how far apart two
/// numbers can be and still mean the same place.
pub const STEP: Self = Self(1);
/// Also what stands in for an unbounded end, since arithmetic saturates /// Also what stands in for an unbounded end, since arithmetic saturates
/// here rather than wrapping past it. /// here rather than wrapping past it.
pub const MIN: Self = Self(i32::MIN); pub const MIN: Self = Self(i32::MIN);
@@ -65,14 +68,25 @@ impl<const SHIFT: u32> Fixed<SHIFT> {
/// Rounds to the nearest step, and saturates rather than wrapping. A NaN /// Rounds to the nearest step, and saturates rather than wrapping. A NaN
/// has no nearest step and becomes zero, which is a caller's mistake /// has no nearest step and becomes zero, which is a caller's mistake
/// rather than a value worth carrying. /// rather than a value worth carrying.
pub fn from_f32(v: f32) -> Self { ///
/// Half-away is written out rather than called through `f32::round`,
/// which is not `const`: a layout constant has to stay a constant.
pub const fn from_f32(v: f32) -> Self {
debug_assert!(!v.is_nan(), "a NaN has no place on the grid"); debug_assert!(!v.is_nan(), "a NaN has no place on the grid");
// Float-to-int casts saturate and send NaN to zero, which is the let scaled = v * Self::one().0 as f32;
// behaviour wanted at both ends. // Above 2^23 an `f32` has no fractional part left to round, and
Self((v * Self::one().0 as f32).round() as i32) // adding a half there rounds the number itself up instead. The cast
// saturates at both ends and sends NaN to zero, which is the
// behaviour wanted at both.
const WHOLE: f32 = (1 << 23) as f32;
Self(match (scaled >= WHOLE, scaled <= -WHOLE, scaled < 0.0) {
(true, _, _) | (_, true, _) => scaled as i32,
(_, _, true) => (scaled - 0.5) as i32,
_ => (scaled + 0.5) as i32,
})
} }
pub fn to_f32(self) -> f32 { pub const fn to_f32(self) -> f32 {
self.0 as f32 / Self::one().0 as f32 self.0 as f32 / Self::one().0 as f32
} }
+1
View File
@@ -244,6 +244,7 @@ pub enum ReuseOutcome {
Moved, Moved,
Dirty, Dirty,
WrongParent, WrongParent,
WrongLayer,
Remapped, Remapped,
Outside, Outside,
Undrawn, Undrawn,
+8 -9
View File
@@ -1,4 +1,4 @@
use crate::vec2; use crate::{Px, Rel, vec2};
use super::*; use super::*;
@@ -175,14 +175,13 @@ impl Vec2 {
impl UiScalar { impl UiScalar {
pub const fn align(&self, align: AxisAlign) -> UiSpan { pub const fn align(&self, align: AxisAlign) -> UiSpan {
let rel = align.rel(); let rel = Rel::from_f32(align.rel());
let mut start = UiScalar::rel(rel); let rest = Rel::ONE.sub(rel);
start.px -= self.px * rel; let at = UiScalar::from_parts(rel, Px::ZERO);
start.rel -= self.rel * rel; UiSpan {
let mut end = UiScalar::rel(rel); start: UiScalar::from_parts(at.rel.sub(self.rel.mul(rel)), at.px.sub(self.px.mul(rel))),
end.px += self.px * (1.0 - rel); end: UiScalar::from_parts(at.rel.add(self.rel.mul(rest)), at.px.add(self.px.mul(rest))),
end.rel += self.rel * (1.0 - rel); }
UiSpan { start, end }
} }
} }
+2 -4
View File
@@ -109,10 +109,8 @@ impl Len {
}; };
pub fn apply_leftover(&self) -> UiScalar { pub fn apply_leftover(&self) -> UiScalar {
UiScalar { let share = if self.leftover > 0.0 { 1.0 } else { 0.0 };
rel: self.rel + if self.leftover > 0.0 { 1.0 } else { 0.0 }, UiScalar::new(self.rel + share, self.px)
px: self.px,
}
} }
pub fn px(px: impl UiNum) -> Self { pub fn px(px: impl UiNum) -> Self {
+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 super::*;
use crate::{ use crate::{Px, Rel, UiNum, util::impl_op};
UiNum,
util::{LerpUtil, impl_op},
};
#[repr(C)] #[repr(C)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)] #[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 { Vec2 {
x: self.x.to_px(rel.x), x: self.x.to_px(Px::from_f32(size.x)).to_f32(),
y: self.y.to_px(rel.y), y: self.y.to_px(Px::from_f32(size.y)).to_f32(),
} }
} }
@@ -93,18 +92,11 @@ impl UiVec2 {
} }
pub fn get_px(&self) -> Vec2 { 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 { pub fn get_rel(&self) -> Vec2 {
(self.x.rel, self.y.rel).into() (self.x.rel.to_f32(), self.y.rel.to_f32()).into()
}
pub fn abs_mut(&mut self) -> Vec2View<'_> {
Vec2View {
x: &mut self.x.px,
y: &mut self.y.px,
}
} }
} }
@@ -114,8 +106,8 @@ impl Display for UiVec2 {
} }
} }
impl_op!(UiVec2 Add add; x y); impl_op!(same UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y); impl_op!(same UiVec2 Sub sub; x y);
const impl From<Vec2> for UiVec2 { const impl From<Vec2> for UiVec2 {
fn from(px: Vec2) -> Self { 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)] #[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 struct UiScalar {
pub rel: f32, pub rel: Rel,
pub px: f32, pub px: Px,
} }
impl Eq for UiScalar {} impl_op!(same UiScalar Add add; rel px);
impl Hash for UiScalar { impl_op!(same UiScalar Sub sub; rel px);
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 UiScalar { impl UiScalar {
pub const ZERO: Self = Self { rel: 0.0, px: 0.0 }; pub const ZERO: Self = Self {
pub const FULL: Self = Self { rel: 1.0, px: 0.0 }; 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 { 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 } Self { rel, px }
} }
pub const fn rel(rel: f32) -> Self { 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 { 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 { pub const fn rel_min() -> Self {
Self::new(0.0, 0.0) Self::ZERO
} }
pub const fn rel_max() -> Self { pub const fn rel_max() -> Self {
Self::new(1.0, 0.0) Self::FULL
} }
pub const fn max(&self, other: Self) -> Self { 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 /// 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. /// length means when the length is part pixels and part a share.
pub const fn scale(&self, by: f32) -> Self { pub const fn scale(&self, by: f32) -> Self {
let by = Rel::from_f32(by);
Self { Self {
rel: self.rel * by, rel: self.rel.mul(by),
px: self.px * by, px: self.px.mul(by),
} }
} }
pub const fn offset(mut self, amt: f32) -> Self { pub const fn offset(mut self, amt: f32) -> Self {
self.px += amt; self.px = self.px.add(Px::from_f32(amt));
self self
} }
pub const fn within(&self, span: &UiSpan) -> 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 { Self {
rel: anchor, rel: self.rel.lerp(span.start.rel, span.end.rel),
px: offset, 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) { pub const fn flip(&mut self) {
self.rel = 1.0 - self.rel; self.rel = Rel::ONE.sub(self.rel);
self.px = -self.px; self.px = self.px.neg();
} }
pub const fn to(&self, end: Self) -> UiSpan { pub const fn to(&self, end: Self) -> UiSpan {
UiSpan { start: *self, end } UiSpan { start: *self, end }
} }
pub const fn to_px(&self, rel: f32) -> f32 { /// Resolved against a box of `len`, which is the only place a fraction
self.rel * rel + self.px /// 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) 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;
}
}
+6 -4
View File
@@ -16,11 +16,13 @@ pub struct PrimitiveInstance {
} }
impl PrimitiveInstance { impl PrimitiveInstance {
// The region's four scalars, each a `Rel` beside a `Px`: whole counts
// that the shader decodes, rather than the numbers themselves.
const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![ const ATTRIBS: [VertexAttribute; 6] = vertex_attr_array![
0 => Float32x2, 0 => Sint32x2,
1 => Float32x2, 1 => Sint32x2,
2 => Float32x2, 2 => Sint32x2,
3 => Float32x2, 3 => Sint32x2,
4 => Uint32, 4 => Uint32,
5 => Uint32, 5 => Uint32,
]; ];
+57 -19
View File
@@ -15,17 +15,54 @@ struct WindowUniform {
}; };
struct Mask { struct Mask {
x: UiSpan, x: RawSpan,
y: UiSpan, y: RawSpan,
move_idx: u32, move_idx: u32,
} }
struct MoveOffset { struct MoveOffset {
x: UiSpan, x: RawSpan,
y: UiSpan, y: RawSpan,
parent: u32, parent: u32,
} }
// What `iris_core` stores: a whole count of a sixty-fourth of a pixel, and of
// a `1 / 2^24` of a box. Both steps are powers of two, so decoding one is
// exact and the number here is the number the CPU decided.
const PX_STEP: f32 = 1.0 / 64.0;
const REL_STEP: f32 = 1.0 / 16777216.0;
// Every coordinate the CPU decided is a whole count of `PX_STEP`, so one that
// composes to within half a step of a pixel boundary is on that boundary and
// belongs to the pixel above it. Flooring the product instead drops a pixel
// wherever a fraction divides a window exactly: a fifth of 1920 comes out of
// `REL_STEP` as 383.99998, and five tabs each lose their last column.
fn snap_floor(v: vec2<f32>) -> vec2<f32> {
return floor(v + PX_STEP * 0.5);
}
struct RawScalar {
rel: i32,
px: i32,
}
struct RawSpan {
start: RawScalar,
end: RawScalar,
}
fn scalar_of(raw: RawScalar) -> UiScalar {
return UiScalar(f32(raw.rel) * REL_STEP, f32(raw.px) * PX_STEP);
}
fn span_of(raw: RawSpan) -> UiSpan {
return UiSpan(scalar_of(raw.start), scalar_of(raw.end));
}
fn scalar_of_pair(raw: vec2<i32>) -> UiScalar {
return UiScalar(f32(raw.x) * REL_STEP, f32(raw.y) * PX_STEP);
}
struct Region { struct Region {
x: UiSpan, x: UiSpan,
y: UiSpan, y: UiSpan,
@@ -37,9 +74,10 @@ const MOVE_NONE: u32 = 4294967295u;
// resolve a deep one the same way. // resolve a deep one the same way.
const CHAIN_LIMIT: u32 = 64u; const CHAIN_LIMIT: u32 = 64u;
// Written the way `UiScalar::within` writes it rather than as `mix`, so the // The same expression `UiScalar::within` uses, in floats rather than on the
// CPU and the shader compose a position with the same arithmetic and answer // CPU's grid: a move is resolved here so that scrolling a subtree writes one
// the same thing about where a widget is. // entry instead of walking it. What has to hold is that this agrees with
// itself frame to frame, not that it matches the CPU to the last bit.
fn scalar_within(s: UiScalar, p: UiSpan) -> UiScalar { fn scalar_within(s: UiScalar, p: UiSpan) -> UiScalar {
return UiScalar( return UiScalar(
p.start.rel + (p.end.rel - p.start.rel) * s.rel, p.start.rel + (p.end.rel - p.start.rel) * s.rel,
@@ -59,7 +97,7 @@ fn resolve_move(idx: u32, local: Region) -> Region {
break; break;
} }
let entry = move_offsets[at]; let entry = move_offsets[at];
r = Region(span_within(r.x, entry.x), span_within(r.y, entry.y)); r = Region(span_within(r.x, span_of(entry.x)), span_within(r.y, span_of(entry.y)));
at = entry.parent; at = entry.parent;
} }
return r; return r;
@@ -76,10 +114,10 @@ struct UiScalar {
} }
struct InstanceInput { struct InstanceInput {
@location(0) x_start: vec2<f32>, @location(0) x_start: vec2<i32>,
@location(1) x_end: vec2<f32>, @location(1) x_end: vec2<i32>,
@location(2) y_start: vec2<f32>, @location(2) y_start: vec2<i32>,
@location(3) y_end: vec2<f32>, @location(3) y_end: vec2<i32>,
@location(4) mask_idx: u32, @location(4) mask_idx: u32,
@location(5) move_idx: u32, @location(5) move_idx: u32,
} }
@@ -102,8 +140,8 @@ fn vs_main(
var out: VertexOutput; var out: VertexOutput;
let local = Region( let local = Region(
UiSpan(UiScalar(in.x_start.x, in.x_start.y), UiScalar(in.x_end.x, in.x_end.y)), UiSpan(scalar_of_pair(in.x_start), scalar_of_pair(in.x_end)),
UiSpan(UiScalar(in.y_start.x, in.y_start.y), UiScalar(in.y_end.x, in.y_end.y)), UiSpan(scalar_of_pair(in.y_start), scalar_of_pair(in.y_end)),
); );
let r = resolve_move(in.move_idx, local); let r = resolve_move(in.move_idx, local);
let top_left_rel = vec2(r.x.start.rel, r.y.start.rel); let top_left_rel = vec2(r.x.start.rel, r.y.start.rel);
@@ -111,8 +149,8 @@ fn vs_main(
let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel); let bot_right_rel = vec2(r.x.end.rel, r.y.end.rel);
let bot_right_px = vec2(r.x.end.px, r.y.end.px); let bot_right_px = vec2(r.x.end.px, r.y.end.px);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_px); let top_left = snap_floor(top_left_rel * window.dim) + snap_floor(top_left_px);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_px); let bot_right = snap_floor(bot_right_rel * window.dim) + snap_floor(bot_right_px);
let size = bot_right - top_left; let size = bot_right - top_left;
let uv = vec2<f32>( let uv = vec2<f32>(
@@ -137,14 +175,14 @@ fn masked(in: VertexOutput, color: vec4<f32>) -> vec4<f32> {
let mask = masks[in.mask_idx]; let mask = masks[in.mask_idx];
// Its own chain, not the drawn primitive's, so a stationary viewport // Its own chain, not the drawn primitive's, so a stationary viewport
// clips content that moves inside it. // clips content that moves inside it.
let m = resolve_move(mask.move_idx, Region(mask.x, mask.y)); let m = resolve_move(mask.move_idx, Region(span_of(mask.x), span_of(mask.y)));
let tl = vec2(m.x.start.rel, m.y.start.rel); let tl = vec2(m.x.start.rel, m.y.start.rel);
let tl_px = vec2(m.x.start.px, m.y.start.px); let tl_px = vec2(m.x.start.px, m.y.start.px);
let br = vec2(m.x.end.rel, m.y.end.rel); let br = vec2(m.x.end.rel, m.y.end.rel);
let br_px = vec2(m.x.end.px, m.y.end.px); let br_px = vec2(m.x.end.px, m.y.end.px);
let top_left = floor(tl * window.dim) + floor(tl_px); let top_left = snap_floor(tl * window.dim) + snap_floor(tl_px);
let bot_right = floor(br * window.dim) + floor(br_px); let bot_right = snap_floor(br * window.dim) + snap_floor(br_px);
let pos = in.clip_position.xy; let pos = in.clip_position.xy;
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y { if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
return color * 0.0; return color * 0.0;
+5 -4
View File
@@ -1,4 +1,4 @@
use crate::UiScalar; use crate::{Rel, UiScalar};
use std::ops::RangeInclusive; use std::ops::RangeInclusive;
/// The lengths of a box, in pixels, that one drawing of a widget holds for: /// The lengths of a box, in pixels, that one drawing of a widget holds for:
@@ -59,11 +59,12 @@ impl Holds {
/// in this range. A part with no relative extent is a fixed length: it /// in this range. A part with no relative extent is a fixed length: it
/// was drawn at that length and any box keeps it there. /// was drawn at that length and any box keeps it there.
pub fn through(self, len: UiScalar) -> Self { pub fn through(self, len: UiScalar) -> Self {
if len.rel == 0.0 { if len.rel == Rel::ZERO {
return Self::ANY; return Self::ANY;
} }
let a = (self.lo - len.px) / len.rel; let (rel, px) = (len.rel.to_f32(), len.px.to_f32());
let b = (self.hi - len.px) / len.rel; let a = (self.lo - px) / rel;
let b = (self.hi - px) / rel;
Self { Self {
lo: a.min(b), lo: a.min(b),
hi: a.max(b), hi: a.max(b),
+3 -3
View File
@@ -1,7 +1,7 @@
#[cfg(feature = "layout-diagnostics")] #[cfg(feature = "layout-diagnostics")]
use crate::layout_diagnostics::{self as diag, Counter}; use crate::layout_diagnostics::{self as diag, Counter};
use crate::{ use crate::{
Axis, Holds, Len, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, Axis, Holds, Len, Px, RegionAlign, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer,
TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, Widgets, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId, Widgets,
render::{ render::{
GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
@@ -293,8 +293,8 @@ impl<'a> Painter<'a> {
fn px_within_offer(&self, local: UiRegion) -> Vec2 { fn px_within_offer(&self, local: UiRegion) -> Vec2 {
let size = local.size(); let size = local.size();
Vec2::new( Vec2::new(
size.x.to_px(self.offered_px.x), size.x.to_px(Px::from_f32(self.offered_px.x)).to_f32(),
size.y.to_px(self.offered_px.y), size.y.to_px(Px::from_f32(self.offered_px.y)).to_f32(),
) )
} }
+16 -6
View File
@@ -3,7 +3,7 @@ use crate::layout_diagnostics::{self as diag, Counter, ReuseOutcome, TimerKind};
use crate::ui::painter::{declared_box, declared_lens, placed_box}; use crate::ui::painter::{declared_box, declared_lens, placed_box};
use crate::{ use crate::{
ActiveData, Axis, DrawLayers, Holds, IdLike, Len, MaskIdx, MoveIdx, Moves, Painter, ActiveData, Axis, DrawLayers, Holds, IdLike, Len, MaskIdx, MoveIdx, Moves, Painter,
PixelRegion, RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId, PixelRegion, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, WidgetId,
Widgets, Widgets,
util::{HashMap, Vec2}, util::{HashMap, Vec2},
}; };
@@ -569,6 +569,16 @@ impl UiRenderState {
if has_region_node != info.region_node { if has_region_node != info.region_node {
return None; return None;
} }
// Drawn on another layer: the drawing sits in that layer's list and
// paints at its moment, which no amount of geometry says. A container
// that measures a child by drawing it and then draws it again where
// it belongs -- `Stack`, over its background -- asks the second time
// on a layer the first answer is not good for.
if active.layer != info.layer {
#[cfg(feature = "layout-diagnostics")]
diag::reuse(id, ReuseOutcome::WrongLayer);
return None;
}
// Drawn somewhere else in the tree: its box is in coordinates it no // Drawn somewhere else in the tree: its box is in coordinates it no
// longer sits in, and its slot names the wrong parent. // longer sits in, and its slot names the wrong parent.
if active.parent_move != info.parent_move { if active.parent_move != info.parent_move {
@@ -1034,14 +1044,14 @@ impl RegionRemap {
fn apply_scalar(self, scalar: UiScalar, from: UiSpan, to: UiSpan) -> UiScalar { fn apply_scalar(self, scalar: UiScalar, from: UiSpan, to: UiSpan) -> UiScalar {
let extent = from.end.rel - from.start.rel; let extent = from.end.rel - from.start.rel;
if extent == 0.0 { if extent == Rel::ZERO {
return scalar + to.start - from.start; return scalar + to.start - from.start;
} }
let fraction = (scalar.rel - from.start.rel) / extent; let fraction = (scalar.rel - from.start.rel) / extent;
let from_px = from.start.px + fraction * (from.end.px - from.start.px); let from_px = fraction.lerp(from.start.px, from.end.px);
let to_rel = to.start.rel + fraction * (to.end.rel - to.start.rel); let to_rel = fraction.lerp(to.start.rel, to.end.rel);
let to_px = to.start.px + fraction * (to.end.px - to.start.px); let to_px = fraction.lerp(to.start.px, to.end.px);
UiScalar::new(to_rel, scalar.px - from_px + to_px) UiScalar::from_parts(to_rel, scalar.px - from_px + to_px)
} }
} }
+28
View File
@@ -56,6 +56,34 @@ macro_rules! impl_op {
} }
} }
}; };
// Without the `f32` operations, for a type whose fields are not all the
// same kind of number: there is nothing a bare float means to a fraction
// and an offset at once.
(same $T:ident $op:ident $fn:ident $opa:ident $fna:ident; $($field:ident)*) => {
#[allow(non_snake_case)]
mod ${concat($T, _op_, $fn, _same_impl)} {
use super::*;
#[allow(unused_imports)]
use std::ops::*;
const impl $op for $T {
type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output {
Self {
$($field: self.$field.$fn(rhs.$field),)*
}
}
}
const impl $opa for $T {
fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs);
}
}
}
};
(same $T:ident $op:ident $fn:ident; $($field:ident)*) => {
impl_op!(same $T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*);
};
($T:ident $op:ident $fn:ident; $($field:ident)*) => { ($T:ident $op:ident $fn:ident; $($field:ident)*) => {
impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*); impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*);
}; };
+4 -1
View File
@@ -113,7 +113,10 @@ impl Widget for Branch {
let mut top = UiRegion::FULL; let mut top = UiRegion::FULL;
top.y.end = top.y.start.offset(40.0); top.y.end = top.y.start.offset(40.0);
let measured = painter.widget_within(&self.probe, top).len(Axis::X); let measured = painter.widget_within(&self.probe, top).len(Axis::X);
let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let px = measured
.apply_leftover()
.to_px(Px::from_f32(painter.px_len(Axis::X)))
.to_f32();
let mut below = UiRegion::FULL; let mut below = UiRegion::FULL;
below.y.start = below.y.start.offset(40.0); below.y.start = below.y.start.offset(40.0);
+4 -4
View File
@@ -49,10 +49,10 @@ impl Padding {
} }
pub fn region(&self) -> UiRegion { pub fn region(&self) -> UiRegion {
let mut region = UiRegion::FULL; let mut region = UiRegion::FULL;
region.x.start.px += self.left; region.x.start.px += Px::from_f32(self.left);
region.y.start.px += self.top; region.y.start.px += Px::from_f32(self.top);
region.x.end.px -= self.right; region.x.end.px -= Px::from_f32(self.right);
region.y.end.px -= self.bottom; region.y.end.px -= Px::from_f32(self.bottom);
region region
} }
pub fn x(amt: impl UiNum) -> Self { pub fn x(amt: impl UiNum) -> Self {
+4 -3
View File
@@ -20,7 +20,7 @@ impl Widget for Scroll {
}; };
let content = answer_len.apply_leftover(); let content = answer_len.apply_leftover();
self.container_len = container_len; self.container_len = container_len;
self.content_len = content.to_px(container_len); self.content_len = content.to_px(Px::from_f32(container_len)).to_f32();
if self.snap_end { if self.snap_end {
self.amt = self.content_len - self.container_len; self.amt = self.content_len - self.container_len;
@@ -33,9 +33,10 @@ impl Widget for Scroll {
// the drawing holds for that length alone. One scrolled part way sits // the drawing holds for that length alone. One scrolled part way sits
// where it is until the box shrinks past what is left of it. Kept to // where it is until the box shrinks past what is left of it. Kept to
// the end, it moves with every length. // the end, it moves with every length.
if content.rel == 0.0 && self.content_len <= self.container_len && align == AxisAlign::NEG { let fixed_len = content.rel == Rel::ZERO;
if fixed_len && self.content_len <= self.container_len && align == AxisAlign::NEG {
painter.holds(self.axis, self.content_len..=f32::INFINITY); painter.holds(self.axis, self.content_len..=f32::INFINITY);
} else if content.rel == 0.0 && !self.snap_end { } else if fixed_len && !self.snap_end {
let left = self.content_len - self.amt; let left = self.content_len - self.amt;
painter.holds(self.axis, f32::NEG_INFINITY..=left); painter.holds(self.axis, f32::NEG_INFINITY..=left);
} }
+7 -6
View File
@@ -35,8 +35,9 @@ impl Widget for Span {
Some(len) => len, Some(len) => len,
None => painter.widget_within(child, region).len(axis), None => painter.widget_within(child, region).len(axis),
}; };
cursor.px += len.px + self.gap; // Onto the grid at the seam: `Len` is still in floats.
cursor.rel += len.rel; cursor.px += Px::from_f32(len.px + self.gap);
cursor.rel += Rel::from_f32(len.rel);
lens.push(len); lens.push(len);
} }
@@ -90,7 +91,7 @@ impl Widget for Span {
// pixels or a fraction keeps those and overflows. // 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 > 0.0 && len.px == 0.0 && len.rel == 0.0 && !shares {
painter.undraw(child); painter.undraw(child);
start.px += self.gap; start.px += Px::from_f32(self.gap);
continue; continue;
} }
let mut span = UiSpan::FULL; let mut span = UiSpan::FULL;
@@ -101,8 +102,8 @@ impl Widget for Span {
let end = (UiScalar::rel_max() + start) - offset; let end = (UiScalar::rel_max() + start) - offset;
start = rel_end.within(&start.to(end)); start = rel_end.within(&start.to(end));
} }
start.px += len.px; start.px += Px::from_f32(len.px);
start.rel += len.rel; start.rel += Rel::from_f32(len.rel);
span.end = start; span.end = start;
let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL);
if self.dir.sign == Sign::Neg { if self.dir.sign == Sign::Neg {
@@ -121,7 +122,7 @@ impl Widget for Span {
ortho.px = ortho.px.max(used.px); ortho.px = ortho.px.max(used.px);
} }
} }
start.px += self.gap; start.px += Px::from_f32(self.gap);
} }
// Carried whole rather than collapsed to one share: a span that sizes // Carried whole rather than collapsed to one share: a span that sizes
+1 -1
View File
@@ -79,7 +79,7 @@ fn fill(ui: &mut UiData, render: &mut UiRenderState, depth: usize) {
slot = render.moves.push(slot, UiRegion::FULL); slot = render.moves.push(slot, UiRegion::FULL);
} }
let px = |v: f32| UiScalar { rel: 0.0, px: v }; let px = |v: f32| UiScalar::px(v);
for i in 0..INSTANCES { for i in 0..INSTANCES {
let x = (i % (SIZE as usize / 2)) as f32 * 2.0; let x = (i % (SIZE as usize / 2)) as f32 * 2.0;
let y = (i / (SIZE as usize / 2)) as f32; let y = (i / (SIZE as usize / 2)) as f32;
+4 -1
View File
@@ -24,7 +24,10 @@ impl Widget for BranchesOnMeasurement {
let mut top = UiRegion::FULL; let mut top = UiRegion::FULL;
top.y.end = top.y.start.offset(40.0); top.y.end = top.y.start.offset(40.0);
let measured = painter.widget_within(&self.probe, top).len(Axis::X); let measured = painter.widget_within(&self.probe, top).len(Axis::X);
let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let px = measured
.apply_leftover()
.to_px(Px::from_f32(painter.px_len(Axis::X)))
.to_f32();
let mut below = UiRegion::FULL; let mut below = UiRegion::FULL;
below.y.start = below.y.start.offset(40.0); below.y.start = below.y.start.offset(40.0);
+5 -2
View File
@@ -295,12 +295,15 @@ fn an_uneven_nesting_still_gives_every_share_the_same_length() {
} }
/// Where the shader puts an edge: the two parts of a scalar are floored /// Where the shader puts an edge: the two parts of a scalar are floored
/// apart, so a fraction and a pixel offset snap independently. /// apart, so a fraction and a pixel offset snap independently, and each is
/// taken to the boundary it composes to within half a step of. Kept in step
/// with `snap_floor` in `prelude.wgsl`.
fn drawn_edges(h: &Harness, id: WidgetId, axis: Axis) -> (f32, f32) { fn drawn_edges(h: &Harness, id: WidgetId, axis: Axis) -> (f32, f32) {
let active = &h.render.active[&id]; let active = &h.render.active[&id];
let region = h.render.moves.resolve(active.parent_move, active.region); let region = h.render.moves.resolve(active.parent_move, active.region);
let dim = h.size().axis(axis); let dim = h.size().axis(axis);
let edge = |s: UiScalar| (s.rel * dim).floor() + s.px.floor(); let snap = |v: f32| (v + Px::STEP.to_f32() * 0.5).floor();
let edge = |s: UiScalar| snap(s.rel.to_f32() * dim) + snap(s.px.to_f32());
let span = region.axis(axis); let span = region.axis(axis);
(edge(span.start), edge(span.end)) (edge(span.start), edge(span.end))
} }
+33
View File
@@ -550,3 +550,36 @@ fn a_declared_length_child_is_not_redrawn_when_the_box_around_it_grows() {
assert_eq!(draws.get(), settled, "its own length did not change"); assert_eq!(draws.get(), settled, "its own length did not change");
assert_corners!(h, fixed, (200, 0), (280, 200)); assert_corners!(h, fixed, (200, 0), (280, 200));
} }
/// `Stack` measures the child that sizes it by drawing it, then draws it
/// again above the background it stacks over. The second ask is for the same
/// box, so nothing geometric says the answer has gone stale, and reusing it
/// leaves the drawing under the background. The same tree got away with it
/// while the two asks differed by a rounding.
#[test]
fn a_widget_asked_again_on_another_layer_is_drawn_there() {
let mut h = Harness::new((400, 200));
let background = rect(Color::RED).add(&mut h.rsc);
let (front, draws) = counted(&mut h, Size::from((100, 50)), false);
let stack = Stack {
children: vec![
background.add_strong(&mut h.rsc),
front.add_strong(&mut h.rsc),
],
size: StackSize::Child(1),
}
.add(&mut h.rsc);
h.set_root(stack);
h.frame();
let layer = |id| h.render.active[&id].layer;
assert_ne!(
layer(front.id()),
layer(stack.id()),
"the measured drawing was left on the stack's own layer"
);
assert_ne!(layer(front.id()), layer(background.id()));
// Measured once for the size and once where it goes, which is what the
// stack costs and not something this test is asserting a number for.
assert_eq!(draws.get(), 2);
}