Skip to main content

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    /// `horizontal` on the left and the right, `vertical` on the top and the bottom.
208    pub const fn symmetric(horizontal: f32, vertical: f32) -> Self {
209        Self::new(vertical, horizontal, vertical, horizontal)
210    }
211}
212
213/// **Directional** insets: `start`/`end` instead of `left`/`right`. In LTR, `start`
214/// is the left; in RTL it is the right. Resolved into concrete [`Insets`] at layout
215/// time.
216#[derive(Clone, Copy, Debug, PartialEq, Default)]
217pub struct InsetsDirectional {
218    /// The top inset, in logical pixels.
219    pub top: f32,
220    /// The end-edge inset (right in LTR, left in RTL), in logical pixels.
221    pub end: f32,
222    /// The bottom inset, in logical pixels.
223    pub bottom: f32,
224    /// The start-edge inset (left in LTR, right in RTL), in logical pixels.
225    pub start: f32,
226}
227
228impl InsetsDirectional {
229    /// No inset on any side.
230    pub const ZERO: Self = Self::new(0.0, 0.0, 0.0, 0.0);
231
232    /// A directional inset per side, in logical pixels.
233    pub const fn new(top: f32, end: f32, bottom: f32, start: f32) -> Self {
234        Self {
235            top,
236            end,
237            bottom,
238            start,
239        }
240    }
241
242    /// A symmetric horizontal `start`/`end` inset.
243    pub const fn horizontal(value: f32) -> Self {
244        Self::new(0.0, value, 0.0, value)
245    }
246
247    /// An inset on the **start** edge only: the left in a left-to-right script, the right in a
248    /// right-to-left one.
249    pub const fn only_start(value: f32) -> Self {
250        Self::new(0.0, 0.0, 0.0, value)
251    }
252
253    /// An inset on the **end** edge only.
254    pub const fn only_end(value: f32) -> Self {
255        Self::new(0.0, value, 0.0, 0.0)
256    }
257
258    /// Resolves to concrete insets for a direction: in RTL, `start` maps to right.
259    pub fn resolve(self, direction: TextDirection) -> Insets {
260        let (left, right) = if direction.is_rtl() {
261            (self.end, self.start)
262        } else {
263            (self.start, self.end)
264        };
265        Insets::new(self.top, right, self.bottom, left)
266    }
267}
268
269/// Insets that are either **physical** ([`Insets`]) or **directional**
270/// ([`InsetsDirectional`]). A widget accepts either one — or one number for all four sides —
271/// through `Into`, and resolves it against the reading direction at layout time.
272#[derive(Clone, Copy, Debug, PartialEq)]
273pub enum InsetsGeometry {
274    /// Physical insets, with absolute left and right.
275    Physical(Insets),
276    /// Directional insets (start/end, mirrored in a right-to-left script).
277    Directional(InsetsDirectional),
278}
279
280impl InsetsGeometry {
281    /// Resolves to physical insets for a reading direction; physical ones are returned
282    /// unchanged.
283    pub fn resolve(self, direction: TextDirection) -> Insets {
284        match self {
285            InsetsGeometry::Physical(insets) => insets,
286            InsetsGeometry::Directional(insets) => insets.resolve(direction),
287        }
288    }
289
290    /// The insets to **lay a box out with**, in a frame laid out left to right and then
291    /// mirrored as a whole for a right-to-left script — which is how frus lays out.
292    ///
293    /// A directional inset is resolved as if left to right: the mirror puts its start on the
294    /// right. A physical one is swapped beforehand in a right-to-left script, so that the
295    /// mirror puts it back on the side it names: a left inset is on the left in Arabic too.
296    pub fn laid_out(self, direction: TextDirection) -> Insets {
297        match self {
298            InsetsGeometry::Directional(insets) => insets.resolve(TextDirection::Ltr),
299            InsetsGeometry::Physical(insets) if direction.is_rtl() => Insets {
300                left: insets.right,
301                right: insets.left,
302                ..insets
303            },
304            InsetsGeometry::Physical(insets) => insets,
305        }
306    }
307}
308
309impl From<Insets> for InsetsGeometry {
310    fn from(insets: Insets) -> Self {
311        InsetsGeometry::Physical(insets)
312    }
313}
314
315impl From<InsetsDirectional> for InsetsGeometry {
316    fn from(insets: InsetsDirectional) -> Self {
317        InsetsGeometry::Directional(insets)
318    }
319}
320
321/// One number: the same inset on all four sides.
322impl From<f32> for InsetsGeometry {
323    fn from(value: f32) -> Self {
324        InsetsGeometry::Physical(Insets::uniform(value))
325    }
326}
327
328/// A 2D size (width × height).
329#[derive(Clone, Copy, Debug, PartialEq, Default)]
330pub struct Size {
331    /// The width, in logical pixels.
332    pub width: f32,
333    /// The height, in logical pixels.
334    pub height: f32,
335}
336
337impl Size {
338    /// No area.
339    pub const ZERO: Self = Self::new(0.0, 0.0);
340
341    /// A size of `width` × `height`, in logical pixels.
342    pub const fn new(width: f32, height: f32) -> Self {
343        Self { width, height }
344    }
345}
346
347/// Where to place a child inside its containing box: two **continuous** fractions
348/// in `[-1, 1]`. `x = -1` hugs the left, `0` centres, `+1` hugs the right; `y = -1`
349/// is the top, `+1` the bottom. Being continuous, it **interpolates**
350/// ([`crate::Lerp`]): a `Tween<Alignment>` slides a child from one anchor to
351/// another. The nine usual anchors are provided as constants
352/// (`Alignment::CENTER`, `Alignment::TOP_LEFT`, and so on).
353#[derive(Clone, Copy, Debug, PartialEq, Default)]
354pub struct Alignment {
355    /// Horizontal fraction: `-1` (left) … `0` (centre) … `+1` (right).
356    pub x: f32,
357    /// Vertical fraction: `-1` (top) … `0` (centre) … `+1` (bottom).
358    pub y: f32,
359}
360
361impl Alignment {
362    /// Hugs the top-left corner.
363    pub const TOP_LEFT: Self = Self::new(-1.0, -1.0);
364    /// Hugs the top edge, centred horizontally.
365    pub const TOP_CENTER: Self = Self::new(0.0, -1.0);
366    /// Hugs the top-right corner.
367    pub const TOP_RIGHT: Self = Self::new(1.0, -1.0);
368    /// Hugs the left edge, centred vertically.
369    pub const CENTER_LEFT: Self = Self::new(-1.0, 0.0);
370    /// The exact centre.
371    pub const CENTER: Self = Self::new(0.0, 0.0);
372    /// Hugs the right edge, centred vertically.
373    pub const CENTER_RIGHT: Self = Self::new(1.0, 0.0);
374    /// Hugs the bottom-left corner.
375    pub const BOTTOM_LEFT: Self = Self::new(-1.0, 1.0);
376    /// Hugs the bottom edge, centred horizontally.
377    pub const BOTTOM_CENTER: Self = Self::new(0.0, 1.0);
378    /// Hugs the bottom-right corner.
379    pub const BOTTOM_RIGHT: Self = Self::new(1.0, 1.0);
380
381    /// An `(x, y)` anchor, with fractions in `[-1, 1]`.
382    pub const fn new(x: f32, y: f32) -> Self {
383        Self { x, y }
384    }
385
386    /// The horizontal fraction mapped into `[0, 1]` (`0` = left, `1` = right): the
387    /// share of the free space to leave **before** the child on the x axis.
388    pub fn fraction_x(self) -> f32 {
389        ((self.x + 1.0) * 0.5).clamp(0.0, 1.0)
390    }
391
392    /// The vertical fraction mapped into `[0, 1]` (`0` = top, `1` = bottom).
393    pub fn fraction_y(self) -> f32 {
394        ((self.y + 1.0) * 0.5).clamp(0.0, 1.0)
395    }
396}
397
398/// A **directional** anchor: the horizontal axis is expressed **start → end**
399/// rather than left → right. `x_start = -1` hugs the **start** edge (left in LTR,
400/// right in RTL), `+1` the **end** edge. Resolved into a physical [`Alignment`] at
401/// render time from the reading direction — the anchor follows the text without the
402/// caller ever testing which way it runs.
403#[derive(Clone, Copy, Debug, PartialEq, Default)]
404pub struct AlignmentDirectional {
405    /// Start-to-end fraction: `-1` (start) … `0` (centre) … `+1` (end).
406    pub x_start: f32,
407    /// Vertical fraction: `-1` (top) … `+1` (bottom).
408    pub y: f32,
409}
410
411impl AlignmentDirectional {
412    /// Hugs the top-start corner (top-left in LTR, top-right in RTL).
413    pub const TOP_START: Self = Self::new(-1.0, -1.0);
414    /// Hugs the top edge, centred horizontally.
415    pub const TOP_CENTER: Self = Self::new(0.0, -1.0);
416    /// Hugs the top-end corner (top-right in LTR, top-left in RTL).
417    pub const TOP_END: Self = Self::new(1.0, -1.0);
418    /// Hugs the start edge, centred vertically.
419    pub const CENTER_START: Self = Self::new(-1.0, 0.0);
420    /// The exact centre.
421    pub const CENTER: Self = Self::new(0.0, 0.0);
422    /// Hugs the end edge, centred vertically.
423    pub const CENTER_END: Self = Self::new(1.0, 0.0);
424    /// Hugs the bottom-start corner (bottom-left in LTR, bottom-right in RTL).
425    pub const BOTTOM_START: Self = Self::new(-1.0, 1.0);
426    /// Hugs the bottom edge, centred horizontally.
427    pub const BOTTOM_CENTER: Self = Self::new(0.0, 1.0);
428    /// Hugs the bottom-end corner (bottom-right in LTR, bottom-left in RTL).
429    pub const BOTTOM_END: Self = Self::new(1.0, 1.0);
430
431    /// A directional `(x_start, y)` anchor, with fractions in `[-1, 1]`.
432    pub const fn new(x_start: f32, y: f32) -> Self {
433        Self { x_start, y }
434    }
435
436    /// Resolves to a **physical** anchor: in RTL, start maps to right (x is
437    /// mirrored); in LTR, start is left (x unchanged). `y` never depends on it.
438    pub fn resolve(self, direction: TextDirection) -> Alignment {
439        let x = if direction.is_rtl() {
440            -self.x_start
441        } else {
442            self.x_start
443        };
444        Alignment::new(x, self.y)
445    }
446}
447
448/// A **resolvable** anchor — either physical ([`Alignment`]) or directional
449/// ([`AlignmentDirectional`]). A widget accepts either one, through `Into`, and
450/// resolves it against the reading direction at render time.
451#[derive(Clone, Copy, Debug, PartialEq)]
452pub enum AlignmentGeometry {
453    /// A physical anchor, with absolute left and right.
454    Physical(Alignment),
455    /// A directional anchor (start/end, mirrored in RTL).
456    Directional(AlignmentDirectional),
457}
458
459impl AlignmentGeometry {
460    /// Resolves to a physical anchor for a reading direction; an already physical
461    /// anchor is returned unchanged.
462    pub fn resolve(self, direction: TextDirection) -> Alignment {
463        match self {
464            AlignmentGeometry::Physical(a) => a,
465            AlignmentGeometry::Directional(d) => d.resolve(direction),
466        }
467    }
468}
469
470impl AlignmentGeometry {
471    /// This anchor's two fractions, **in its own coordinates**: `x` for a physical
472    /// anchor, start-to-end for a directional one.
473    pub fn fractions(self) -> (f32, f32) {
474        match self {
475            AlignmentGeometry::Physical(a) => (a.x, a.y),
476            AlignmentGeometry::Directional(d) => (d.x_start, d.y),
477        }
478    }
479
480    /// The same **kind** of anchor with different fractions.
481    ///
482    /// This is what an animation between two anchors interpolates. It stays in the
483    /// anchor's own coordinates, so the reading direction is applied to the result rather
484    /// than to the endpoints — a directional anchor sliding from start to end mirrors as a
485    /// whole in a right-to-left script, instead of interpolating between two physical
486    /// points that were already mirrored and crossing in the middle.
487    pub fn with_fractions(self, x: f32, y: f32) -> Self {
488        match self {
489            AlignmentGeometry::Physical(_) => AlignmentGeometry::Physical(Alignment::new(x, y)),
490            AlignmentGeometry::Directional(_) => {
491                AlignmentGeometry::Directional(AlignmentDirectional::new(x, y))
492            }
493        }
494    }
495}
496
497impl From<Alignment> for AlignmentGeometry {
498    fn from(a: Alignment) -> Self {
499        AlignmentGeometry::Physical(a)
500    }
501}
502
503impl From<AlignmentDirectional> for AlignmentGeometry {
504    fn from(d: AlignmentDirectional) -> Self {
505        AlignmentGeometry::Directional(d)
506    }
507}
508
509/// The **window** insets, split by nature: `padding` is the area **permanently**
510/// taken by the system (status and navigation bars, the notch — all static), while
511/// `view_insets` is the area covered by **transient** UI, chiefly the soft
512/// keyboard. Avoiding the keyboard means keeping content clear of
513/// [`WindowInsets::safe`].
514#[derive(Clone, Copy, Debug, PartialEq)]
515pub struct WindowInsets {
516    /// Permanent system areas (bars, notch) **that are still worth avoiding**: the
517    /// intrusions, less whatever [`view_insets`](Self::view_insets) already covers.
518    ///
519    /// Zero at the bottom while the keyboard is up, because the navigation bar it
520    /// hides is not an edge anything needs to stay clear of any more. Padding a screen
521    /// by it as well would leave a strip of nothing between the content and the
522    /// keyboard.
523    pub padding: Insets,
524    /// Transient areas (the soft keyboard); in practice only the bottom moves.
525    ///
526    /// Measured from the window edge, so it already includes whatever bar it covers.
527    pub view_insets: Insets,
528    /// The intrusions **ignoring** anything transient: what the notch and the bars take
529    /// whether or not the keyboard is over them.
530    ///
531    /// The one that does not move when the keyboard opens, which is why a layout that
532    /// must not shift reads this rather than [`padding`](Self::padding).
533    pub view_padding: Insets,
534}
535
536impl WindowInsets {
537    /// No insets at all.
538    pub const ZERO: Self = Self {
539        padding: Insets::ZERO,
540        view_insets: Insets::ZERO,
541        view_padding: Insets::ZERO,
542    };
543
544    /// The intrusions of a surface with nothing transient over them: the bars and the
545    /// notch, and no keyboard.
546    ///
547    /// Here so that nobody has to assemble the three by hand and get them disagreeing.
548    /// `padding` and `view_padding` are the same thing while there is no keyboard, and
549    /// a hand-written literal that says otherwise describes a surface no platform can
550    /// report.
551    pub const fn bars(intrusions: Insets) -> Self {
552        Self {
553            padding: intrusions,
554            view_insets: Insets::ZERO,
555            view_padding: intrusions,
556        }
557    }
558
559    /// The total area to avoid: the per-side **maximum** of the two kinds. The
560    /// keyboard covers the navigation bar, so they are not added together.
561    pub fn safe(&self) -> Insets {
562        Insets::new(
563            self.padding.top.max(self.view_insets.top),
564            self.padding.right.max(self.view_insets.right),
565            self.padding.bottom.max(self.view_insets.bottom),
566            self.padding.left.max(self.view_insets.left),
567        )
568    }
569
570    /// Splits raw insets into `(padding, view_insets)` given a **keyboard-free**
571    /// reference. A **bottom** excess beyond that reference signals the keyboard;
572    /// `view_insets.bottom` then measures the **total** occlusion **from the window
573    /// edge**, bar included. That is what makes combining the two with `max`
574    /// correct.
575    pub fn from_baseline(baseline: Insets, current: Insets) -> WindowInsets {
576        let keyboard = (current.bottom - baseline.bottom).max(0.0);
577        // The intrusions with the keyboard taken back out: `current.bottom - keyboard`
578        // is the baseline's bottom by construction, and the other three sides do not
579        // move with a keyboard.
580        let view_padding = Insets::new(
581            current.top,
582            current.right,
583            current.bottom - keyboard,
584            current.left,
585        );
586        let view_insets = Insets::new(
587            0.0,
588            0.0,
589            if keyboard > 0.0 { current.bottom } else { 0.0 },
590            0.0,
591        );
592        WindowInsets {
593            padding: subtract(view_padding, view_insets),
594            view_insets,
595            view_padding,
596        }
597    }
598}
599
600/// `a` less `b`, side by side, never below zero.
601///
602/// What makes the padding the *remaining* intrusion rather than the whole one: a
603/// navigation bar under an open keyboard is covered, so nothing has to avoid it, and
604/// counting it twice would leave a strip of nothing above the keys.
605fn subtract(a: Insets, b: Insets) -> Insets {
606    Insets::new(
607        (a.top - b.top).max(0.0),
608        (a.right - b.right).max(0.0),
609        (a.bottom - b.bottom).max(0.0),
610        (a.left - b.left).max(0.0),
611    )
612}
613
614impl Default for WindowInsets {
615    fn default() -> Self {
616        Self::ZERO
617    }
618}
619
620/// An axis-aligned rectangle, in logical pixels.
621#[derive(Clone, Copy, Debug, PartialEq)]
622pub struct Rect {
623    /// Left edge.
624    pub x: f32,
625    /// Top edge.
626    pub y: f32,
627    /// Width, extending to the right.
628    pub width: f32,
629    /// Height, extending downwards.
630    pub height: f32,
631}
632
633impl Rect {
634    /// An "infinite" rectangle, used as a neutral clip that clips nothing.
635    pub const UNBOUNDED: Rect = Rect::new(-1.0e7, -1.0e7, 2.0e7, 2.0e7);
636
637    /// Builds a rectangle from its position (top-left corner) and its size.
638    pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
639        Self {
640            x,
641            y,
642            width,
643            height,
644        }
645    }
646
647    /// Offsets the rectangle by `(dx, dy)`.
648    pub fn translate(self, dx: f32, dy: f32) -> Self {
649        Self::new(self.x + dx, self.y + dy, self.width, self.height)
650    }
651
652    /// Multiplies position and size by `factor` (logical → physical conversion).
653    pub fn scale(self, factor: f32) -> Self {
654        Self::new(
655            self.x * factor,
656            self.y * factor,
657            self.width * factor,
658            self.height * factor,
659        )
660    }
661
662    /// Scales the rectangle by `factor` **about `pivot`**, which stays fixed:
663    /// `pos' = pivot + (pos - pivot) * factor`, with size × `factor`.
664    pub fn scale_about(self, pivot: Point, factor: f32) -> Self {
665        self.scale_about_xy(pivot, factor, factor)
666    }
667
668    /// Scales the rectangle **per axis** about `pivot`.
669    pub fn scale_about_xy(self, pivot: Point, sx: f32, sy: f32) -> Self {
670        Self::new(
671            pivot.x + (self.x - pivot.x) * sx,
672            pivot.y + (self.y - pivot.y) * sy,
673            self.width * sx,
674            self.height * sy,
675        )
676    }
677
678    /// Multiplies position and size **per axis** (`sx`, `sy`).
679    pub fn scale_xy(self, sx: f32, sy: f32) -> Self {
680        Self::new(self.x * sx, self.y * sy, self.width * sx, self.height * sy)
681    }
682
683    /// The intersection of two rectangles; zero-sized when they are disjoint.
684    pub fn intersect(self, other: Rect) -> Self {
685        let x0 = self.x.max(other.x);
686        let y0 = self.y.max(other.y);
687        let x1 = (self.x + self.width).min(other.x + other.width);
688        let y1 = (self.y + self.height).min(other.y + other.height);
689        Self::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
690    }
691
692    /// The smallest rectangle enclosing both `self` **and** `other`.
693    pub fn union(self, other: Rect) -> Self {
694        let x0 = self.x.min(other.x);
695        let y0 = self.y.min(other.y);
696        let x1 = (self.x + self.width).max(other.x + other.width);
697        let y1 = (self.y + self.height).max(other.y + other.height);
698        Self::new(x0, y0, x1 - x0, y1 - y0)
699    }
700
701    /// `true` when `point` lies inside; the left and top edges count as inside.
702    pub fn contains(self, point: Point) -> bool {
703        point.x >= self.x
704            && point.x < self.x + self.width
705            && point.y >= self.y
706            && point.y < self.y + self.height
707    }
708
709    /// Builds a rectangle from an origin point and a size.
710    pub const fn from_point_size(origin: Point, size: Size) -> Self {
711        Self::new(origin.x, origin.y, size.width, size.height)
712    }
713
714    /// The top-left corner.
715    pub const fn origin(self) -> Point {
716        Point::new(self.x, self.y)
717    }
718
719    /// The rectangle's size.
720    pub const fn size(self) -> Size {
721        Size::new(self.width, self.height)
722    }
723
724    /// The `[x, y, width, height]` form, ready for the GPU.
725    pub const fn to_array(self) -> [f32; 4] {
726        [self.x, self.y, self.width, self.height]
727    }
728}
729
730/// **The rule for anything that measures itself**: report a *whole* number of pixels,
731/// rounded **up**.
732///
733/// The layout engine rounds every box to whole pixels, and it is right to: two boxes that
734/// abut on a fractional edge each anti-alias into the pixel they share, and what shows
735/// through between them is the background — a bright hairline seam, not a soft join.
736/// Milestone 496's measurement of it is `(136, 187, 136)` where red met blue over green.
737///
738/// The price of that rounding is this rule. A widget that measures itself at `146.4` and
739/// says so is given a box of `146`, and `146 < 146.4` — so at paint time, where it is
740/// measured again, it no longer fits and wraps, clips or overflows. That is milestone
741/// 289, and it presented as a mysteriously missing line of text rather than as
742/// arithmetic.
743///
744/// So a measurement goes through here, and the box it is rounded to is one it still fits
745/// in. Where the measurement is then clamped to a constraint, clamp **after**: a box a
746/// fraction wider than it was allowed is a different bug.
747///
748/// ```
749/// use frus_core::fits;
750/// assert_eq!(fits(146.4), 147.0);
751/// assert_eq!(fits(146.0), 146.0);
752/// ```
753pub fn fits(extent: f32) -> f32 {
754    // `ceil` and not `round`: rounding to nearest is the bug this exists to prevent, and
755    // it is the one a new measurer will write by reflex.
756    extent.ceil()
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    #[test]
764    fn affine_composes_scale_then_rotate_about_a_pivot() {
765        use std::f32::consts::FRAC_PI_2;
766        let pivot = Point::new(10.0, 10.0);
767        // Scale ×2 then rotate +90°, both about the pivot.
768        let m = Affine::scale(2.0, 2.0)
769            .about(pivot)
770            .then(Affine::rotation(FRAC_PI_2).about(pivot));
771        // The pivot stays put.
772        let c = m.apply(pivot);
773        assert!(
774            (c.x - 10.0).abs() < 1e-3 && (c.y - 10.0).abs() < 1e-3,
775            "pivot fixe : {c:?}"
776        );
777        // Linear part = rotation(90°) ∘ scale(2) = [0, 2, -2, 0].
778        assert!(m.m[0].abs() < 1e-3 && (m.m[1] - 2.0).abs() < 1e-3);
779        assert!((m.m[2] + 2.0).abs() < 1e-3 && m.m[3].abs() < 1e-3);
780    }
781
782    #[test]
783    fn affine_inverse_round_trips() {
784        use std::f32::consts::FRAC_PI_3;
785        let m = Affine::scale(1.5, 0.5)
786            .about(Point::new(4.0, 7.0))
787            .then(Affine::rotation(FRAC_PI_3).about(Point::new(2.0, 3.0)))
788            .then(Affine::translation(5.0, -2.0));
789        let inv = m.inverse();
790        for p in [
791            Point::new(0.0, 0.0),
792            Point::new(12.0, -3.0),
793            Point::new(-5.0, 8.0),
794        ] {
795            let back = inv.apply(m.apply(p));
796            assert!(
797                (back.x - p.x).abs() < 1e-2 && (back.y - p.y).abs() < 1e-2,
798                "{p:?} -> {back:?}"
799            );
800        }
801    }
802
803    #[test]
804    fn directional_alignment_resolves_by_direction() {
805        // start = the reading edge: left in LTR, right in RTL.
806        let start = AlignmentDirectional::CENTER_START;
807        assert_eq!(start.resolve(TextDirection::Ltr), Alignment::CENTER_LEFT);
808        assert_eq!(start.resolve(TextDirection::Rtl), Alignment::CENTER_RIGHT);
809        // The centre and the vertical axis do not depend on direction.
810        let top = AlignmentDirectional::TOP_CENTER;
811        assert_eq!(top.resolve(TextDirection::Rtl), Alignment::TOP_CENTER);
812    }
813
814    #[test]
815    fn alignment_geometry_unifies_physical_and_directional() {
816        // Physical: resolved as-is, whatever the direction.
817        let phys: AlignmentGeometry = Alignment::CENTER_LEFT.into();
818        assert_eq!(phys.resolve(TextDirection::Rtl), Alignment::CENTER_LEFT);
819        // Directional: follows the reading direction.
820        let dir: AlignmentGeometry = AlignmentDirectional::CENTER_START.into();
821        assert_eq!(dir.resolve(TextDirection::Ltr), Alignment::CENTER_LEFT);
822        assert_eq!(dir.resolve(TextDirection::Rtl), Alignment::CENTER_RIGHT);
823    }
824
825    #[test]
826    fn directional_insets_flip_start_end() {
827        // start=10, end=4: in LTR start is left; in RTL start is right.
828        let d = InsetsDirectional::new(1.0, 4.0, 2.0, 10.0);
829        let ltr = d.resolve(TextDirection::Ltr);
830        assert_eq!(
831            (ltr.left, ltr.right, ltr.top, ltr.bottom),
832            (10.0, 4.0, 1.0, 2.0)
833        );
834        let rtl = d.resolve(TextDirection::Rtl);
835        assert_eq!(
836            (rtl.left, rtl.right),
837            (4.0, 10.0),
838            "start moves to the right under RTL"
839        );
840        // The vertical axis never moves.
841        assert_eq!((rtl.top, rtl.bottom), (1.0, 2.0));
842    }
843
844    #[test]
845    fn window_insets_split_and_safe_area() {
846        // Keyboard-free reference: the top and bottom system bars.
847        let baseline = Insets::new(84.0, 0.0, 45.0, 0.0);
848
849        // Keyboard closed: everything is static padding, nothing transient, and the
850        // intrusion that does not move is the same as the one that does.
851        let closed = WindowInsets::from_baseline(baseline, baseline);
852        assert_eq!(closed.padding, baseline);
853        assert_eq!(closed.view_padding, baseline);
854        assert_eq!(closed.view_insets, Insets::ZERO);
855        assert_eq!(closed.safe(), baseline);
856
857        // Keyboard open (bottom excess of 300): `view_insets.bottom` measures the
858        // total occlusion from the edge (345, bar included).
859        let open = WindowInsets::from_baseline(baseline, Insets::new(84.0, 0.0, 345.0, 0.0));
860        assert_eq!(open.view_insets, Insets::new(0.0, 0.0, 345.0, 0.0));
861        // The bar has not moved, so `view_padding` still reports it — while `padding`
862        // reports what is **left** to avoid, which at the bottom is nothing at all. The
863        // keyboard is over the bar; padding by both would leave a strip of nothing
864        // between the content and the keys.
865        assert_eq!(open.view_padding, baseline);
866        assert_eq!(open.padding, Insets::new(84.0, 0.0, 0.0, 0.0));
867        // The safe area is the per-side max — the keyboard covers the bar.
868        assert_eq!(open.safe(), Insets::new(84.0, 0.0, 345.0, 0.0));
869
870        // Current bottom BELOW the reference (bars hidden): no negative keyboard.
871        let hidden = WindowInsets::from_baseline(baseline, Insets::new(84.0, 0.0, 10.0, 0.0));
872        assert_eq!(hidden.view_insets, Insets::ZERO);
873        assert_eq!(hidden.padding.bottom, 10.0);
874        assert_eq!(hidden.view_padding.bottom, 10.0);
875    }
876
877    #[test]
878    fn rect_from_point_size_roundtrips() {
879        let r = Rect::from_point_size(Point::new(3.0, 4.0), Size::new(10.0, 20.0));
880        assert_eq!(r, Rect::new(3.0, 4.0, 10.0, 20.0));
881        assert_eq!(r.origin(), Point::new(3.0, 4.0));
882        assert_eq!(r.size(), Size::new(10.0, 20.0));
883        assert_eq!(r.to_array(), [3.0, 4.0, 10.0, 20.0]);
884    }
885}