From 39e4ca20e697aa2745dfa03f8f6463157b2b21db Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Wed, 16 Sep 2026 02:56:48 -0400 Subject: [PATCH] Decide layout on the grid end to end, and delete the tolerance `Px` and `PxVec2` reach the last places a pixel was a float: the window, the box a widget reads, the box it is compared against, and `PixelRegion`. A pointer, a wheel notch and a shaped glyph advance still arrive as floats, and each is put on the grid where it arrives. `Holds` is an interval of `Px`. `HOLDS_EPSILON_PX` is gone with the `exact`/tolerant split it existed for: `at` is the length a widget read, an open end is the next step along, and `same_px` is equality. `Span`'s margin from `5ed9e87` goes too -- the box a parent hands back and the sum of what its children asked for are counts of the same step, so the boundary decides the same way from either side. Three things had to be true for that, and were not: `Holds::through` inverts `px + rel * box`, which rounds -- so a part of a given length came from a range of boxes, and inverting the length alone gave a point that need not contain the box the part was drawn in. It now maps the half step either side, and one more for a length composed down the chain against the same length measured against the window. `RegionRemap` translates when a box only moved, rather than dividing to find each part's fraction and multiplying to place it again. Two roundings landed a step from where growing the tree that way does; a move is exact on a grid, which is the whole reason `tests/drift.rs` was written. A pixel is `1/1024` rather than `1/64`. At `1/64` the residue of a length reached two ways was one step, and one step was 0.016 px -- enough to move a box. `PX_SHIFT` and `REL_SHIFT` are the only statement of the grid now, and the shader's copy is prepended from them rather than written twice. Checked: fmt, clippy, 102 tests, 100 generated seeds in 75 s, all five shrinker cases at 300 seeds, and `tabs`, `view`, `minimal`, `text` and `random` byte-identical at 1920x1200. What the fuzzers ask for is now a step, not a twentieth of a pixel: the shrinker's five cases agree within one (`resize` exactly), and the oracle's two-operation cases within two. The residue is a single rounding either way -- it scales with the grid rather than accumulating, which is why it is a thousandth of a pixel now. Closing it means one way of asking how long a box is, rather than a chain composed down and a length measured against the window; that is a bigger change than this one. Co-Authored-By: Claude Opus 5 --- core/src/fixed.rs | 136 +++++++++++++++++++++++++--- core/src/layout_diagnostics.rs | 6 +- core/src/orientation/axis.rs | 24 +++++ core/src/orientation/len.rs | 18 +++- core/src/orientation/pos.rs | 29 +++--- core/src/render/mod.rs | 9 +- core/src/render/shader/prelude.wgsl | 8 +- core/src/ui/active.rs | 2 +- core/src/ui/holds.rs | 122 +++++++++++++++---------- core/src/ui/painter.rs | 17 ++-- core/src/ui/render_state.rs | 44 ++++++--- src/default/attr.rs | 10 +- src/default/sense.rs | 6 +- src/harness.rs | 12 ++- src/random.rs | 7 +- src/widget/position/scroll.rs | 42 +++++---- src/widget/position/span.rs | 39 ++++---- src/widget/text/edit.rs | 8 +- src/widget/text/mod.rs | 6 +- tests/determinism.rs | 7 +- tests/generated.rs | 20 ++-- tests/retained.rs | 32 +++++-- tests/shrink.rs | 5 +- tests/unsettled.rs | 5 +- 24 files changed, 420 insertions(+), 194 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index 15e3a9e..3488c00 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -1,4 +1,4 @@ -use crate::UiNum; +use crate::{UiNum, util::Vec2}; use std::{ fmt::{Debug, Display, Formatter}, ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}, @@ -27,7 +27,11 @@ pub struct Fixed(i32); /// A length or a coordinate in pixels, to a sixty-fourth. Finer than anything /// a display can show, and exact in `f32` up to 262,144 px, which is what lets /// the same number reach the GPU. -pub type Px = Fixed<6>; +pub type Px = Fixed; + +/// How many bits of a pixel a [`Px`] keeps. One place, because [`PxVec2`] +/// and the shader's own decoding are the same grid or nothing lines up. +pub const PX_SHIFT: u32 = 10; /// 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 @@ -40,7 +44,11 @@ pub type Weight = Fixed<16>; /// +/-128 of range, enough to sum a hundred children each asking for a whole /// box. A `leftover` weight is not one of these: it is a share of what is /// left rather than a fraction of anything, and it sums over a whole list. -pub type Rel = Fixed<24>; +pub type Rel = Fixed; + +/// How many bits of a box a [`Rel`] keeps, beside [`PX_SHIFT`] and for the +/// same reason. +pub const REL_SHIFT: u32 = 24; impl Fixed { pub const ZERO: Self = Self(0); @@ -135,6 +143,15 @@ impl Fixed { Self(narrow(self.0 as i64 * by as i64)) } + /// Divided into a whole number of parts, rounded to the nearest step. + pub const fn div_int(self, by: i32) -> Self { + debug_assert!(by != 0, "no part of nothing"); + if by == 0 { + return Self::ZERO; + } + Self(narrow(div_round(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. @@ -221,7 +238,19 @@ const fn div_round(num: i64, den: i64) -> i64 { } } -const fn narrow(v: i64) -> i32 { +/// Toward positive infinity when `up`, toward negative infinity otherwise. +pub(crate) const fn div_toward(num: i64, den: i64, up: bool) -> i64 { + let (q, rem) = (num / den, num % den); + if rem == 0 { + return q; + } + match (rem < 0) == (den < 0) { + true => q + up as i64, + false => q - !up as i64, + } +} + +pub(crate) const fn narrow(v: i64) -> i32 { if v > i32::MAX as i64 { return i32::MAX; } @@ -297,6 +326,91 @@ impl Debug for Fixed { } } +/// Two of them, for the places a size or a position needs both axes: a +/// window, a box in pixels, a pointer. Held apart from [`crate::util::Vec2`] +/// because that one is what the GPU and the platform speak. +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct FixedVec2 { + pub x: Fixed, + pub y: Fixed, +} + +pub type PxVec2 = FixedVec2; + +impl FixedVec2 { + pub const ZERO: Self = Self::splat(Fixed::ZERO); + + pub const fn new(x: Fixed, y: Fixed) -> Self { + Self { x, y } + } + + pub const fn splat(v: Fixed) -> Self { + Self { x: v, y: v } + } + + pub fn from_f32(v: Vec2) -> Self { + Self::new(Fixed::from_f32(v.x), Fixed::from_f32(v.y)) + } + + pub fn to_f32(self) -> Vec2 { + Vec2::new(self.x.to_f32(), self.y.to_f32()) + } + + pub const fn div_int(self, by: i32) -> Self { + Self::new(self.x.div_int(by), self.y.div_int(by)) + } + + pub const fn min(self, other: Self) -> Self { + Self::new(self.x.min(other.x), self.y.min(other.y)) + } + + pub const fn max(self, other: Self) -> Self { + Self::new(self.x.max(other.x), self.y.max(other.y)) + } +} + +// `impl_op!` names one concrete type, and this one is generic. +const impl Add for FixedVec2 { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + Self::new(self.x.add(rhs.x), self.y.add(rhs.y)) + } +} + +const impl Sub for FixedVec2 { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + Self::new(self.x.sub(rhs.x), self.y.sub(rhs.y)) + } +} + +const impl AddAssign for FixedVec2 { + fn add_assign(&mut self, rhs: Self) { + *self = Add::add(*self, rhs); + } +} + +const impl SubAssign for FixedVec2 { + fn sub_assign(&mut self, rhs: Self) { + *self = Sub::sub(*self, rhs); + } +} + +impl Debug for FixedVec2 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + +impl Display for FixedVec2 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.x, self.y) + } +} + #[cfg(test)] mod tests { use super::*; @@ -332,10 +446,10 @@ mod tests { #[test] fn halves_round_away_from_zero_either_side() { - // A sixty-fourth and a half of one, which has no step of its own. - let one_and_a_half = Rel::from_f32(1.5) / Rel::from_int(64); - assert_eq!(Px::ONE * one_and_a_half, Px::from_raw(2)); - assert_eq!(Px::ONE.neg() * one_and_a_half, Px::from_raw(-2)); + // A step and a half of one, which has no step of its own. + let step_and_a_half = Rel::from_f32(1.5).div_int(Px::ONE.raw()); + assert_eq!(Px::ONE * step_and_a_half, Px::from_raw(2)); + assert_eq!(Px::ONE.neg() * step_and_a_half, Px::from_raw(-2)); } #[test] @@ -360,10 +474,8 @@ mod tests { // A third, which neither grid holds exactly. let third = Rel::ONE / Rel::from_int(3); assert_eq!(third.to_scale::<6>(), Fixed::<6>::from_raw(21)); - assert_eq!( - Px::from_raw(21).to_scale::<24>().to_scale::<6>(), - Px::from_raw(21) - ); + let coarse = Fixed::<6>::from_raw(21); + assert_eq!(coarse.to_scale::<24>().to_scale::<6>(), coarse); } #[test] diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index 7a10bd9..c86ecd8 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -15,7 +15,7 @@ //! reuse, size, placement, and text events for one suspicious widget. The //! selection is a set and survives [`take`] until cleared. -use crate::{Axis, Len, Size, UiRegion, WidgetId, util::Vec2}; +use crate::{Axis, Len, PxVec2, Size, UiRegion, WidgetId}; use std::{ cell::RefCell, collections::{HashMap, HashSet}, @@ -258,7 +258,7 @@ pub enum TraceEvent { id: WidgetId, parent: Option, region: UiRegion, - pixel_size: Vec2, + pixel_size: PxVec2, region_node: bool, }, Reuse { @@ -354,7 +354,7 @@ pub(crate) fn draw_request( id: WidgetId, parent: Option, region: UiRegion, - pixel_size: Vec2, + pixel_size: PxVec2, region_node: bool, ) { trace( diff --git a/core/src/orientation/axis.rs b/core/src/orientation/axis.rs index 1053b4e..17b5cc9 100644 --- a/core/src/orientation/axis.rs +++ b/core/src/orientation/axis.rs @@ -1,4 +1,5 @@ use super::*; +use crate::{Fixed, FixedVec2}; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Axis { @@ -40,6 +41,29 @@ pub enum Sign { Pos, } +impl FixedVec2 { + pub const fn axis(&self, axis: Axis) -> Fixed { + match axis { + Axis::X => self.x, + Axis::Y => self.y, + } + } + + pub const fn axis_mut(&mut self, axis: Axis) -> &mut Fixed { + match axis { + Axis::X => &mut self.x, + Axis::Y => &mut self.y, + } + } + + pub const fn from_axis(axis: Axis, aligned: Fixed, ortho: Fixed) -> Self { + match axis { + Axis::X => Self::new(aligned, ortho), + Axis::Y => Self::new(ortho, aligned), + } + } +} + impl Vec2 { pub fn axis(&self, axis: Axis) -> f32 { match axis { diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 36a68a8..72b1b5e 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -1,5 +1,5 @@ use super::*; -use crate::{Px, Rel, UiNum, Weight, util::impl_op}; +use crate::{Px, PxVec2, Rel, UiNum, Weight, util::impl_op}; #[derive(Debug, Default, Clone, Copy, PartialEq)] pub struct Size { @@ -49,10 +49,22 @@ impl Size { y: Len::LEFTOVER, }; + /// From something measured outside layout -- a texture, a shaped line -- + /// which is where a size in floats comes from. pub fn px(v: Vec2) -> Self { + Self::from_px(PxVec2::from_f32(v)) + } + + pub const fn from_px(v: PxVec2) -> Self { Self { - x: Len::px(v.x), - y: Len::px(v.y), + x: Len { + px: v.x, + ..Len::ZERO + }, + y: Len { + px: v.y, + ..Len::ZERO + }, } } diff --git a/core/src/orientation/pos.rs b/core/src/orientation/pos.rs index 8a4a7ae..53df60d 100644 --- a/core/src/orientation/pos.rs +++ b/core/src/orientation/pos.rs @@ -1,7 +1,7 @@ use std::{fmt::Display, marker::Destruct}; use super::*; -use crate::{Px, Rel, UiNum, util::impl_op}; +use crate::{Px, PxVec2, Rel, UiNum, util::impl_op}; #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, bytemuck::Pod, bytemuck::Zeroable, Default)] @@ -67,13 +67,10 @@ impl UiVec2 { } } - /// 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(Px::from_f32(size.x)).to_f32(), - y: self.y.to_px(Px::from_f32(size.y)).to_f32(), - } + /// Resolved against a box of `size`, which is where a fraction stops + /// being one and becomes a place. + pub fn to_px(&self, size: PxVec2) -> PxVec2 { + PxVec2::new(self.x.to_px(size.x), self.y.to_px(size.y)) } pub const FULL_SIZE: Self = Self::rel(Vec2::ONE); @@ -344,10 +341,10 @@ impl UiRegion { self } - pub fn to_px(&self, size: Vec2) -> PixelRegion { + pub fn to_px(&self, size: PxVec2) -> PixelRegion { PixelRegion { - top_left: self.top_left().get_rel() * size + self.top_left().get_px(), - bot_right: self.bot_right().get_rel() * size + self.bot_right().get_px(), + top_left: self.top_left().to_px(size), + bot_right: self.bot_right().to_px(size), } } @@ -402,21 +399,21 @@ impl Display for UiRegion { } } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PixelRegion { - pub top_left: Vec2, - pub bot_right: Vec2, + pub top_left: PxVec2, + pub bot_right: PxVec2, } impl PixelRegion { - pub fn contains(&self, pos: Vec2) -> bool { + pub fn contains(&self, pos: PxVec2) -> bool { pos.x >= self.top_left.x && pos.x <= self.bot_right.x && pos.y >= self.top_left.y && pos.y <= self.bot_right.y } - pub fn size(&self) -> Vec2 { + pub fn size(&self) -> PxVec2 { self.bot_right - self.top_left } } diff --git a/core/src/render/mod.rs b/core/src/render/mod.rs index 834cc8c..09ae52b 100644 --- a/core/src/render/mod.rs +++ b/core/src/render/mod.rs @@ -23,7 +23,14 @@ pub use primitive::*; const PRELUDE: &str = include_str!("./shader/prelude.wgsl"); fn module_source(wgsl: &str) -> String { - format!("{PRELUDE}\n{wgsl}") + // The steps come from the same constants the CPU counts in, rather than + // a second copy of them written into the shader: a grid the two disagree + // about puts every coordinate somewhere else. + format!( + "const PX_STEP: f32 = 1.0 / {}.0;\nconst REL_STEP: f32 = 1.0 / {}.0;\n{PRELUDE}\n{wgsl}", + 1u32 << crate::PX_SHIFT, + 1u32 << crate::REL_SHIFT, + ) } pub struct UiRenderNode { diff --git a/core/src/render/shader/prelude.wgsl b/core/src/render/shader/prelude.wgsl index 269bc2a..413eb47 100644 --- a/core/src/render/shader/prelude.wgsl +++ b/core/src/render/shader/prelude.wgsl @@ -26,11 +26,9 @@ struct MoveOffset { 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; +// `PX_STEP` and `REL_STEP` are prepended from `iris_core`'s own constants: +// what it stores is a whole count of each, both powers of two, so decoding +// is exact and the number here is the number the CPU decided. // 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 diff --git a/core/src/ui/active.rs b/core/src/ui/active.rs index 29257a1..9da0a0b 100644 --- a/core/src/ui/active.rs +++ b/core/src/ui/active.rs @@ -58,7 +58,7 @@ pub struct ActiveData { impl ActiveData { /// Whether its drawing and size hold for a box of these pixel lengths. - pub fn holds_at(&self, px: crate::util::Vec2) -> bool { + pub fn holds_at(&self, px: crate::PxVec2) -> bool { self.holds[0].contains(px.x) && self.holds[1].contains(px.y) } } diff --git a/core/src/ui/holds.rs b/core/src/ui/holds.rs index ff0ec0b..bafeea2 100644 --- a/core/src/ui/holds.rs +++ b/core/src/ui/holds.rs @@ -1,4 +1,4 @@ -use crate::{Rel, UiScalar}; +use crate::{Px, REL_SHIFT, UiScalar, fixed::div_toward, fixed::narrow}; use std::ops::RangeInclusive; /// The lengths of a box, in pixels, that one drawing of a widget holds for: @@ -7,48 +7,33 @@ use std::ops::RangeInclusive; /// for every length; one that does holds for the one it read unless it says /// otherwise, and a parent holds for whatever keeps every child it asked /// about or drew inside its own range. -#[derive(Clone, Copy, Debug, PartialEq)] +/// +/// The ends are lengths on the grid rather than floats with a tolerance +/// around them: a box offered back at the length a widget reported comes back +/// as the same number, so a range means what it says. What widening there is +/// belongs to [`Self::through`], which has a rounding to undo, and is derived +/// from that rounding rather than chosen. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Holds { - pub lo: f32, - pub hi: f32, + pub lo: Px, + pub hi: Px, } -/// How far outside a range a length may fall and still be inside it: a box -/// offered back to a widget at the length it reported comes back through the -/// chain a few bits off, and nothing a reader could see lives in that gap. -pub const HOLDS_EPSILON_PX: f32 = 0.05; - impl Holds { pub const ANY: Self = Self { - lo: f32::NEG_INFINITY, - hi: f32::INFINITY, + lo: Px::MIN, + hi: Px::MAX, }; - pub const fn at(len: f32) -> Self { - Self::tolerant(len, len) + pub const fn at(len: Px) -> Self { + Self { lo: len, hi: len } } - const fn tolerant(lo: f32, hi: f32) -> Self { - Self { - lo: lo - HOLDS_EPSILON_PX, - hi: hi + HOLDS_EPSILON_PX, - } + pub const fn contains(&self, len: Px) -> bool { + len.raw() >= self.lo.raw() && len.raw() <= self.hi.raw() } - /// A range whose endpoints are exact, for a widget decision with a hard - /// boundary rather than an accumulated coordinate-rounding difference. - pub fn exact(range: RangeInclusive) -> Self { - Self { - lo: *range.start(), - hi: *range.end(), - } - } - - pub fn contains(&self, len: f32) -> bool { - len >= self.lo && len <= self.hi - } - - pub fn and(self, other: Self) -> Self { + pub const fn and(self, other: Self) -> Self { Self { lo: self.lo.max(other.lo), hi: self.hi.min(other.hi), @@ -58,41 +43,82 @@ impl Holds { /// What a box has to be for a part of it, `len` of the box long, to stay /// 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 == Rel::ZERO { + /// + /// The way in is `px + rel * box` taken to the nearest step, so a part + /// of exactly `lo` came from anything within half a step of it and the + /// answer is an interval even where this range is one length. Inverting + /// the length alone instead gives a point that need not even contain the + /// box the part was drawn in, which is a range excluding the drawing it + /// was made for. + pub const fn through(self, len: UiScalar) -> Self { + let rel = len.rel.raw() as i64; + if rel == 0 { return Self::ANY; } - let (rel, px) = (len.rel.to_f32(), len.px.to_f32()); - let a = (self.lo - px) / rel; - let b = (self.hi - px) / rel; + // Three half steps either side -- one for the rounding on the way + // in, two for the difference between a length composed down the + // chain and the same length measured against the window -- and half + // of what a `Rel` counts in, to divide by the fraction. Exact until + // the division takes it back to the grid. + let px = len.px.raw() as i64; + let half_rel = REL_SHIFT - 1; + let lo = ((self.lo.raw() as i64 - px) * 2 - 3) << half_rel; + let hi = ((self.hi.raw() as i64 - px) * 2 + 3) << half_rel; + let (a, b) = (div_toward(lo, rel, true), div_toward(hi, rel, false)); + let (c, d) = (div_toward(lo, rel, false), div_toward(hi, rel, true)); + match rel > 0 { + true => Self::raws(a, b), + false => Self::raws(d, c), + } + } + + const fn raws(lo: i64, hi: i64) -> Self { Self { - lo: a.min(b), - hi: a.max(b), + lo: Px::from_raw(narrow(lo)), + hi: Px::from_raw(narrow(hi)), } } } -impl From> for Holds { - fn from(range: RangeInclusive) -> Self { - Self::tolerant(*range.start(), *range.end()) +impl From> for Holds { + fn from(range: RangeInclusive) -> Self { + Self { + lo: *range.start(), + hi: *range.end(), + } } } #[cfg(test)] mod tests { use super::*; + use crate::Rel; #[test] fn through_reverses_a_range_for_a_negative_fraction() { - let holds = Holds::from(20.0..=40.0).through(UiScalar::new(-0.5, 10.0)); - assert!((holds.lo - -60.1).abs() < 0.001); - assert!((holds.hi - -19.9).abs() < 0.001); + // `10 - box / 2` is between 20 and 40 for boxes from -60 to -20. + let part = UiScalar::from_parts(Rel::from_f32(-0.5), Px::from_int(10)); + let holds = Holds::from(Px::from_int(20)..=Px::from_int(40)).through(part); + assert!(holds.contains(Px::from_int(-60)) && holds.contains(Px::from_int(-20))); + assert!(!holds.contains(Px::from_int(-61)) && !holds.contains(Px::from_int(-19))); + } + + /// The case the widening is for: a part that holds only for the length it + /// was drawn at has to hold for the box it was drawn in, and a third of a + /// box is not a whole number of steps. + #[test] + fn a_part_maps_back_onto_the_box_it_was_measured_in() { + let part = UiScalar::from_parts(Rel::from_f32(1.0 / 3.0), Px::from_int(-146)); + for box_len in (440..460).map(Px::from_int) { + let holds = Holds::at(part.to_px(box_len)).through(part); + assert!(holds.contains(box_len), "{box_len:?} left out by {holds:?}"); + } } #[test] - fn an_exact_open_boundary_does_not_admit_the_boundary() { - let boundary = 10.0_f32; - let above = Holds::exact(boundary.next_up()..=f32::INFINITY); + fn a_boundary_the_next_step_along_does_not_admit_it() { + let boundary = Px::from_int(10); + let above = Holds::from(boundary.next_up()..=Px::MAX); assert!(!above.contains(boundary)); assert!(above.contains(boundary.next_up())); } diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 69fd38e..95cf238 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, Px, RegionAlign, Rel, RenderedText, Size, StrongWidget, TextAttrs, + Axis, Holds, Len, Px, PxVec2, RegionAlign, Rel, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Weight, WidgetId, Widgets, render::{ @@ -9,7 +9,6 @@ use crate::{ PrimitiveKind, TexturePrimitive, }, ui::render_state::DrawInfo, - util::Vec2, }; const AXES: [Axis; 2] = [Axis::X, Axis::Y]; @@ -28,7 +27,7 @@ pub struct Painter<'a> { /// is the one recorded as its offer. pub(super) offered: Vec, /// The box this widget was first asked about in, in pixels. - pub(super) offered_px: Vec2, + pub(super) offered_px: PxVec2, /// Whether this draw is in that box, which makes the questions it asks /// the ones a cold layout asks and their answers the ones to keep. pub(super) at_offer: bool, @@ -291,11 +290,11 @@ impl<'a> Painter<'a> { } /// The pixel size of a part of the box this widget was asked in. - fn px_within_offer(&self, local: UiRegion) -> Vec2 { + fn px_within_offer(&self, local: UiRegion) -> PxVec2 { let size = local.size(); - Vec2::new( - 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(), + PxVec2::new( + size.x.to_px(self.offered_px.x), + size.y.to_px(self.offered_px.y), ) } @@ -363,7 +362,7 @@ impl<'a> Painter<'a> { /// This widget's box in pixels. Reading it makes the drawing one that /// holds for this box only, until `holds` says how far it goes. - pub fn px_size(&mut self) -> Vec2 { + pub fn px_size(&mut self) -> PxVec2 { let px = self.state.px_of(self.move_idx, self.region); for (own, len) in self.own.iter_mut().zip([px.x, px.y]) { if *own == Holds::ANY { @@ -375,7 +374,7 @@ impl<'a> Painter<'a> { /// One axis of this widget's box in pixels. Prefer this to /// [`Self::px_size`] when the other axis cannot affect the drawing. - pub fn px_len(&mut self, axis: Axis) -> f32 { + pub fn px_len(&mut self, axis: Axis) -> Px { let len = self.state.px_of(self.move_idx, self.region).axis(axis); let own = &mut self.own[axis as usize]; if *own == Holds::ANY { diff --git a/core/src/ui/render_state.rs b/core/src/ui/render_state.rs index afde7a5..c21c321 100644 --- a/core/src/ui/render_state.rs +++ b/core/src/ui/render_state.rs @@ -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, Weight, - WidgetId, Widgets, + PixelRegion, PxVec2, RegionAlign, Rel, Size, StrongWidget, UiRegion, UiRsc, UiScalar, UiSpan, + Weight, WidgetId, Widgets, util::{HashMap, Vec2}, }; @@ -23,7 +23,7 @@ pub(super) struct DrawInfo { /// The box it was first asked about in, as a part of its parent's, and /// that box in pixels. pub offer: UiRegion, - pub offered_px: Vec2, + pub offered_px: PxVec2, /// A container's answer for where the widget sits. `None` uses the /// widget's own property. pub align: Option, @@ -32,7 +32,7 @@ pub(super) struct DrawInfo { pub struct UiRenderState { pub active: HashMap, pub layers: DrawLayers, - pub(super) output_size: Vec2, + pub(super) output_size: PxVec2, old_root: Option, /// The slot every chain bottoms out in, holding the output as a box. @@ -58,7 +58,7 @@ impl UiRenderState { Self { active: Default::default(), layers: Default::default(), - output_size: Vec2::ZERO, + output_size: PxVec2::ZERO, old_root: None, slots: Default::default(), answer_invalid: Default::default(), @@ -75,8 +75,14 @@ impl UiRenderState { /// downstream has to know the output's size to resolve a position. fn write_root(&mut self) { let region = UiRegion::new( - UiSpan::new(UiScalar::ZERO, UiScalar::px(self.output_size.x)), - UiSpan::new(UiScalar::ZERO, UiScalar::px(self.output_size.y)), + UiSpan::new( + UiScalar::ZERO, + UiScalar::from_parts(Rel::ZERO, self.output_size.x), + ), + UiSpan::new( + UiScalar::ZERO, + UiScalar::from_parts(Rel::ZERO, self.output_size.y), + ), ); match self.root_move == MoveIdx::NONE { true => self.root_move = self.moves.push(MoveIdx::NONE, region), @@ -84,8 +90,10 @@ impl UiRenderState { } } + /// The window, in whatever the platform measures it in, onto the grid + /// everything below it is decided on. pub fn resize(&mut self, size: impl Into) { - let size = size.into(); + let size = PxVec2::from_f32(size.into()); if size == self.output_size { return; } @@ -108,7 +116,7 @@ impl UiRenderState { } } - pub fn output_size(&self) -> Vec2 { + pub fn output_size(&self) -> PxVec2 { self.output_size } @@ -447,7 +455,7 @@ impl UiRenderState { } /// The pixel size of a region held in `slot`'s coordinates. - pub(super) fn px_of(&self, slot: MoveIdx, region: UiRegion) -> Vec2 { + pub(super) fn px_of(&self, slot: MoveIdx, region: UiRegion) -> PxVec2 { self.moves .resolve(slot, region) .size() @@ -460,7 +468,7 @@ impl UiRenderState { pub(super) fn retained_size( &self, id: WidgetId, - px: Vec2, + px: PxVec2, parent_move: MoveIdx, widgets: &Widgets, ) -> Option<(Size, [Holds; 2])> { @@ -1003,8 +1011,10 @@ impl UiRenderState { } } -fn same_px(a: Vec2, b: Vec2) -> bool { - Holds::at(a.x).contains(b.x) && Holds::at(a.y).contains(b.y) +/// The same box is the same number of steps, both of these being lengths on +/// the grid rather than floats to be compared for nearness. +fn same_px(a: PxVec2, b: PxVec2) -> bool { + a == b } fn same_pixel_region(a: PixelRegion, b: PixelRegion) -> bool { @@ -1045,7 +1055,13 @@ impl RegionRemap { fn apply_scalar(self, scalar: UiScalar, from: UiSpan, to: UiSpan) -> UiScalar { let extent = from.end.rel - from.start.rel; - if extent == Rel::ZERO { + // A box that only moved, or that has no relative extent to divide, + // carries its parts by moving them, which is exact. Dividing to find + // the fraction each sits at and multiplying to place it again are two + // roundings, and they land a step from where growing the tree that + // way does. Where the box changed length there is nothing else to do, + // and the fraction is what a part means. + if from.len() == to.len() || extent == Rel::ZERO { return scalar + to.start - from.start; } let fraction = (scalar.rel - from.start.rel) / extent; diff --git a/src/default/attr.rs b/src/default/attr.rs index ff4e313..33a3d8d 100644 --- a/src/default/attr.rs +++ b/src/default/attr.rs @@ -15,8 +15,10 @@ where let region = ctx.data.render.window_region(&id).unwrap(); let id_pos = region.top_left; let container_pos = ctx.data.render.window_region(&container).unwrap().top_left; - let pos = ctx.data.pos + container_pos - id_pos; - let size = region.size(); + // The pointer arrives from the platform in floats; everything + // it is compared against is on the grid. + let pos = (PxVec2::from_f32(ctx.data.pos) + container_pos - id_pos).to_f32(); + let size = region.size().to_f32(); select( rsc, ctx.data.render, @@ -70,8 +72,8 @@ fn select( if let Some(region) = render.window_region(&id) { state.window.set_ime_allowed(true); state.window.set_ime_cursor_area( - LogicalPosition::::from(region.top_left.tuple()), - LogicalSize::::from(region.size().tuple()), + LogicalPosition::::from(region.top_left.to_f32().tuple()), + LogicalSize::::from(region.size().to_f32().tuple()), ); } state.focus = Some(id); diff --git a/src/default/sense.rs b/src/default/sense.rs index 14d5b3a..b4a6010 100644 --- a/src/default/sense.rs +++ b/src/default/sense.rs @@ -198,7 +198,7 @@ impl SensorUi for UiRenderState { let Some(region) = region_of(id) else { continue; }; - if !cursor.exists || !region.contains(cursor.pos) { + if !cursor.exists || !region.contains(PxVec2::from_f32(cursor.pos)) { continue; } hovered.now.push(id); @@ -249,8 +249,8 @@ fn deliver( region: PixelRegion, ) -> bool { let data = CursorData { - pos: cursor.pos - region.top_left, - size: region.bot_right - region.top_left, + pos: cursor.pos - region.top_left.to_f32(), + size: region.size().to_f32(), scroll_delta: cursor.scroll_delta, hover, cursor: cursor.clone(), diff --git a/src/harness.rs b/src/harness.rs index 4b7f569..281514d 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -29,8 +29,14 @@ macro_rules! assert_corners { assert_eq!( $harness.region(&$id).expect("widget drew nothing"), $crate::core::PixelRegion { - top_left: $crate::core::util::Vec2::new($x0 as f32, $y0 as f32), - bot_right: $crate::core::util::Vec2::new($x1 as f32, $y1 as f32), + top_left: $crate::core::PxVec2::new( + $crate::core::Px::from_f32($x0 as f32), + $crate::core::Px::from_f32($y0 as f32), + ), + bot_right: $crate::core::PxVec2::new( + $crate::core::Px::from_f32($x1 as f32), + $crate::core::Px::from_f32($y1 as f32), + ), } ); }; @@ -151,7 +157,7 @@ impl Harness { } pub fn size(&self) -> Vec2 { - self.render.output_size() + self.render.output_size().to_f32() } pub fn resize(&mut self, size: impl Into) { diff --git a/src/random.rs b/src/random.rs index a1895dc..d287547 100644 --- a/src/random.rs +++ b/src/random.rs @@ -113,14 +113,11 @@ impl Widget for Branch { let mut top = UiRegion::FULL; 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() - .to_px(Px::from_f32(painter.px_len(Axis::X))) - .to_f32(); + let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let mut below = UiRegion::FULL; below.y.start = below.y.start.offset(Px::from_int(40)); - match px > self.threshold { + match px > Px::from_f32(self.threshold) { true => painter.widget_within(&self.wide, below), false => painter.widget_within(&self.narrow, below), }; diff --git a/src/widget/position/scroll.rs b/src/widget/position/scroll.rs index 9f06180..4dd2c10 100644 --- a/src/widget/position/scroll.rs +++ b/src/widget/position/scroll.rs @@ -3,10 +3,10 @@ use crate::prelude::*; pub struct Scroll { inner: StrongWidget, axis: Axis, - amt: f32, + amt: Px, snap_end: bool, - container_len: f32, - content_len: f32, + container_len: Px, + content_len: Px, } impl Widget for Scroll { @@ -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(Px::from_f32(container_len)).to_f32(); + self.content_len = content.to_px(container_len); if self.snap_end { self.amt = self.content_len - self.container_len; @@ -35,22 +35,24 @@ impl Widget for Scroll { // the end, it moves with every length. 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..=Px::MAX); } else if fixed_len && !self.snap_end { let left = self.content_len - self.amt; - painter.holds(self.axis, f32::NEG_INFINITY..=left); + painter.holds(self.axis, Px::MIN..=left); } // Content shorter than the viewport has room to sit in, and where it // 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().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(Px::from_f32(self.content_len)); + let slack = (self.container_len - self.content_len).max(Px::ZERO); + let anchor = slack.mul(align.rel()); + let offset = UiVec2::from_axis( + self.axis, + UiScalar::from_parts(Rel::ZERO, anchor - self.amt), + UiScalar::ZERO, + ); + let mut region = UiRegion::FULL.offset(offset); + region.axis_mut(self.axis).end = region.axis(self.axis).start.offset(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 @@ -65,22 +67,24 @@ impl Scroll { Self { inner, axis, - amt: 0.0, + amt: Px::ZERO, snap_end: true, - container_len: 0.0, - content_len: 0.0, + container_len: Px::ZERO, + content_len: Px::ZERO, } } pub fn update_amt(&mut self) { - self.amt = self.amt.max(0.0); - let len = (self.content_len - self.container_len).max(0.0); + self.amt = self.amt.max(Px::ZERO); + let len = (self.content_len - self.container_len).max(Px::ZERO); self.amt = self.amt.min(len); self.snap_end = self.amt == len; } + /// Scrolled by a distance the platform measures, which is the last place + /// a wheel notch or a finger is a float. pub fn scroll(&mut self, amt: f32) { - self.amt -= amt; + self.amt -= Px::from_f32(amt); self.update_amt(); } } diff --git a/src/widget/position/span.rs b/src/widget/position/span.rs index 8e51fcf..6bd4753 100644 --- a/src/widget/position/span.rs +++ b/src/widget/position/span.rs @@ -52,40 +52,39 @@ impl Widget for Span { ); // 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 - // is `len * fixed - total.px`, and under `HOLDS_EPSILON_PX` of it is - // none -- because the length where the room runs out is exactly the - // box a parent sizing itself from this answer hands back, and that box - // returns through the chain a few bits either way. Without the margin - // 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. + // beside 300 px is full at 600 and overfull at 400. The room to + // divide is `len * fixed - total.px`, and the length where it runs + // out is exactly the box a parent sizing itself from this answer + // hands back -- which is why this used to need a margin either side + // of the boundary, and why it does not now: that box and this sum are + // whole counts of the same step, and both routes to it land on the + // same count. What the generated oracle checks is the consequence, + // since which children exist at all turns on this. let fixed = Rel::ONE - total.rel; - let margin = Px::from_f32(HOLDS_EPSILON_PX); let mut shares = false; if total.leftover > Weight::ZERO { - let current = Px::from_f32(painter.px_len(axis)); + let current = painter.px_len(axis); let holds = if fixed > Rel::ZERO { - // The box length at which the room reaches the margin. - let enough = (total.px + margin).div(fixed); - shares = current > enough; + // The box length the fixed parts alone fill. + let full = total.px.div(fixed); + shares = current > full; match shares { - true => Holds::exact(enough.to_f32().next_up()..=f32::INFINITY), - false => Holds::exact(f32::NEG_INFINITY..=enough.to_f32()), + true => Holds::from(full.next_up()..=Px::MAX), + false => Holds::from(Px::MIN..=full), } } 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 + margin).div(fixed); - shares = current < enough; + let full = total.px.div(fixed); + shares = current < full; match shares { - true => Holds::exact(f32::NEG_INFINITY..=enough.to_f32().next_down()), - false => Holds::exact(enough.to_f32()..=f32::INFINITY), + true => Holds::from(Px::MIN..=full.next_down()), + false => Holds::from(full..=Px::MAX), } } else { // The relative parts take exactly the box, whatever it is, so // the only room is what negative pixels leave. - shares = total.px < margin.neg(); + shares = total.px < Px::ZERO; Holds::ANY }; painter.holds(axis, holds); diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index c7bd195..6126046 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -276,7 +276,13 @@ impl<'a> TextEditCtx<'a> { } pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { - let pos = pos - self.text.region().top_left().to_px(size); + let pos = pos + - self + .text + .region() + .top_left() + .to_px(PxVec2::from_f32(size)) + .to_f32(); let prev_sel = self.text.selection; let prev_hit = self.text.double_hit; diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 764d12e..303d713 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -48,13 +48,15 @@ impl TextView { /// rather than invalidating anything. fn render(&mut self, painter: &mut Painter) -> &RenderedText { let width = self.attrs.wrap.then(|| painter.px_len(Axis::X)); - let text = painter.render_text(&mut self.buf, &self.attrs, width); + // The shaper measures in floats, which is where a glyph advance comes + // from; what it answers goes back on the grid. + let text = painter.render_text(&mut self.buf, &self.attrs, width.map(Px::to_f32)); // A greedy break is the same break at every width from its longest // line up to the one it was made at: each line still fits, and none // could take a word that did not fit in the wider box. A line too // long to fit at all says nothing about narrower boxes. if let Some(width) = width { - painter.holds(Axis::X, text.size.x.min(width)..=width); + painter.holds(Axis::X, Px::from_f32(text.size.x).min(width)..=width); } text } diff --git a/tests/determinism.rs b/tests/determinism.rs index b2d6b9a..721da4e 100644 --- a/tests/determinism.rs +++ b/tests/determinism.rs @@ -24,14 +24,11 @@ impl Widget for BranchesOnMeasurement { let mut top = UiRegion::FULL; 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() - .to_px(Px::from_f32(painter.px_len(Axis::X))) - .to_f32(); + let px = measured.apply_leftover().to_px(painter.px_len(Axis::X)); let mut below = UiRegion::FULL; below.y.start = below.y.start.offset(Px::from_int(40)); - match px > self.threshold { + match px > Px::from_f32(self.threshold) { true => painter.widget_within(&self.wide, below), false => painter.widget_within(&self.narrow, below), }; diff --git a/tests/generated.rs b/tests/generated.rs index 26b1358..b4e9fba 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -30,19 +30,23 @@ fn env(name: &str, fallback: T) -> T { .unwrap_or(fallback) } const SEEDS: [u64; 9] = [1, 2, 3, 5, 8, 10, 13, 86, 98]; -const REGION_EPSILON_PX: f32 = 0.05; -fn same_coordinate(got: f32, want: f32) -> bool { - (got - want).abs() <= REGION_EPSILON_PX -} +/// The same box, to a step of the grid per operation. Warm and cold reach a +/// coordinate by different arithmetic: a move lands on the same number now, +/// and a length composed one way against the same length measured another can +/// land one step out. These cases apply two operations in turn, so they allow +/// two steps -- a thousandth of a pixel each, where this was a twentieth of +/// one before any of it was on a grid. +const AGREE_STEPS: i32 = 2; fn same_region(got: Option, want: Option) -> bool { match (got, want) { (Some(got), Some(want)) => { - same_coordinate(got.top_left.x, want.top_left.x) - && same_coordinate(got.top_left.y, want.top_left.y) - && same_coordinate(got.bot_right.x, want.bot_right.x) - && same_coordinate(got.bot_right.y, want.bot_right.y) + let same = |a: Px, b: Px| (a - b).abs() <= Px::STEP.mul_int(AGREE_STEPS); + same(got.top_left.x, want.top_left.x) + && same(got.top_left.y, want.top_left.y) + && same(got.bot_right.x, want.bot_right.x) + && same(got.bot_right.y, want.bot_right.y) } (None, None) => true, _ => false, diff --git a/tests/retained.rs b/tests/retained.rs index 42eabb1..4b517b6 100644 --- a/tests/retained.rs +++ b/tests/retained.rs @@ -255,7 +255,7 @@ struct ReadsBox { impl Widget for ReadsBox { fn draw(&mut self, painter: &mut Painter) -> Size { self.draws.set(self.draws.get() + 1); - Size::px(painter.px_size() / 4.0) + Size::from_px(painter.px_size().div_int(4)) } } @@ -273,7 +273,10 @@ struct ReadsWidth { impl Widget for ReadsWidth { fn draw(&mut self, painter: &mut Painter) -> Size { self.draws.set(self.draws.get() + 1); - Size::px((painter.px_len(Axis::X) / 4.0, 20.0).into()) + Size::from_px(PxVec2::new( + painter.px_len(Axis::X).div_int(4), + Px::from_int(20), + )) } } @@ -369,8 +372,11 @@ fn a_resize_only_redraws_read_axes() { assert_eq!(draws.get(), settled + 2, "width changes its answer"); } +/// A window is measured onto the grid like everything else, so a resize too +/// small to reach the next step is not a resize at all -- and one that does +/// reach it is, however little of a pixel it is worth. #[test] -fn subpixel_resize_changes_accumulate_from_the_last_layout() { +fn a_resize_within_one_step_is_not_a_resize() { let mut h = Harness::new((400, 200)); let draws = Rc::new(Cell::new(0)); let leaf = ReadsWidth { @@ -380,30 +386,36 @@ fn subpixel_resize_changes_accumulate_from_the_last_layout() { h.set_root(leaf); let settled = draws.get(); - for width in [400.02, 400.04, 400.05] { - h.resize((width, 200.0)); + // All of these are 400 px to the nearest step. + let step = Px::STEP.to_f32(); + for part in [0.1, 0.2, 0.3] { + h.resize((400.0 + step * part, 200.0)); h.frame(); assert_eq!(draws.get(), settled); } - h.resize((400.06, 200.0)); + h.resize((400.0 + step, 200.0)); h.frame(); assert_eq!(draws.get(), settled + 2); } +/// The same for a box that changes because a sibling did: what is compared +/// is the length on the grid, and three lengths that land on one step are +/// one length. #[test] -fn subpixel_box_changes_accumulate_from_the_last_draw() { +fn a_box_change_within_one_step_is_not_a_change() { let mut h = Harness::new((400, 200)); let (first, draws, _) = pair(&mut h, true); let settled = draws.get(); - for width in [100.02, 100.04, 100.05] { - h.rsc[first].size.x = Len::px(width); + let step = Px::STEP.to_f32(); + for part in [0.1, 0.2, 0.3] { + h.rsc[first].size.x = Len::px(100.0 + step * part); h.frame(); assert_eq!(draws.get(), settled); } - h.rsc[first].size.x = Len::px(100.06); + h.rsc[first].size.x = Len::px(100.0 + step); h.frame(); assert_eq!(draws.get(), settled + 1); } diff --git a/tests/shrink.rs b/tests/shrink.rs index 5eb6612..321068b 100644 --- a/tests/shrink.rs +++ b/tests/shrink.rs @@ -515,9 +515,12 @@ fn diverges(node: &Node, case: Case) -> Option { for (i, (&w, &c)) in warm_ids.iter().zip(&cold_ids).enumerate() { let (got, want) = (warm.region(&w), cold.region(&c)); + // To one step of the grid. A move or a resize lands on the same + // number now; a length measured one way and composed another can + // still be a step apart. let same = match (got, want) { (Some(g), Some(c)) => { - let d = |a: f32, b: f32| (a - b).abs() <= 0.05; + let d = |a: Px, b: Px| (a - b).abs() <= Px::STEP; d(g.top_left.x, c.top_left.x) && d(g.top_left.y, c.top_left.y) && d(g.bot_right.x, c.bot_right.x) diff --git a/tests/unsettled.rs b/tests/unsettled.rs index 98536ae..e732381 100644 --- a/tests/unsettled.rs +++ b/tests/unsettled.rs @@ -284,7 +284,10 @@ struct Wider { impl Widget for Wider { fn draw(&mut self, painter: &mut Painter) -> Size { Size { - x: Len::px(painter.px_len(Axis::X) + self.extra), + x: Len { + px: painter.px_len(Axis::X) + Px::from_f32(self.extra), + ..Len::ZERO + }, y: Len::LEFTOVER, } }