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}