frus_core/geometry.rs
1//! Geometric primitives, expressed in logical pixels.
2//!
3//! frus's coordinate convention: the origin sits at the **top left**, X runs
4//! right, and Y runs **down** — the same convention as CSS.
5
6/// A **2D affine transform** (a 2×3 matrix). A point `(x, y)` becomes
7/// `(a·x + c·y + e, b·x + d·y + f)`: a 2×2 linear part `[[a, c], [b, d]]` (scale,
8/// rotation, shear) plus a translation `(e, f)`.
9///
10/// Compositions read "right to left": `a.then(b)` applies `a` **then** `b`. This is
11/// the unified representation of a [`crate::LayerTransform`]'s paint transforms:
12/// translation, scale (uniform or per axis) and rotation all fold into a **single**
13/// matrix, with no approximation introduced by composing them.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Affine {
16 /// `[a, b, c, d, e, f]`: `x' = a·x + c·y + e`, `y' = b·x + d·y + f`.
17 pub m: [f32; 6],
18}
19
20impl Default for Affine {
21 fn default() -> Self {
22 Self::IDENTITY
23 }
24}
25
26impl Affine {
27 /// The identity — no transform at all.
28 pub const IDENTITY: Affine = Affine {
29 m: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
30 };
31
32 /// A pure translation by `(tx, ty)`.
33 pub const fn translation(tx: f32, ty: f32) -> Affine {
34 Affine {
35 m: [1.0, 0.0, 0.0, 1.0, tx, ty],
36 }
37 }
38
39 /// A per-axis scale, about the origin.
40 pub const fn scale(sx: f32, sy: f32) -> Affine {
41 Affine {
42 m: [sx, 0.0, 0.0, sy, 0.0, 0.0],
43 }
44 }
45
46 /// A rotation of `angle` radians (clockwise, with y pointing down), about the
47 /// origin.
48 pub fn rotation(angle: f32) -> Affine {
49 let (s, c) = angle.sin_cos();
50 Affine {
51 m: [c, s, -s, c, 0.0, 0.0],
52 }
53 }
54
55 /// Composition: `self.then(next)` applies `self` **then** `next`.
56 pub fn then(self, next: Affine) -> Affine {
57 // next ∘ self: expand next(self(p)).
58 let [a, b, c, d, e, f] = self.m;
59 let [a2, b2, c2, d2, e2, f2] = next.m;
60 Affine {
61 m: [
62 a2 * a + c2 * b,
63 b2 * a + d2 * b,
64 a2 * c + c2 * d,
65 b2 * c + d2 * d,
66 a2 * e + c2 * f + e2,
67 b2 * e + d2 * f + f2,
68 ],
69 }
70 }
71
72 /// The same transform, but **about `pivot`**, which stays fixed:
73 /// `T(pivot) ∘ self ∘ T(-pivot)`.
74 pub fn about(self, pivot: Point) -> Affine {
75 Affine::translation(-pivot.x, -pivot.y)
76 .then(self)
77 .then(Affine::translation(pivot.x, pivot.y))
78 }
79
80 /// Applies the transform to a point.
81 pub fn apply(self, p: Point) -> Point {
82 let [a, b, c, d, e, f] = self.m;
83 Point::new(a * p.x + c * p.y + e, b * p.x + d * p.y + f)
84 }
85
86 /// `true` when the transform is **axis-aligned**: its linear part is diagonal
87 /// (scale and/or translation, with no rotation and no shear), so the image of a
88 /// rectangle is still a rectangle.
89 pub fn is_axis_aligned(self) -> bool {
90 self.m[1].abs() < 1e-4 && self.m[2].abs() < 1e-4
91 }
92
93 /// The image of a rectangle under the transform. **Exact** when the matrix is
94 /// axis-aligned ([`Affine::is_axis_aligned`]); otherwise it returns the image's
95 /// **bounding box**, since a rectangle cannot represent a rotated shape.
96 pub fn apply_rect(self, r: Rect) -> Rect {
97 let a = self.apply(Point::new(r.x, r.y));
98 let b = self.apply(Point::new(r.x + r.width, r.y + r.height));
99 Rect::new(
100 a.x.min(b.x),
101 a.y.min(b.y),
102 (a.x - b.x).abs(),
103 (a.y - b.y).abs(),
104 )
105 }
106
107 /// The inverse transform — the identity if the matrix is degenerate.
108 pub fn inverse(self) -> Affine {
109 let [a, b, c, d, e, f] = self.m;
110 let det = a * d - b * c;
111 if det.abs() < f32::EPSILON {
112 return Affine::IDENTITY;
113 }
114 let inv = 1.0 / det;
115 Affine {
116 m: [
117 d * inv,
118 -b * inv,
119 -c * inv,
120 a * inv,
121 (c * f - d * e) * inv,
122 (b * e - a * f) * inv,
123 ],
124 }
125 }
126}
127
128/// A 2D point.
129#[derive(Clone, Copy, Debug, PartialEq, Default)]
130pub struct Point {
131 /// The horizontal coordinate, in logical pixels.
132 pub x: f32,
133 /// The vertical coordinate, in logical pixels.
134 pub y: f32,
135}
136
137impl Point {
138 /// The origin.
139 pub const ZERO: Self = Self::new(0.0, 0.0);
140
141 /// A point at `(x, y)`, in logical pixels.
142 pub const fn new(x: f32, y: f32) -> Self {
143 Self { x, y }
144 }
145
146 /// Multiplies both coordinates by `factor` (logical → physical conversion).
147 pub fn scale(self, factor: f32) -> Self {
148 Self::new(self.x * factor, self.y * factor)
149 }
150
151 /// Multiplies the coordinates **per axis** (`sx`, `sy`).
152 pub fn scale_xy(self, sx: f32, sy: f32) -> Self {
153 Self::new(self.x * sx, self.y * sy)
154 }
155}
156
157/// The **reading direction** of text and layout. An ambient context: in RTL, rows,
158/// alignment and directional insets all flip horizontally.
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
160pub enum TextDirection {
161 /// Left to right (Latin scripts; the default).
162 #[default]
163 Ltr,
164 /// Right to left (Arabic, Hebrew, and others).
165 Rtl,
166}
167
168impl TextDirection {
169 /// `true` when right-to-left.
170 pub fn is_rtl(self) -> bool {
171 matches!(self, TextDirection::Rtl)
172 }
173}
174
175/// Insets — padding or margin — per side, in logical pixels.
176#[derive(Clone, Copy, Debug, PartialEq, Default)]
177pub struct Insets {
178 /// The top inset, in logical pixels.
179 pub top: f32,
180 /// The right inset, in logical pixels.
181 pub right: f32,
182 /// The bottom inset, in logical pixels.
183 pub bottom: f32,
184 /// The left inset, in logical pixels.
185 pub left: f32,
186}
187
188impl Insets {
189 /// No inset on any side.
190 pub const ZERO: Self = Self::new(0.0, 0.0, 0.0, 0.0);
191
192 /// An inset per side, in logical pixels.
193 pub const fn new(top: f32, right: f32, bottom: f32, left: f32) -> Self {
194 Self {
195 top,
196 right,
197 bottom,
198 left,
199 }
200 }
201
202 /// The same inset on all four sides.
203 pub const fn uniform(value: f32) -> Self {
204 Self::new(value, value, value, value)
205 }
206}
207
208/// **Directional** insets: `start`/`end` instead of `left`/`right`. In LTR, `start`
209/// is the left; in RTL it is the right. Resolved into concrete [`Insets`] at layout
210/// time.
211#[derive(Clone, Copy, Debug, PartialEq, Default)]
212pub struct InsetsDirectional {
213 /// The top inset, in logical pixels.
214 pub top: f32,
215 /// The end-edge inset (right in LTR, left in RTL), in logical pixels.
216 pub end: f32,
217 /// The bottom inset, in logical pixels.
218 pub bottom: f32,
219 /// The start-edge inset (left in LTR, right in RTL), in logical pixels.
220 pub start: f32,
221}
222
223impl InsetsDirectional {
224 /// No inset on any side.
225 pub const ZERO: Self = Self::new(0.0, 0.0, 0.0, 0.0);
226
227 /// A directional inset per side, in logical pixels.
228 pub const fn new(top: f32, end: f32, bottom: f32, start: f32) -> Self {
229 Self {
230 top,
231 end,
232 bottom,
233 start,
234 }
235 }
236
237 /// A symmetric horizontal `start`/`end` inset.
238 pub const fn horizontal(value: f32) -> Self {
239 Self::new(0.0, value, 0.0, value)
240 }
241
242 /// Resolves to concrete insets for a direction: in RTL, `start` maps to right.
243 pub fn resolve(self, direction: TextDirection) -> Insets {
244 let (left, right) = if direction.is_rtl() {
245 (self.end, self.start)
246 } else {
247 (self.start, self.end)
248 };
249 Insets::new(self.top, right, self.bottom, left)
250 }
251}
252
253/// A 2D size (width × height).
254#[derive(Clone, Copy, Debug, PartialEq, Default)]
255pub struct Size {
256 /// The width, in logical pixels.
257 pub width: f32,
258 /// The height, in logical pixels.
259 pub height: f32,
260}
261
262impl Size {
263 /// No area.
264 pub const ZERO: Self = Self::new(0.0, 0.0);
265
266 /// A size of `width` × `height`, in logical pixels.
267 pub const fn new(width: f32, height: f32) -> Self {
268 Self { width, height }
269 }
270}
271
272/// Where to place a child inside its containing box: two **continuous** fractions
273/// in `[-1, 1]`. `x = -1` hugs the left, `0` centres, `+1` hugs the right; `y = -1`
274/// is the top, `+1` the bottom. Being continuous, it **interpolates**
275/// ([`crate::Lerp`]): a `Tween<Alignment>` slides a child from one anchor to
276/// another. The nine usual anchors are provided as constants
277/// (`Alignment::CENTER`, `Alignment::TOP_LEFT`, and so on).
278#[derive(Clone, Copy, Debug, PartialEq, Default)]
279pub struct Alignment {
280 /// Horizontal fraction: `-1` (left) … `0` (centre) … `+1` (right).
281 pub x: f32,
282 /// Vertical fraction: `-1` (top) … `0` (centre) … `+1` (bottom).
283 pub y: f32,
284}
285
286impl Alignment {
287 /// Hugs the top-left corner.
288 pub const TOP_LEFT: Self = Self::new(-1.0, -1.0);
289 /// Hugs the top edge, centred horizontally.
290 pub const TOP_CENTER: Self = Self::new(0.0, -1.0);
291 /// Hugs the top-right corner.
292 pub const TOP_RIGHT: Self = Self::new(1.0, -1.0);
293 /// Hugs the left edge, centred vertically.
294 pub const CENTER_LEFT: Self = Self::new(-1.0, 0.0);
295 /// The exact centre.
296 pub const CENTER: Self = Self::new(0.0, 0.0);
297 /// Hugs the right edge, centred vertically.
298 pub const CENTER_RIGHT: Self = Self::new(1.0, 0.0);
299 /// Hugs the bottom-left corner.
300 pub const BOTTOM_LEFT: Self = Self::new(-1.0, 1.0);
301 /// Hugs the bottom edge, centred horizontally.
302 pub const BOTTOM_CENTER: Self = Self::new(0.0, 1.0);
303 /// Hugs the bottom-right corner.
304 pub const BOTTOM_RIGHT: Self = Self::new(1.0, 1.0);
305
306 /// An `(x, y)` anchor, with fractions in `[-1, 1]`.
307 pub const fn new(x: f32, y: f32) -> Self {
308 Self { x, y }
309 }
310
311 /// The horizontal fraction mapped into `[0, 1]` (`0` = left, `1` = right): the
312 /// share of the free space to leave **before** the child on the x axis.
313 pub fn fraction_x(self) -> f32 {
314 ((self.x + 1.0) * 0.5).clamp(0.0, 1.0)
315 }
316
317 /// The vertical fraction mapped into `[0, 1]` (`0` = top, `1` = bottom).
318 pub fn fraction_y(self) -> f32 {
319 ((self.y + 1.0) * 0.5).clamp(0.0, 1.0)
320 }
321}
322
323/// A **directional** anchor: the horizontal axis is expressed **start → end**
324/// rather than left → right. `x_start = -1` hugs the **start** edge (left in LTR,
325/// right in RTL), `+1` the **end** edge. Resolved into a physical [`Alignment`] at
326/// render time from the reading direction — the anchor follows the text without the
327/// caller ever testing which way it runs.
328#[derive(Clone, Copy, Debug, PartialEq, Default)]
329pub struct AlignmentDirectional {
330 /// Start-to-end fraction: `-1` (start) … `0` (centre) … `+1` (end).
331 pub x_start: f32,
332 /// Vertical fraction: `-1` (top) … `+1` (bottom).
333 pub y: f32,
334}
335
336impl AlignmentDirectional {
337 /// Hugs the top-start corner (top-left in LTR, top-right in RTL).
338 pub const TOP_START: Self = Self::new(-1.0, -1.0);
339 /// Hugs the top edge, centred horizontally.
340 pub const TOP_CENTER: Self = Self::new(0.0, -1.0);
341 /// Hugs the top-end corner (top-right in LTR, top-left in RTL).
342 pub const TOP_END: Self = Self::new(1.0, -1.0);
343 /// Hugs the start edge, centred vertically.
344 pub const CENTER_START: Self = Self::new(-1.0, 0.0);
345 /// The exact centre.
346 pub const CENTER: Self = Self::new(0.0, 0.0);
347 /// Hugs the end edge, centred vertically.
348 pub const CENTER_END: Self = Self::new(1.0, 0.0);
349 /// Hugs the bottom-start corner (bottom-left in LTR, bottom-right in RTL).
350 pub const BOTTOM_START: Self = Self::new(-1.0, 1.0);
351 /// Hugs the bottom edge, centred horizontally.
352 pub const BOTTOM_CENTER: Self = Self::new(0.0, 1.0);
353 /// Hugs the bottom-end corner (bottom-right in LTR, bottom-left in RTL).
354 pub const BOTTOM_END: Self = Self::new(1.0, 1.0);
355
356 /// A directional `(x_start, y)` anchor, with fractions in `[-1, 1]`.
357 pub const fn new(x_start: f32, y: f32) -> Self {
358 Self { x_start, y }
359 }
360
361 /// Resolves to a **physical** anchor: in RTL, start maps to right (x is
362 /// mirrored); in LTR, start is left (x unchanged). `y` never depends on it.
363 pub fn resolve(self, direction: TextDirection) -> Alignment {
364 let x = if direction.is_rtl() {
365 -self.x_start
366 } else {
367 self.x_start
368 };
369 Alignment::new(x, self.y)
370 }
371}
372
373/// A **resolvable** anchor — either physical ([`Alignment`]) or directional
374/// ([`AlignmentDirectional`]). A widget accepts either one, through `Into`, and
375/// resolves it against the reading direction at render time.
376#[derive(Clone, Copy, Debug, PartialEq)]
377pub enum AlignmentGeometry {
378 /// A physical anchor, with absolute left and right.
379 Physical(Alignment),
380 /// A directional anchor (start/end, mirrored in RTL).
381 Directional(AlignmentDirectional),
382}
383
384impl AlignmentGeometry {
385 /// Resolves to a physical anchor for a reading direction; an already physical
386 /// anchor is returned unchanged.
387 pub fn resolve(self, direction: TextDirection) -> Alignment {
388 match self {
389 AlignmentGeometry::Physical(a) => a,
390 AlignmentGeometry::Directional(d) => d.resolve(direction),
391 }
392 }
393}
394
395impl AlignmentGeometry {
396 /// This anchor's two fractions, **in its own coordinates**: `x` for a physical
397 /// anchor, start-to-end for a directional one.
398 pub fn fractions(self) -> (f32, f32) {
399 match self {
400 AlignmentGeometry::Physical(a) => (a.x, a.y),
401 AlignmentGeometry::Directional(d) => (d.x_start, d.y),
402 }
403 }
404
405 /// The same **kind** of anchor with different fractions.
406 ///
407 /// This is what an animation between two anchors interpolates. It stays in the
408 /// anchor's own coordinates, so the reading direction is applied to the result rather
409 /// than to the endpoints — a directional anchor sliding from start to end mirrors as a
410 /// whole in a right-to-left script, instead of interpolating between two physical
411 /// points that were already mirrored and crossing in the middle.
412 pub fn with_fractions(self, x: f32, y: f32) -> Self {
413 match self {
414 AlignmentGeometry::Physical(_) => AlignmentGeometry::Physical(Alignment::new(x, y)),
415 AlignmentGeometry::Directional(_) => {
416 AlignmentGeometry::Directional(AlignmentDirectional::new(x, y))
417 }
418 }
419 }
420}
421
422impl From<Alignment> for AlignmentGeometry {
423 fn from(a: Alignment) -> Self {
424 AlignmentGeometry::Physical(a)
425 }
426}
427
428impl From<AlignmentDirectional> for AlignmentGeometry {
429 fn from(d: AlignmentDirectional) -> Self {
430 AlignmentGeometry::Directional(d)
431 }
432}
433
434/// The **window** insets, split by nature: `padding` is the area **permanently**
435/// taken by the system (status and navigation bars, the notch — all static), while
436/// `view_insets` is the area covered by **transient** UI, chiefly the soft
437/// keyboard. Avoiding the keyboard means keeping content clear of
438/// [`WindowInsets::safe`].
439#[derive(Clone, Copy, Debug, PartialEq)]
440pub struct WindowInsets {
441 /// Permanent system areas (bars, notch) **that are still worth avoiding**: the
442 /// intrusions, less whatever [`view_insets`](Self::view_insets) already covers.
443 ///
444 /// Zero at the bottom while the keyboard is up, because the navigation bar it
445 /// hides is not an edge anything needs to stay clear of any more. Padding a screen
446 /// by it as well would leave a strip of nothing between the content and the
447 /// keyboard.
448 pub padding: Insets,
449 /// Transient areas (the soft keyboard); in practice only the bottom moves.
450 ///
451 /// Measured from the window edge, so it already includes whatever bar it covers.
452 pub view_insets: Insets,
453 /// The intrusions **ignoring** anything transient: what the notch and the bars take
454 /// whether or not the keyboard is over them.
455 ///
456 /// The one that does not move when the keyboard opens, which is why a layout that
457 /// must not shift reads this rather than [`padding`](Self::padding).
458 pub view_padding: Insets,
459}
460
461impl WindowInsets {
462 /// No insets at all.
463 pub const ZERO: Self = Self {
464 padding: Insets::ZERO,
465 view_insets: Insets::ZERO,
466 view_padding: Insets::ZERO,
467 };
468
469 /// The intrusions of a surface with nothing transient over them: the bars and the
470 /// notch, and no keyboard.
471 ///
472 /// Here so that nobody has to assemble the three by hand and get them disagreeing.
473 /// `padding` and `view_padding` are the same thing while there is no keyboard, and
474 /// a hand-written literal that says otherwise describes a surface no platform can
475 /// report.
476 pub const fn bars(intrusions: Insets) -> Self {
477 Self {
478 padding: intrusions,
479 view_insets: Insets::ZERO,
480 view_padding: intrusions,
481 }
482 }
483
484 /// The total area to avoid: the per-side **maximum** of the two kinds. The
485 /// keyboard covers the navigation bar, so they are not added together.
486 pub fn safe(&self) -> Insets {
487 Insets::new(
488 self.padding.top.max(self.view_insets.top),
489 self.padding.right.max(self.view_insets.right),
490 self.padding.bottom.max(self.view_insets.bottom),
491 self.padding.left.max(self.view_insets.left),
492 )
493 }
494
495 /// Splits raw insets into `(padding, view_insets)` given a **keyboard-free**
496 /// reference. A **bottom** excess beyond that reference signals the keyboard;
497 /// `view_insets.bottom` then measures the **total** occlusion **from the window
498 /// edge**, bar included. That is what makes combining the two with `max`
499 /// correct.
500 pub fn from_baseline(baseline: Insets, current: Insets) -> WindowInsets {
501 let keyboard = (current.bottom - baseline.bottom).max(0.0);
502 // The intrusions with the keyboard taken back out: `current.bottom - keyboard`
503 // is the baseline's bottom by construction, and the other three sides do not
504 // move with a keyboard.
505 let view_padding = Insets::new(
506 current.top,
507 current.right,
508 current.bottom - keyboard,
509 current.left,
510 );
511 let view_insets = Insets::new(
512 0.0,
513 0.0,
514 if keyboard > 0.0 { current.bottom } else { 0.0 },
515 0.0,
516 );
517 WindowInsets {
518 padding: subtract(view_padding, view_insets),
519 view_insets,
520 view_padding,
521 }
522 }
523}
524
525/// `a` less `b`, side by side, never below zero.
526///
527/// What makes the padding the *remaining* intrusion rather than the whole one: a
528/// navigation bar under an open keyboard is covered, so nothing has to avoid it, and
529/// counting it twice would leave a strip of nothing above the keys.
530fn subtract(a: Insets, b: Insets) -> Insets {
531 Insets::new(
532 (a.top - b.top).max(0.0),
533 (a.right - b.right).max(0.0),
534 (a.bottom - b.bottom).max(0.0),
535 (a.left - b.left).max(0.0),
536 )
537}
538
539impl Default for WindowInsets {
540 fn default() -> Self {
541 Self::ZERO
542 }
543}
544
545/// An axis-aligned rectangle, in logical pixels.
546#[derive(Clone, Copy, Debug, PartialEq)]
547pub struct Rect {
548 /// Left edge.
549 pub x: f32,
550 /// Top edge.
551 pub y: f32,
552 /// Width, extending to the right.
553 pub width: f32,
554 /// Height, extending downwards.
555 pub height: f32,
556}
557
558impl Rect {
559 /// An "infinite" rectangle, used as a neutral clip that clips nothing.
560 pub const UNBOUNDED: Rect = Rect::new(-1.0e7, -1.0e7, 2.0e7, 2.0e7);
561
562 /// Builds a rectangle from its position (top-left corner) and its size.
563 pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
564 Self {
565 x,
566 y,
567 width,
568 height,
569 }
570 }
571
572 /// Offsets the rectangle by `(dx, dy)`.
573 pub fn translate(self, dx: f32, dy: f32) -> Self {
574 Self::new(self.x + dx, self.y + dy, self.width, self.height)
575 }
576
577 /// Multiplies position and size by `factor` (logical → physical conversion).
578 pub fn scale(self, factor: f32) -> Self {
579 Self::new(
580 self.x * factor,
581 self.y * factor,
582 self.width * factor,
583 self.height * factor,
584 )
585 }
586
587 /// Scales the rectangle by `factor` **about `pivot`**, which stays fixed:
588 /// `pos' = pivot + (pos - pivot) * factor`, with size × `factor`.
589 pub fn scale_about(self, pivot: Point, factor: f32) -> Self {
590 self.scale_about_xy(pivot, factor, factor)
591 }
592
593 /// Scales the rectangle **per axis** about `pivot`.
594 pub fn scale_about_xy(self, pivot: Point, sx: f32, sy: f32) -> Self {
595 Self::new(
596 pivot.x + (self.x - pivot.x) * sx,
597 pivot.y + (self.y - pivot.y) * sy,
598 self.width * sx,
599 self.height * sy,
600 )
601 }
602
603 /// Multiplies position and size **per axis** (`sx`, `sy`).
604 pub fn scale_xy(self, sx: f32, sy: f32) -> Self {
605 Self::new(self.x * sx, self.y * sy, self.width * sx, self.height * sy)
606 }
607
608 /// The intersection of two rectangles; zero-sized when they are disjoint.
609 pub fn intersect(self, other: Rect) -> Self {
610 let x0 = self.x.max(other.x);
611 let y0 = self.y.max(other.y);
612 let x1 = (self.x + self.width).min(other.x + other.width);
613 let y1 = (self.y + self.height).min(other.y + other.height);
614 Self::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
615 }
616
617 /// The smallest rectangle enclosing both `self` **and** `other`.
618 pub fn union(self, other: Rect) -> Self {
619 let x0 = self.x.min(other.x);
620 let y0 = self.y.min(other.y);
621 let x1 = (self.x + self.width).max(other.x + other.width);
622 let y1 = (self.y + self.height).max(other.y + other.height);
623 Self::new(x0, y0, x1 - x0, y1 - y0)
624 }
625
626 /// `true` when `point` lies inside; the left and top edges count as inside.
627 pub fn contains(self, point: Point) -> bool {
628 point.x >= self.x
629 && point.x < self.x + self.width
630 && point.y >= self.y
631 && point.y < self.y + self.height
632 }
633
634 /// Builds a rectangle from an origin point and a size.
635 pub const fn from_point_size(origin: Point, size: Size) -> Self {
636 Self::new(origin.x, origin.y, size.width, size.height)
637 }
638
639 /// The top-left corner.
640 pub const fn origin(self) -> Point {
641 Point::new(self.x, self.y)
642 }
643
644 /// The rectangle's size.
645 pub const fn size(self) -> Size {
646 Size::new(self.width, self.height)
647 }
648
649 /// The `[x, y, width, height]` form, ready for the GPU.
650 pub const fn to_array(self) -> [f32; 4] {
651 [self.x, self.y, self.width, self.height]
652 }
653}
654
655/// **The rule for anything that measures itself**: report a *whole* number of pixels,
656/// rounded **up**.
657///
658/// The layout engine rounds every box to whole pixels, and it is right to: two boxes that
659/// abut on a fractional edge each anti-alias into the pixel they share, and what shows
660/// through between them is the background — a bright hairline seam, not a soft join.
661/// Milestone 496's measurement of it is `(136, 187, 136)` where red met blue over green.
662///
663/// The price of that rounding is this rule. A widget that measures itself at `146.4` and
664/// says so is given a box of `146`, and `146 < 146.4` — so at paint time, where it is
665/// measured again, it no longer fits and wraps, clips or overflows. That is milestone
666/// 289, and it presented as a mysteriously missing line of text rather than as
667/// arithmetic.
668///
669/// So a measurement goes through here, and the box it is rounded to is one it still fits
670/// in. Where the measurement is then clamped to a constraint, clamp **after**: a box a
671/// fraction wider than it was allowed is a different bug.
672///
673/// ```
674/// use frus_core::fits;
675/// assert_eq!(fits(146.4), 147.0);
676/// assert_eq!(fits(146.0), 146.0);
677/// ```
678pub fn fits(extent: f32) -> f32 {
679 // `ceil` and not `round`: rounding to nearest is the bug this exists to prevent, and
680 // it is the one a new measurer will write by reflex.
681 extent.ceil()
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn affine_composes_scale_then_rotate_about_a_pivot() {
690 use std::f32::consts::FRAC_PI_2;
691 let pivot = Point::new(10.0, 10.0);
692 // Scale ×2 then rotate +90°, both about the pivot.
693 let m = Affine::scale(2.0, 2.0)
694 .about(pivot)
695 .then(Affine::rotation(FRAC_PI_2).about(pivot));
696 // The pivot stays put.
697 let c = m.apply(pivot);
698 assert!(
699 (c.x - 10.0).abs() < 1e-3 && (c.y - 10.0).abs() < 1e-3,
700 "pivot fixe : {c:?}"
701 );
702 // Linear part = rotation(90°) ∘ scale(2) = [0, 2, -2, 0].
703 assert!(m.m[0].abs() < 1e-3 && (m.m[1] - 2.0).abs() < 1e-3);
704 assert!((m.m[2] + 2.0).abs() < 1e-3 && m.m[3].abs() < 1e-3);
705 }
706
707 #[test]
708 fn affine_inverse_round_trips() {
709 use std::f32::consts::FRAC_PI_3;
710 let m = Affine::scale(1.5, 0.5)
711 .about(Point::new(4.0, 7.0))
712 .then(Affine::rotation(FRAC_PI_3).about(Point::new(2.0, 3.0)))
713 .then(Affine::translation(5.0, -2.0));
714 let inv = m.inverse();
715 for p in [
716 Point::new(0.0, 0.0),
717 Point::new(12.0, -3.0),
718 Point::new(-5.0, 8.0),
719 ] {
720 let back = inv.apply(m.apply(p));
721 assert!(
722 (back.x - p.x).abs() < 1e-2 && (back.y - p.y).abs() < 1e-2,
723 "{p:?} -> {back:?}"
724 );
725 }
726 }
727
728 #[test]
729 fn directional_alignment_resolves_by_direction() {
730 // start = the reading edge: left in LTR, right in RTL.
731 let start = AlignmentDirectional::CENTER_START;
732 assert_eq!(start.resolve(TextDirection::Ltr), Alignment::CENTER_LEFT);
733 assert_eq!(start.resolve(TextDirection::Rtl), Alignment::CENTER_RIGHT);
734 // The centre and the vertical axis do not depend on direction.
735 let top = AlignmentDirectional::TOP_CENTER;
736 assert_eq!(top.resolve(TextDirection::Rtl), Alignment::TOP_CENTER);
737 }
738
739 #[test]
740 fn alignment_geometry_unifies_physical_and_directional() {
741 // Physical: resolved as-is, whatever the direction.
742 let phys: AlignmentGeometry = Alignment::CENTER_LEFT.into();
743 assert_eq!(phys.resolve(TextDirection::Rtl), Alignment::CENTER_LEFT);
744 // Directional: follows the reading direction.
745 let dir: AlignmentGeometry = AlignmentDirectional::CENTER_START.into();
746 assert_eq!(dir.resolve(TextDirection::Ltr), Alignment::CENTER_LEFT);
747 assert_eq!(dir.resolve(TextDirection::Rtl), Alignment::CENTER_RIGHT);
748 }
749
750 #[test]
751 fn directional_insets_flip_start_end() {
752 // start=10, end=4: in LTR start is left; in RTL start is right.
753 let d = InsetsDirectional::new(1.0, 4.0, 2.0, 10.0);
754 let ltr = d.resolve(TextDirection::Ltr);
755 assert_eq!(
756 (ltr.left, ltr.right, ltr.top, ltr.bottom),
757 (10.0, 4.0, 1.0, 2.0)
758 );
759 let rtl = d.resolve(TextDirection::Rtl);
760 assert_eq!(
761 (rtl.left, rtl.right),
762 (4.0, 10.0),
763 "start moves to the right under RTL"
764 );
765 // The vertical axis never moves.
766 assert_eq!((rtl.top, rtl.bottom), (1.0, 2.0));
767 }
768
769 #[test]
770 fn window_insets_split_and_safe_area() {
771 // Keyboard-free reference: the top and bottom system bars.
772 let baseline = Insets::new(84.0, 0.0, 45.0, 0.0);
773
774 // Keyboard closed: everything is static padding, nothing transient, and the
775 // intrusion that does not move is the same as the one that does.
776 let closed = WindowInsets::from_baseline(baseline, baseline);
777 assert_eq!(closed.padding, baseline);
778 assert_eq!(closed.view_padding, baseline);
779 assert_eq!(closed.view_insets, Insets::ZERO);
780 assert_eq!(closed.safe(), baseline);
781
782 // Keyboard open (bottom excess of 300): `view_insets.bottom` measures the
783 // total occlusion from the edge (345, bar included).
784 let open = WindowInsets::from_baseline(baseline, Insets::new(84.0, 0.0, 345.0, 0.0));
785 assert_eq!(open.view_insets, Insets::new(0.0, 0.0, 345.0, 0.0));
786 // The bar has not moved, so `view_padding` still reports it — while `padding`
787 // reports what is **left** to avoid, which at the bottom is nothing at all. The
788 // keyboard is over the bar; padding by both would leave a strip of nothing
789 // between the content and the keys.
790 assert_eq!(open.view_padding, baseline);
791 assert_eq!(open.padding, Insets::new(84.0, 0.0, 0.0, 0.0));
792 // The safe area is the per-side max — the keyboard covers the bar.
793 assert_eq!(open.safe(), Insets::new(84.0, 0.0, 345.0, 0.0));
794
795 // Current bottom BELOW the reference (bars hidden): no negative keyboard.
796 let hidden = WindowInsets::from_baseline(baseline, Insets::new(84.0, 0.0, 10.0, 0.0));
797 assert_eq!(hidden.view_insets, Insets::ZERO);
798 assert_eq!(hidden.padding.bottom, 10.0);
799 assert_eq!(hidden.view_padding.bottom, 10.0);
800 }
801
802 #[test]
803 fn rect_from_point_size_roundtrips() {
804 let r = Rect::from_point_size(Point::new(3.0, 4.0), Size::new(10.0, 20.0));
805 assert_eq!(r, Rect::new(3.0, 4.0, 10.0, 20.0));
806 assert_eq!(r.origin(), Point::new(3.0, 4.0));
807 assert_eq!(r.size(), Size::new(10.0, 20.0));
808 assert_eq!(r.to_array(), [3.0, 4.0, 10.0, 20.0]);
809 }
810}