From 4e28f1047e8e8509a4800b05d88a77c3f86644aa Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 01:22:12 -0400 Subject: [PATCH] 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 --- core/src/fixed.rs | 24 ++++-- core/src/layout_diagnostics.rs | 1 + core/src/orientation/align.rs | 17 ++-- core/src/orientation/len.rs | 6 +- core/src/orientation/pos.rs | 115 ++++++++++++---------------- core/src/render/data.rs | 10 ++- core/src/render/shader/prelude.wgsl | 76 +++++++++++++----- core/src/ui/holds.rs | 9 ++- core/src/ui/painter.rs | 6 +- core/src/ui/render_state.rs | 22 ++++-- core/src/util/math.rs | 28 +++++++ src/random.rs | 5 +- src/widget/position/pad.rs | 8 +- src/widget/position/scroll.rs | 7 +- src/widget/position/span.rs | 13 ++-- tests/chain_cost.rs | 2 +- tests/determinism.rs | 5 +- tests/layout.rs | 7 +- tests/retained.rs | 33 ++++++++ 19 files changed, 256 insertions(+), 138 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index c1df01f..637fd61 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -38,6 +38,9 @@ pub type Rel = Fixed<24>; impl Fixed { pub const ZERO: Self = Self(0); 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 /// here rather than wrapping past it. pub const MIN: Self = Self(i32::MIN); @@ -65,14 +68,25 @@ impl Fixed { /// 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 /// 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"); - // Float-to-int casts saturate and send NaN to zero, which is the - // behaviour wanted at both ends. - Self((v * Self::one().0 as f32).round() as i32) + let scaled = v * Self::one().0 as f32; + // Above 2^23 an `f32` has no fractional part left to round, and + // 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 } diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index 4aef559..7a10bd9 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -244,6 +244,7 @@ pub enum ReuseOutcome { Moved, Dirty, WrongParent, + WrongLayer, Remapped, Outside, Undrawn, diff --git a/core/src/orientation/align.rs b/core/src/orientation/align.rs index 7bc309d..e47d372 100644 --- a/core/src/orientation/align.rs +++ b/core/src/orientation/align.rs @@ -1,4 +1,4 @@ -use crate::vec2; +use crate::{Px, Rel, vec2}; use super::*; @@ -175,14 +175,13 @@ impl Vec2 { impl UiScalar { pub const fn align(&self, align: AxisAlign) -> UiSpan { - let rel = align.rel(); - let mut start = UiScalar::rel(rel); - start.px -= self.px * rel; - start.rel -= self.rel * rel; - let mut end = UiScalar::rel(rel); - end.px += self.px * (1.0 - rel); - end.rel += self.rel * (1.0 - rel); - UiSpan { start, end } + let rel = Rel::from_f32(align.rel()); + let rest = Rel::ONE.sub(rel); + let at = UiScalar::from_parts(rel, Px::ZERO); + UiSpan { + start: UiScalar::from_parts(at.rel.sub(self.rel.mul(rel)), at.px.sub(self.px.mul(rel))), + end: UiScalar::from_parts(at.rel.add(self.rel.mul(rest)), at.px.add(self.px.mul(rest))), + } } } diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 757c7f6..e77bbb0 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -109,10 +109,8 @@ impl Len { }; pub fn apply_leftover(&self) -> UiScalar { - UiScalar { - rel: self.rel + if self.leftover > 0.0 { 1.0 } else { 0.0 }, - px: self.px, - } + let share = if self.leftover > 0.0 { 1.0 } else { 0.0 }; + UiScalar::new(self.rel + share, self.px) } pub fn px(px: impl UiNum) -> Self { diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index d271ad9..8a97ad6 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -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 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(&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; - } -} diff --git a/core/src/render/data.rs b/core/src/render/data.rs index 16a2747..8bd7eb5 100644 --- a/core/src/render/data.rs +++ b/core/src/render/data.rs @@ -16,11 +16,13 @@ pub struct 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![ - 0 => Float32x2, - 1 => Float32x2, - 2 => Float32x2, - 3 => Float32x2, + 0 => Sint32x2, + 1 => Sint32x2, + 2 => Sint32x2, + 3 => Sint32x2, 4 => Uint32, 5 => Uint32, ]; diff --git a/core/src/render/shader/prelude.wgsl b/core/src/render/shader/prelude.wgsl index 469e231..269bc2a 100644 --- a/core/src/render/shader/prelude.wgsl +++ b/core/src/render/shader/prelude.wgsl @@ -15,17 +15,54 @@ struct WindowUniform { }; struct Mask { - x: UiSpan, - y: UiSpan, + x: RawSpan, + y: RawSpan, move_idx: u32, } struct MoveOffset { - x: UiSpan, - y: UiSpan, + x: RawSpan, + y: RawSpan, 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) -> vec2 { + 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) -> UiScalar { + return UiScalar(f32(raw.x) * REL_STEP, f32(raw.y) * PX_STEP); +} + struct Region { x: UiSpan, y: UiSpan, @@ -37,9 +74,10 @@ const MOVE_NONE: u32 = 4294967295u; // resolve a deep one the same way. const CHAIN_LIMIT: u32 = 64u; -// Written the way `UiScalar::within` writes it rather than as `mix`, so the -// CPU and the shader compose a position with the same arithmetic and answer -// the same thing about where a widget is. +// The same expression `UiScalar::within` uses, in floats rather than on the +// CPU's grid: a move is resolved here so that scrolling a subtree writes one +// 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 { return UiScalar( p.start.rel + (p.end.rel - p.start.rel) * s.rel, @@ -59,7 +97,7 @@ fn resolve_move(idx: u32, local: Region) -> Region { break; } 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; } return r; @@ -76,10 +114,10 @@ struct UiScalar { } struct InstanceInput { - @location(0) x_start: vec2, - @location(1) x_end: vec2, - @location(2) y_start: vec2, - @location(3) y_end: vec2, + @location(0) x_start: vec2, + @location(1) x_end: vec2, + @location(2) y_start: vec2, + @location(3) y_end: vec2, @location(4) mask_idx: u32, @location(5) move_idx: u32, } @@ -102,8 +140,8 @@ fn vs_main( var out: VertexOutput; let local = Region( - UiSpan(UiScalar(in.x_start.x, in.x_start.y), UiScalar(in.x_end.x, in.x_end.y)), - UiSpan(UiScalar(in.y_start.x, in.y_start.y), UiScalar(in.y_end.x, in.y_end.y)), + UiSpan(scalar_of_pair(in.x_start), scalar_of_pair(in.x_end)), + UiSpan(scalar_of_pair(in.y_start), scalar_of_pair(in.y_end)), ); let r = resolve_move(in.move_idx, local); 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_px = vec2(r.x.end.px, r.y.end.px); - let top_left = floor(top_left_rel * window.dim) + floor(top_left_px); - let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_px); + let top_left = snap_floor(top_left_rel * window.dim) + snap_floor(top_left_px); + let bot_right = snap_floor(bot_right_rel * window.dim) + snap_floor(bot_right_px); let size = bot_right - top_left; let uv = vec2( @@ -137,14 +175,14 @@ fn masked(in: VertexOutput, color: vec4) -> vec4 { let mask = masks[in.mask_idx]; // Its own chain, not the drawn primitive's, so a stationary viewport // 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_px = vec2(m.x.start.px, m.y.start.px); let br = vec2(m.x.end.rel, m.y.end.rel); let br_px = vec2(m.x.end.px, m.y.end.px); - let top_left = floor(tl * window.dim) + floor(tl_px); - let bot_right = floor(br * window.dim) + floor(br_px); + let top_left = snap_floor(tl * window.dim) + snap_floor(tl_px); + let bot_right = snap_floor(br * window.dim) + snap_floor(br_px); 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 { return color * 0.0; diff --git a/core/src/ui/holds.rs b/core/src/ui/holds.rs index 61e8e6d..ff0ec0b 100644 --- a/core/src/ui/holds.rs +++ b/core/src/ui/holds.rs @@ -1,4 +1,4 @@ -use crate::UiScalar; +use crate::{Rel, UiScalar}; use std::ops::RangeInclusive; /// 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 /// was drawn at that length and any box keeps it there. pub fn through(self, len: UiScalar) -> Self { - if len.rel == 0.0 { + if len.rel == Rel::ZERO { return Self::ANY; } - let a = (self.lo - len.px) / len.rel; - let b = (self.hi - len.px) / len.rel; + let (rel, px) = (len.rel.to_f32(), len.px.to_f32()); + let a = (self.lo - px) / rel; + let b = (self.hi - px) / rel; Self { lo: a.min(b), hi: a.max(b), diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index f37a673..fa7428b 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -1,7 +1,7 @@ #[cfg(feature = "layout-diagnostics")] use crate::layout_diagnostics::{self as diag, Counter}; 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, render::{ GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst, @@ -293,8 +293,8 @@ impl<'a> Painter<'a> { fn px_within_offer(&self, local: UiRegion) -> Vec2 { let size = local.size(); Vec2::new( - size.x.to_px(self.offered_px.x), - size.y.to_px(self.offered_px.y), + size.x.to_px(Px::from_f32(self.offered_px.x)).to_f32(), + size.y.to_px(Px::from_f32(self.offered_px.y)).to_f32(), ) } diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index 69540c5..7432951 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -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::{ 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, util::{HashMap, Vec2}, }; @@ -569,6 +569,16 @@ impl UiRenderState { if has_region_node != info.region_node { 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 // longer sits in, and its slot names the wrong parent. if active.parent_move != info.parent_move { @@ -1034,14 +1044,14 @@ impl RegionRemap { fn apply_scalar(self, scalar: UiScalar, from: UiSpan, to: UiSpan) -> UiScalar { let extent = from.end.rel - from.start.rel; - if extent == 0.0 { + if extent == Rel::ZERO { return scalar + to.start - from.start; } let fraction = (scalar.rel - from.start.rel) / extent; - let from_px = from.start.px + fraction * (from.end.px - from.start.px); - let to_rel = to.start.rel + fraction * (to.end.rel - to.start.rel); - let to_px = to.start.px + fraction * (to.end.px - to.start.px); - UiScalar::new(to_rel, scalar.px - from_px + to_px) + let from_px = fraction.lerp(from.start.px, from.end.px); + let to_rel = fraction.lerp(to.start.rel, to.end.rel); + let to_px = fraction.lerp(to.start.px, to.end.px); + UiScalar::from_parts(to_rel, scalar.px - from_px + to_px) } } diff --git a/core/src/util/math.rs b/core/src/util/math.rs index f62ec7a..95e7eef 100644 --- a/core/src/util/math.rs +++ b/core/src/util/math.rs @@ -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)*) => { impl_op!($T $op $fn ${concat($op,Assign)} ${concat($fn,_assign)}; $($field)*); }; diff --git a/src/random.rs b/src/random.rs index eb131f6..e12a5b5 100644 --- a/src/random.rs +++ b/src/random.rs @@ -113,7 +113,10 @@ impl Widget for Branch { let mut top = UiRegion::FULL; top.y.end = top.y.start.offset(40.0); 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; below.y.start = below.y.start.offset(40.0); diff --git a/src/widget/position/pad.rs b/src/widget/position/pad.rs index d16beb5..36eb3f6 100644 --- a/src/widget/position/pad.rs +++ b/src/widget/position/pad.rs @@ -49,10 +49,10 @@ impl Padding { } pub fn region(&self) -> UiRegion { let mut region = UiRegion::FULL; - 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.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 } pub fn x(amt: impl UiNum) -> Self { diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 91be954..dd57e0e 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -20,7 +20,7 @@ impl Widget for Scroll { }; let content = answer_len.apply_leftover(); 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 { 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 // where it is until the box shrinks past what is left of it. Kept to // 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); - } else if content.rel == 0.0 && !self.snap_end { + } else if fixed_len && !self.snap_end { let left = self.content_len - self.amt; painter.holds(self.axis, f32::NEG_INFINITY..=left); } diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 7d61233..b5ffddc 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -35,8 +35,9 @@ impl Widget for Span { Some(len) => len, None => painter.widget_within(child, region).len(axis), }; - cursor.px += len.px + self.gap; - cursor.rel += len.rel; + // 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); lens.push(len); } @@ -90,7 +91,7 @@ impl Widget for Span { // pixels or a fraction keeps those and overflows. if len.leftover > 0.0 && len.px == 0.0 && len.rel == 0.0 && !shares { painter.undraw(child); - start.px += self.gap; + start.px += Px::from_f32(self.gap); continue; } let mut span = UiSpan::FULL; @@ -101,8 +102,8 @@ impl Widget for Span { let end = (UiScalar::rel_max() + start) - offset; start = rel_end.within(&start.to(end)); } - start.px += len.px; - start.rel += len.rel; + start.px += Px::from_f32(len.px); + start.rel += Rel::from_f32(len.rel); span.end = start; let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); if self.dir.sign == Sign::Neg { @@ -121,7 +122,7 @@ impl Widget for Span { 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 diff --git a/tests/chain_cost.rs b/tests/chain_cost.rs index 8b2ec45..038ff7d 100644 --- a/tests/chain_cost.rs +++ b/tests/chain_cost.rs @@ -79,7 +79,7 @@ fn fill(ui: &mut UiData, render: &mut UiRenderState, depth: usize) { 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 { let x = (i % (SIZE as usize / 2)) as f32 * 2.0; let y = (i / (SIZE as usize / 2)) as f32; diff --git a/tests/determinism.rs b/tests/determinism.rs index 3700035..b18e96c 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -24,7 +24,10 @@ impl Widget for BranchesOnMeasurement { let mut top = UiRegion::FULL; top.y.end = top.y.start.offset(40.0); 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; below.y.start = below.y.start.offset(40.0); diff --git a/tests/layout.rs b/tests/layout.rs index 3e3ca21..2640625 100644 --- a/tests/layout.rs +++ b/tests/layout.rs @@ -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 -/// 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) { let active = &h.render.active[&id]; let region = h.render.moves.resolve(active.parent_move, active.region); 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); (edge(span.start), edge(span.end)) } diff --git a/tests/retained.rs b/tests/retained.rs index 0494e54..42eabb1 100644 --- a/tests/retained.rs +++ b/tests/retained.rs @@ -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_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); +}