Skip to main content

kui_core/
geom.rs

1#[repr(C)]
2#[derive(Clone, Copy, Debug, Default, PartialEq)]
3pub struct Vec2 {
4    pub x: f32,
5    pub y: f32,
6}
7
8impl Vec2 {
9    pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 };
10
11    /// `{x, y}` — an offset as a readback spells it.
12    pub fn to_value(self) -> crate::value::Value {
13        use crate::value::Value;
14        Value::map([("x", Value::float(self.x)), ("y", Value::float(self.y))])
15    }
16
17    pub fn new(x: f32, y: f32) -> Self {
18        Self { x, y }
19    }
20
21    /// Component-wise sum.
22    pub fn plus(self, o: Vec2) -> Vec2 {
23        Vec2::new(self.x + o.x, self.y + o.y)
24    }
25
26    /// Component-wise difference.
27    pub fn minus(self, o: Vec2) -> Vec2 {
28        Vec2::new(self.x - o.x, self.y - o.y)
29    }
30
31    /// This displacement rounded to a whole number of physical pixels.
32    ///
33    /// Every offset a *subtree* is moved by goes through here — a `slide`,
34    /// an `enter`/`exit` offset, a scroll — because glyphs are placed at
35    /// whole physical pixels (one raster per glyph, `text::emit`) and their
36    /// box is not. A fractional displacement moves the two by different
37    /// amounts, so text wobbles ±0.5 px inside its own background for as
38    /// long as the motion lasts; a whole one moves them together. Where a
39    /// node sits when it is *still* is untouched: this rounds the offset,
40    /// not the position, so a card laid out at a fractional x stays there
41    /// and its text keeps the gap it had. See backlog W7.
42    pub(crate) fn snapped(self, scale: f32) -> Self {
43        if scale <= 0.0 || !scale.is_finite() {
44            return self;
45        }
46        Self::new(
47            snap_px(self.x * scale) / scale,
48            snap_px(self.y * scale) / scale,
49        )
50    }
51}
52
53/// A physical coordinate put on the pixel grid — where a run of glyphs or a
54/// cell grid is placed, so one raster serves every frame.
55///
56/// `floor(v + 0.5)` and not `v.round()`, because this has to survive being
57/// *moved*: `round` breaks a .5 tie away from zero, so text sitting at
58/// exactly x.5 jumps a whole pixel the moment it crosses the origin, which
59/// is the wobble [`Vec2::snapped`] removes coming back at one line on the
60/// screen. This one obeys `snap_px(v + k) == snap_px(v) + k` for every
61/// whole `k`, which is the property that makes a snapped displacement move
62/// a box and its text by the same amount.
63///
64/// The bias is what makes that property survive floating point. At 150%
65/// every other whole logical pixel *is* a half physical one, so exact ties
66/// are ordinary here, not a corner — and a snapped displacement reaches
67/// this through a divide by the scale and a multiply back, which lands a
68/// microscopic hair either side of the tie and picks a different pixel each
69/// way. A thousandth of a pixel is three orders above that noise and three
70/// below anything a placement could show. (Past ~2^16 physical pixels the
71/// float spacing overtakes it again; that is well off any screen.)
72pub(crate) fn snap_px(v: f32) -> f32 {
73    const TIE: f32 = 1.0 / 1024.0;
74    (v + 0.5 + TIE).floor()
75}
76
77#[repr(C)]
78#[derive(Clone, Copy, Debug, Default, PartialEq)]
79pub struct Size {
80    pub w: f32,
81    pub h: f32,
82}
83
84impl Size {
85    pub const ZERO: Size = Size { w: 0.0, h: 0.0 };
86
87    pub fn new(w: f32, h: f32) -> Self {
88        Self { w, h }
89    }
90}
91
92#[repr(C)]
93#[derive(Clone, Copy, Debug, Default, PartialEq)]
94pub struct Rect {
95    pub x: f32,
96    pub y: f32,
97    pub w: f32,
98    pub h: f32,
99}
100
101impl Rect {
102    /// `{x, y, w, h}` — a rect as a readback spells it: a caret, a
103    /// scroller's box, a window's anchor.
104    pub fn to_value(self) -> crate::value::Value {
105        use crate::value::Value;
106        Value::map([
107            ("x", Value::float(self.x)),
108            ("y", Value::float(self.y)),
109            ("w", Value::float(self.w)),
110            ("h", Value::float(self.h)),
111        ])
112    }
113
114    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
115        Self { x, y, w, h }
116    }
117
118    pub fn from_pos_size(pos: Vec2, size: Size) -> Self {
119        Self {
120            x: pos.x,
121            y: pos.y,
122            w: size.w,
123            h: size.h,
124        }
125    }
126
127    /// The point halfway across and halfway down.
128    pub fn center(&self) -> Vec2 {
129        Vec2 {
130            x: self.x + self.w / 2.0,
131            y: self.y + self.h / 2.0,
132        }
133    }
134
135    pub fn contains(&self, p: Vec2) -> bool {
136        p.x >= self.x && p.x < self.x + self.w && p.y >= self.y && p.y < self.y + self.h
137    }
138
139    pub fn scaled(&self, s: f32) -> Rect {
140        Rect {
141            x: self.x * s,
142            y: self.y * s,
143            w: self.w * s,
144            h: self.h * s,
145        }
146    }
147
148    /// This physical rect with each edge snapped to a whole pixel
149    /// ([`snap_px`]) on its own, so two rects that share an edge land it
150    /// on the same pixel line: `pixelSnap` boxes, and a text's
151    /// backgrounds (`text::emit`).
152    pub(crate) fn on_pixels(&self) -> Rect {
153        let (x0, y0) = (snap_px(self.x), snap_px(self.y));
154        let (x1, y1) = (snap_px(self.x + self.w), snap_px(self.y + self.h));
155        Rect::new(x0, y0, x1 - x0, y1 - y0)
156    }
157
158    /// The smallest rect holding both. What a selection spanning several
159    /// runs is anchored by (ADR 0017's Look Up panel).
160    pub fn union(&self, other: &Rect) -> Rect {
161        let x = self.x.min(other.x);
162        let y = self.y.min(other.y);
163        let r = (self.x + self.w).max(other.x + other.w);
164        let b = (self.y + self.h).max(other.y + other.h);
165        Rect::new(x, y, r - x, b - y)
166    }
167
168    pub fn intersect(&self, other: &Rect) -> Rect {
169        let x = self.x.max(other.x);
170        let y = self.y.max(other.y);
171        let r = (self.x + self.w).min(other.x + other.w);
172        let b = (self.y + self.h).min(other.y + other.h);
173        Rect {
174            x,
175            y,
176            w: (r - x).max(0.0),
177            h: (b - y).max(0.0),
178        }
179    }
180}
181
182/// Per-side lengths: padding, borders.
183#[repr(C)]
184#[derive(Clone, Copy, Debug, Default, PartialEq)]
185pub struct Edges {
186    pub l: f32,
187    pub r: f32,
188    pub t: f32,
189    pub b: f32,
190}
191
192impl Edges {
193    pub fn all(v: f32) -> Self {
194        Self {
195            l: v,
196            r: v,
197            t: v,
198            b: v,
199        }
200    }
201
202    pub fn xy(x: f32, y: f32) -> Self {
203        Self {
204            l: x,
205            r: x,
206            t: y,
207            b: y,
208        }
209    }
210
211    /// Total horizontal extent.
212    pub fn x(&self) -> f32 {
213        self.l + self.r
214    }
215
216    /// Total vertical extent.
217    pub fn y(&self) -> f32 {
218        self.t + self.b
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn a_whole_pixel_shift_moves_a_snapped_coordinate_by_exactly_that() {
228        // The property `Vec2::snapped` relies on: box and text move
229        // together only if shifting by a whole pixel shifts the snapped
230        // coordinate by the same whole pixel — at a tie, across zero, and
231        // through the float noise a logical round trip leaves behind.
232        for v in [0.0f32, 0.25, 0.5, 10.5, -0.5, -26.5, 31.5, 7.3, -118.5] {
233            for k in [-200.0f32, -1.0, 0.0, 1.0, 3.0, 141.0] {
234                assert_eq!(
235                    snap_px(v + k),
236                    snap_px(v) + k,
237                    "snap_px({v}) shifted by {k}"
238                );
239            }
240        }
241    }
242
243    #[test]
244    fn a_snapped_displacement_is_whole_physical_pixels() {
245        for scale in [1.0f32, 1.25, 1.5, 2.0, 3.0] {
246            for d in [0.0f32, 0.1, -0.4, 12.34, -99.9] {
247                let s = Vec2::new(d, -d).snapped(scale);
248                for v in [s.x, s.y] {
249                    let px = v * scale;
250                    assert!((px - px.round()).abs() < 1e-3, "{v} at {scale} is {px} px");
251                }
252            }
253        }
254        // A scale that cannot be divided by is left alone rather than
255        // turning a position into a NaN.
256        assert_eq!(Vec2::new(1.5, 2.5).snapped(0.0), Vec2::new(1.5, 2.5));
257    }
258}