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