Skip to main content

day_geometry/
lib.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! day-geometry: plain `Copy` value types shared by layout, canvas, and the toolkit spec.
5//! Everything is in points (density-independent); backends convert to device pixels (§7.9).
6
7#[derive(Clone, Copy, Debug, Default, PartialEq)]
8pub struct Point {
9    pub x: f64,
10    pub y: f64,
11}
12
13impl Point {
14    pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
15    #[inline]
16    pub const fn new(x: f64, y: f64) -> Self {
17        Point { x, y }
18    }
19    #[inline]
20    pub fn offset(self, dx: f64, dy: f64) -> Self {
21        Point::new(self.x + dx, self.y + dy)
22    }
23}
24
25#[derive(Clone, Copy, Debug, Default, PartialEq)]
26pub struct Size {
27    pub width: f64,
28    pub height: f64,
29}
30
31impl Size {
32    pub const ZERO: Size = Size {
33        width: 0.0,
34        height: 0.0,
35    };
36    #[inline]
37    pub const fn new(width: f64, height: f64) -> Self {
38        Size { width, height }
39    }
40    #[inline]
41    pub fn max(self, other: Size) -> Size {
42        Size::new(self.width.max(other.width), self.height.max(other.height))
43    }
44    /// Approximate equality on the half-pixel epsilon used by frame diffing (§7.9).
45    #[inline]
46    pub fn approx_eq(self, other: Size, eps: f64) -> bool {
47        (self.width - other.width).abs() <= eps && (self.height - other.height).abs() <= eps
48    }
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq)]
52pub struct Rect {
53    pub origin: Point,
54    pub size: Size,
55}
56
57impl Rect {
58    pub const ZERO: Rect = Rect {
59        origin: Point::ZERO,
60        size: Size::ZERO,
61    };
62    #[inline]
63    pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
64        Rect {
65            origin: Point::new(x, y),
66            size: Size::new(width, height),
67        }
68    }
69    #[inline]
70    pub fn from_size(size: Size) -> Self {
71        Rect {
72            origin: Point::ZERO,
73            size,
74        }
75    }
76    #[inline]
77    pub fn min_x(&self) -> f64 {
78        self.origin.x
79    }
80    #[inline]
81    pub fn min_y(&self) -> f64 {
82        self.origin.y
83    }
84    #[inline]
85    pub fn max_x(&self) -> f64 {
86        self.origin.x + self.size.width
87    }
88    #[inline]
89    pub fn max_y(&self) -> f64 {
90        self.origin.y + self.size.height
91    }
92    #[inline]
93    pub fn center(&self) -> Point {
94        Point::new(
95            self.origin.x + self.size.width / 2.0,
96            self.origin.y + self.size.height / 2.0,
97        )
98    }
99    #[inline]
100    pub fn inset(&self, d: f64) -> Rect {
101        self.inset_by(Insets::all(d))
102    }
103    pub fn inset_by(&self, i: Insets) -> Rect {
104        Rect::new(
105            self.origin.x + i.leading,
106            self.origin.y + i.top,
107            (self.size.width - i.leading - i.trailing).max(0.0),
108            (self.size.height - i.top - i.bottom).max(0.0),
109        )
110    }
111    pub fn intersects(&self, other: &Rect) -> bool {
112        self.min_x() < other.max_x()
113            && other.min_x() < self.max_x()
114            && self.min_y() < other.max_y()
115            && other.min_y() < self.max_y()
116    }
117    /// Approximate equality on the half-pixel epsilon used by frame diffing (§7.9).
118    pub fn approx_eq(&self, other: &Rect, eps: f64) -> bool {
119        (self.origin.x - other.origin.x).abs() <= eps
120            && (self.origin.y - other.origin.y).abs() <= eps
121            && self.size.approx_eq(other.size, eps)
122    }
123}
124
125/// A 2-D affine transform (CoreGraphics row-vector convention): a point `p` maps to
126/// `(a·p.x + c·p.y + tx, b·p.x + d·p.y + ty)`. Used by canvas transform ops for shape
127/// rotate/scale/offset; every native 2-D context concatenates it onto its CTM identically.
128#[derive(Clone, Copy, Debug, PartialEq)]
129pub struct Affine {
130    pub a: f64,
131    pub b: f64,
132    pub c: f64,
133    pub d: f64,
134    pub tx: f64,
135    pub ty: f64,
136}
137
138impl Affine {
139    pub const IDENTITY: Affine = Affine {
140        a: 1.0,
141        b: 0.0,
142        c: 0.0,
143        d: 1.0,
144        tx: 0.0,
145        ty: 0.0,
146    };
147
148    #[inline]
149    pub const fn translate(x: f64, y: f64) -> Affine {
150        Affine {
151            a: 1.0,
152            b: 0.0,
153            c: 0.0,
154            d: 1.0,
155            tx: x,
156            ty: y,
157        }
158    }
159    #[inline]
160    pub const fn scale(sx: f64, sy: f64) -> Affine {
161        Affine {
162            a: sx,
163            b: 0.0,
164            c: 0.0,
165            d: sy,
166            tx: 0.0,
167            ty: 0.0,
168        }
169    }
170    /// Rotation by `radians` (counter-clockwise in a y-up space; clockwise on y-down screens).
171    #[inline]
172    pub fn rotate(radians: f64) -> Affine {
173        let (s, cos) = radians.sin_cos();
174        Affine {
175            a: cos,
176            b: s,
177            c: -s,
178            d: cos,
179            tx: 0.0,
180            ty: 0.0,
181        }
182    }
183    /// `self` applied first, then `other` (row-vector product `self · other`).
184    #[inline]
185    pub fn then(self, o: Affine) -> Affine {
186        Affine {
187            a: self.a * o.a + self.b * o.c,
188            b: self.a * o.b + self.b * o.d,
189            c: self.c * o.a + self.d * o.c,
190            d: self.c * o.b + self.d * o.d,
191            tx: self.tx * o.a + self.ty * o.c + o.tx,
192            ty: self.tx * o.b + self.ty * o.d + o.ty,
193        }
194    }
195    #[inline]
196    pub fn apply(&self, p: Point) -> Point {
197        Point::new(
198            self.a * p.x + self.c * p.y + self.tx,
199            self.b * p.x + self.d * p.y + self.ty,
200        )
201    }
202    /// Map a point back through the inverse (for hit-testing a transformed shape). None if singular.
203    pub fn invert_apply(&self, p: Point) -> Option<Point> {
204        let det = self.a * self.d - self.b * self.c;
205        if det.abs() < 1e-12 {
206            return None;
207        }
208        let inv = 1.0 / det;
209        let x = p.x - self.tx;
210        let y = p.y - self.ty;
211        Some(Point::new(
212            (x * self.d - y * self.c) * inv,
213            (y * self.a - x * self.b) * inv,
214        ))
215    }
216    #[inline]
217    pub fn is_identity(&self) -> bool {
218        *self == Affine::IDENTITY
219    }
220    #[inline]
221    pub fn as_array(&self) -> [f64; 6] {
222        [self.a, self.b, self.c, self.d, self.tx, self.ty]
223    }
224    #[inline]
225    pub fn from_array(m: [f64; 6]) -> Affine {
226        Affine {
227            a: m[0],
228            b: m[1],
229            c: m[2],
230            d: m[3],
231            tx: m[4],
232            ty: m[5],
233        }
234    }
235}
236
237/// Logical insets: `leading`/`trailing` resolve against the layout direction at place time (§7.8).
238#[derive(Clone, Copy, Debug, Default, PartialEq)]
239pub struct Insets {
240    pub top: f64,
241    pub leading: f64,
242    pub bottom: f64,
243    pub trailing: f64,
244}
245
246impl Insets {
247    pub const ZERO: Insets = Insets {
248        top: 0.0,
249        leading: 0.0,
250        bottom: 0.0,
251        trailing: 0.0,
252    };
253    /// Four explicit edges, in the order they are named.
254    #[inline]
255    pub const fn new(top: f64, leading: f64, bottom: f64, trailing: f64) -> Self {
256        Insets {
257            top,
258            leading,
259            bottom,
260            trailing,
261        }
262    }
263    #[inline]
264    pub const fn all(d: f64) -> Self {
265        Insets {
266            top: d,
267            leading: d,
268            bottom: d,
269            trailing: d,
270        }
271    }
272    #[inline]
273    pub const fn symmetric(horizontal: f64, vertical: f64) -> Self {
274        Insets {
275            top: vertical,
276            leading: horizontal,
277            bottom: vertical,
278            trailing: horizontal,
279        }
280    }
281    /// `d` on the leading and trailing edges, nothing above or below.
282    #[inline]
283    pub const fn horizontal(d: f64) -> Self {
284        Insets::symmetric(d, 0.0)
285    }
286    /// `d` above and below, nothing on the sides.
287    #[inline]
288    pub const fn vertical(d: f64) -> Self {
289        Insets::symmetric(0.0, d)
290    }
291    /// `d` above, nothing elsewhere.
292    #[inline]
293    pub const fn top(d: f64) -> Self {
294        Insets {
295            top: d,
296            ..Insets::ZERO
297        }
298    }
299    /// `d` below, nothing elsewhere.
300    #[inline]
301    pub const fn bottom(d: f64) -> Self {
302        Insets {
303            bottom: d,
304            ..Insets::ZERO
305        }
306    }
307    /// `d` on the leading edge, nothing elsewhere.
308    #[inline]
309    pub const fn leading(d: f64) -> Self {
310        Insets {
311            leading: d,
312            ..Insets::ZERO
313        }
314    }
315    /// `d` on the trailing edge, nothing elsewhere.
316    #[inline]
317    pub const fn trailing(d: f64) -> Self {
318        Insets {
319            trailing: d,
320            ..Insets::ZERO
321        }
322    }
323    /// The width the insets take: leading plus trailing.
324    #[inline]
325    pub fn horizontal_total(&self) -> f64 {
326        self.leading + self.trailing
327    }
328    /// The height the insets take: top plus bottom.
329    #[inline]
330    pub fn vertical_total(&self) -> f64 {
331        self.top + self.bottom
332    }
333}
334
335/// sRGB color, 0.0–1.0 components. Semantic theme tokens (§6.3) resolve to these in the backend.
336#[derive(Clone, Copy, Debug, PartialEq)]
337pub struct Color {
338    pub r: f64,
339    pub g: f64,
340    pub b: f64,
341    pub a: f64,
342}
343
344impl Color {
345    pub const fn rgba(r: f64, g: f64, b: f64, a: f64) -> Self {
346        Color { r, g, b, a }
347    }
348    pub const fn rgb(r: f64, g: f64, b: f64) -> Self {
349        Color::rgba(r, g, b, 1.0)
350    }
351    pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0);
352    pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0);
353    pub const CLEAR: Color = Color::rgba(0.0, 0.0, 0.0, 0.0);
354    /// 0xRRGGBB
355    pub const fn hex(v: u32) -> Self {
356        Color::rgb(
357            ((v >> 16) & 0xff) as f64 / 255.0,
358            ((v >> 8) & 0xff) as f64 / 255.0,
359            (v & 0xff) as f64 / 255.0,
360        )
361    }
362
363    /// From HSL: `h` in degrees (wraps mod 360), `s`/`l` in `0.0..=1.0`. `Color` is the one color
364    /// type every parameter accepts, so this makes HSL usable everywhere a color is.
365    pub fn hsl(h: f64, s: f64, l: f64) -> Self {
366        Color::hsla(h, s, l, 1.0)
367    }
368    pub fn hsla(h: f64, s: f64, l: f64, a: f64) -> Self {
369        let (s, l) = (s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
370        if s == 0.0 {
371            return Color::rgba(l, l, l, a);
372        }
373        let hk = h.rem_euclid(360.0) / 360.0;
374        let q = if l < 0.5 {
375            l * (1.0 + s)
376        } else {
377            l + s - l * s
378        };
379        let p = 2.0 * l - q;
380        Color::rgba(
381            hue2rgb(p, q, hk + 1.0 / 3.0),
382            hue2rgb(p, q, hk),
383            hue2rgb(p, q, hk - 1.0 / 3.0),
384            a,
385        )
386    }
387
388    /// From HSV/HSB: `h` degrees (wraps), `s`/`v` in `0.0..=1.0`.
389    pub fn hsv(h: f64, s: f64, v: f64) -> Self {
390        Color::hsva(h, s, v, 1.0)
391    }
392    pub fn hsva(h: f64, s: f64, v: f64, a: f64) -> Self {
393        let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
394        let hh = h.rem_euclid(360.0) / 60.0;
395        let c = v * s;
396        let x = c * (1.0 - (hh % 2.0 - 1.0).abs());
397        let m = v - c;
398        let (r, g, b) = match hh as i32 {
399            0 => (c, x, 0.0),
400            1 => (x, c, 0.0),
401            2 => (0.0, c, x),
402            3 => (0.0, x, c),
403            4 => (x, 0.0, c),
404            _ => (c, 0.0, x),
405        };
406        Color::rgba(r + m, g + m, b + m, a)
407    }
408
409    /// Decompose to `(hue°, saturation, lightness)` (HSL). Hue is `0.0` for grays.
410    pub fn to_hsl(&self) -> (f64, f64, f64) {
411        let (r, g, b) = (self.r, self.g, self.b);
412        let max = r.max(g).max(b);
413        let min = r.min(g).min(b);
414        let l = (max + min) / 2.0;
415        let d = max - min;
416        if d.abs() < 1e-9 {
417            return (0.0, 0.0, l);
418        }
419        let s = d / (1.0 - (2.0 * l - 1.0).abs());
420        let h = if max == r {
421            60.0 * (((g - b) / d).rem_euclid(6.0))
422        } else if max == g {
423            60.0 * ((b - r) / d + 2.0)
424        } else {
425            60.0 * ((r - g) / d + 4.0)
426        };
427        (h.rem_euclid(360.0), s.clamp(0.0, 1.0), l)
428    }
429
430    /// Decompose to `(hue°, saturation, value)` (HSV/HSB), the model every native color picker's
431    /// spectrum tab is built on, so this is what a renderer seeds its sliders from. Hue is `0.0`
432    /// for grays. Inverse of [`Color::hsv`].
433    pub fn to_hsv(&self) -> (f64, f64, f64) {
434        let (r, g, b) = (self.r, self.g, self.b);
435        let max = r.max(g).max(b);
436        let min = r.min(g).min(b);
437        let d = max - min;
438        if d.abs() < 1e-9 {
439            return (0.0, 0.0, max);
440        }
441        let h = if max == r {
442            60.0 * (((g - b) / d).rem_euclid(6.0))
443        } else if max == g {
444            60.0 * ((b - r) / d + 2.0)
445        } else {
446            60.0 * ((r - g) / d + 4.0)
447        };
448        (h.rem_euclid(360.0), d / max, max)
449    }
450
451    /// The same color at a different opacity: what a picker's alpha slider produces, and what
452    /// tinting a surface down to a wash needs (`palette.with_alpha(0.14)`).
453    pub fn with_alpha(self, a: f64) -> Color {
454        Color { a, ..self }
455    }
456
457    /// `#rrggbb`, or `#rrggbbaa` when the color is not fully opaque. The 8-bit form every
458    /// platform's own color field speaks, and what [`Color::parse`] round-trips.
459    pub fn to_hex_string(&self) -> String {
460        let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
461        if self.a >= 1.0 {
462            format!("#{:02x}{:02x}{:02x}", q(self.r), q(self.g), q(self.b))
463        } else {
464            format!(
465                "#{:02x}{:02x}{:02x}{:02x}",
466                q(self.r),
467                q(self.g),
468                q(self.b),
469                q(self.a)
470            )
471        }
472    }
473
474    /// Parse a color from either interchange form:
475    ///
476    /// - **hex**: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` (the leading `#` optional). What a
477    ///   person types, and what an 8-bit platform field (`<input type="color">`, `Windows.UI.Color`,
478    ///   `android.graphics.Color`) hands back.
479    /// - **components**: 3 or 4 space-separated floats in `0.0..=1.0`, `"r g b"` / `"r g b a"`.
480    ///   The lossless form, for the toolkits whose picker really is float-precision (`NSColor`,
481    ///   `GdkRGBA`, `QColor::getRgbF`).
482    ///
483    /// `None` on anything else. See [docs/color.md](https://daybrite.dev/docs/reference/color) for
484    /// why the currency stays sRGB for now and what a wider one would carry.
485    pub fn parse(s: &str) -> Option<Color> {
486        let s = s.trim();
487        let hex = s.strip_prefix('#').unwrap_or(s);
488        if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
489            let nib = |i: usize| u8::from_str_radix(&hex[i..i + 1], 16).ok().map(f64::from);
490            let byte = |i: usize| {
491                u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
492                    .ok()
493                    .map(f64::from)
494            };
495            return match hex.len() {
496                3 | 4 => Some(Color::rgba(
497                    nib(0)? / 15.0,
498                    nib(1)? / 15.0,
499                    nib(2)? / 15.0,
500                    if hex.len() == 4 { nib(3)? / 15.0 } else { 1.0 },
501                )),
502                6 | 8 => Some(Color::rgba(
503                    byte(0)? / 255.0,
504                    byte(1)? / 255.0,
505                    byte(2)? / 255.0,
506                    if hex.len() == 8 {
507                        byte(3)? / 255.0
508                    } else {
509                        1.0
510                    },
511                )),
512                _ => None,
513            };
514        }
515        let mut it = s.split_whitespace();
516        let (r, g, b) = (
517            it.next()?.parse::<f64>().ok()?,
518            it.next()?.parse::<f64>().ok()?,
519            it.next()?.parse::<f64>().ok()?,
520        );
521        let a = match it.next() {
522            Some(a) => a.parse::<f64>().ok()?,
523            None => 1.0,
524        };
525        if it.next().is_some() {
526            return None;
527        }
528        Some(Color::rgba(
529            r.clamp(0.0, 1.0),
530            g.clamp(0.0, 1.0),
531            b.clamp(0.0, 1.0),
532            a.clamp(0.0, 1.0),
533        ))
534    }
535
536    /// Interpolate toward `to` in HSL space, taking the shortest hue arc (`t` in `0.0..=1.0`). A
537    /// hue-space blend (red→green sweeps through yellow) rather than the muddy RGB straight line,
538    /// used by the canvas / self-driven animation path (native widget color animation interpolates
539    /// in the toolkit's own space).
540    pub fn lerp_hsl(self, to: Color, t: f64) -> Color {
541        let (h0, s0, l0) = self.to_hsl();
542        let (h1, s1, l1) = to.to_hsl();
543        let mut dh = (h1 - h0).rem_euclid(360.0);
544        if dh > 180.0 {
545            dh -= 360.0;
546        }
547        let lerp = |a: f64, b: f64| a + (b - a) * t;
548        Color::hsla(h0 + dh * t, lerp(s0, s1), lerp(l0, l1), lerp(self.a, to.a))
549    }
550}
551
552/// The lossless interchange form: four space-separated components, exactly what
553/// [`Color::parse`] reads back. This is what crosses a JNI / C-ABI / JS boundary when a native
554/// color picker reports a pick, so the float precision `NSColor` and `GdkRGBA` really carry is
555/// not rounded to 8 bits on the way. For the form a person reads (and types into a dayscript
556/// step) use [`Color::to_hex_string`].
557impl std::fmt::Display for Color {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        write!(f, "{} {} {} {}", self.r, self.g, self.b, self.a)
560    }
561}
562
563/// HSL hue channel → RGB component (helper for [`Color::hsla`]).
564fn hue2rgb(p: f64, q: f64, t: f64) -> f64 {
565    let t = t.rem_euclid(1.0);
566    if t < 1.0 / 6.0 {
567        p + (q - p) * 6.0 * t
568    } else if t < 1.0 / 2.0 {
569        q
570    } else if t < 2.0 / 3.0 {
571        p + (q - p) * (2.0 / 3.0 - t) * 6.0
572    } else {
573        p
574    }
575}
576
577/// The layout proposal: `None` = unconstrained on that axis (§7.2).
578#[derive(Clone, Copy, Debug, Default, PartialEq)]
579pub struct Proposal {
580    pub width: Option<f64>,
581    pub height: Option<f64>,
582}
583
584impl Proposal {
585    pub const UNCONSTRAINED: Proposal = Proposal {
586        width: None,
587        height: None,
588    };
589    #[inline]
590    pub const fn new(width: Option<f64>, height: Option<f64>) -> Self {
591        Proposal { width, height }
592    }
593    #[inline]
594    pub const fn exact(size: Size) -> Self {
595        Proposal {
596            width: Some(size.width),
597            height: Some(size.height),
598        }
599    }
600    /// Quantized key for the measurement cache (§7.4): tenth-of-a-point buckets.
601    pub fn cache_key(&self) -> (u64, u64) {
602        #[inline]
603        fn q(v: Option<f64>) -> u64 {
604            match v {
605                None => u64::MAX,
606                Some(f) => (f * 10.0).round().max(0.0) as u64,
607            }
608        }
609        (q(self.width), q(self.height))
610    }
611}
612
613#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
614pub enum LayoutDirection {
615    #[default]
616    Ltr,
617    Rtl,
618}
619
620/// A cheap per-node visual transform (§8.4 animation): translation, uniform/non-uniform scale, and
621/// rotation about a unit anchor (`0.0..1.0` within the node's bounds; default center). Distinct
622/// from the layout frame: animating a `Transform` never triggers relayout, so it is the vehicle
623/// for movement/scaling animation. Each backend composes it onto the node's laid-out frame via its
624/// native transform channel (CALayer/GskTransform/RenderTransform/NODE_TRANSFORM/…).
625#[derive(Clone, Copy, Debug, PartialEq)]
626pub struct Transform {
627    pub tx: f64,
628    pub ty: f64,
629    pub sx: f64,
630    pub sy: f64,
631    pub rotate_deg: f64,
632    /// Anchor for scale/rotation as a unit fraction of the node's bounds (`0.5,0.5` = center).
633    pub anchor_x: f64,
634    pub anchor_y: f64,
635}
636
637impl Default for Transform {
638    fn default() -> Self {
639        Transform::IDENTITY
640    }
641}
642
643impl Transform {
644    pub const IDENTITY: Transform = Transform {
645        tx: 0.0,
646        ty: 0.0,
647        sx: 1.0,
648        sy: 1.0,
649        rotate_deg: 0.0,
650        anchor_x: 0.5,
651        anchor_y: 0.5,
652    };
653
654    #[inline]
655    pub const fn translate(tx: f64, ty: f64) -> Transform {
656        Transform {
657            tx,
658            ty,
659            ..Transform::IDENTITY
660        }
661    }
662    #[inline]
663    pub const fn scale(sx: f64, sy: f64) -> Transform {
664        Transform {
665            sx,
666            sy,
667            ..Transform::IDENTITY
668        }
669    }
670    #[inline]
671    pub const fn rotate(deg: f64) -> Transform {
672        Transform {
673            rotate_deg: deg,
674            ..Transform::IDENTITY
675        }
676    }
677    /// Whether this transform has no visual effect; backends skip applying it.
678    #[inline]
679    pub fn is_identity(&self) -> bool {
680        *self == Transform::IDENTITY
681    }
682}
683
684/// Linear interpolation of animatable values (`t` in `0.0..1.0`). This drives the **canvas /
685/// self-driven** animation path (docs/shapes.md §5) and Qt's sampled spring; native-widget
686/// animation does not use it; the toolkit interpolates on its own compositor.
687pub trait Animatable: Copy {
688    fn lerp(self, to: Self, t: f64) -> Self;
689}
690
691#[inline]
692fn flerp(a: f64, b: f64, t: f64) -> f64 {
693    a + (b - a) * t
694}
695
696impl Animatable for f64 {
697    #[inline]
698    fn lerp(self, to: f64, t: f64) -> f64 {
699        flerp(self, to, t)
700    }
701}
702impl Animatable for Point {
703    #[inline]
704    fn lerp(self, to: Point, t: f64) -> Point {
705        Point::new(flerp(self.x, to.x, t), flerp(self.y, to.y, t))
706    }
707}
708impl Animatable for Size {
709    #[inline]
710    fn lerp(self, to: Size, t: f64) -> Size {
711        Size::new(
712            flerp(self.width, to.width, t),
713            flerp(self.height, to.height, t),
714        )
715    }
716}
717impl Animatable for Rect {
718    #[inline]
719    fn lerp(self, to: Rect, t: f64) -> Rect {
720        Rect {
721            origin: self.origin.lerp(to.origin, t),
722            size: self.size.lerp(to.size, t),
723        }
724    }
725}
726impl Animatable for Color {
727    #[inline]
728    fn lerp(self, to: Color, t: f64) -> Color {
729        Color::rgba(
730            flerp(self.r, to.r, t),
731            flerp(self.g, to.g, t),
732            flerp(self.b, to.b, t),
733            flerp(self.a, to.a, t),
734        )
735    }
736}
737impl Animatable for Transform {
738    #[inline]
739    fn lerp(self, to: Transform, t: f64) -> Transform {
740        // Anchor snaps to the destination's (it's a coordinate frame, not a visual value).
741        Transform {
742            tx: flerp(self.tx, to.tx, t),
743            ty: flerp(self.ty, to.ty, t),
744            sx: flerp(self.sx, to.sx, t),
745            sy: flerp(self.sy, to.sy, t),
746            rotate_deg: flerp(self.rotate_deg, to.rotate_deg, t),
747            anchor_x: to.anchor_x,
748            anchor_y: to.anchor_y,
749        }
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn rect_insets() {
759        let r = Rect::new(10.0, 10.0, 100.0, 50.0).inset_by(Insets::symmetric(4.0, 2.0));
760        assert_eq!(r, Rect::new(14.0, 12.0, 92.0, 46.0));
761    }
762
763    #[test]
764    fn hsl_primaries_and_roundtrip() {
765        let approx = |a: Color, b: Color| {
766            (a.r - b.r).abs() < 1e-6 && (a.g - b.g).abs() < 1e-6 && (a.b - b.b).abs() < 1e-6
767        };
768        assert!(approx(Color::hsl(0.0, 1.0, 0.5), Color::rgb(1.0, 0.0, 0.0))); // red
769        assert!(approx(
770            Color::hsl(120.0, 1.0, 0.5),
771            Color::rgb(0.0, 1.0, 0.0)
772        )); // green
773        assert!(approx(
774            Color::hsl(240.0, 1.0, 0.5),
775            Color::rgb(0.0, 0.0, 1.0)
776        )); // blue
777        assert!(approx(Color::hsl(0.0, 0.0, 0.5), Color::rgb(0.5, 0.5, 0.5))); // gray
778        assert!(approx(Color::hsv(0.0, 1.0, 1.0), Color::rgb(1.0, 0.0, 0.0))); // hsv red
779        // Hue wraps, and to_hsl inverts hsl.
780        let (h, s, l) = Color::hsl(370.0, 0.6, 0.4).to_hsl();
781        assert!((h - 10.0).abs() < 1e-3 && (s - 0.6).abs() < 1e-3 && (l - 0.4).abs() < 1e-3);
782        // Shortest-arc hue lerp red→(hue 300, magenta) goes the short way (down through 330), not
783        // through green; midpoint hue ≈ 330.
784        let mid = Color::hsl(0.0, 1.0, 0.5).lerp_hsl(Color::hsl(300.0, 1.0, 0.5), 0.5);
785        assert!((mid.to_hsl().0 - 330.0).abs() < 1.0);
786    }
787
788    #[test]
789    fn hsv_roundtrip_and_gray_hue() {
790        let c = Color::rgb(0.2, 0.7, 0.45);
791        let (h, s, v) = c.to_hsv();
792        let back = Color::hsv(h, s, v);
793        assert!((back.r - c.r).abs() < 1e-6 && (back.g - c.g).abs() < 1e-6);
794        assert!((back.b - c.b).abs() < 1e-6);
795        // A gray has no hue to report; saturation 0 is what the sliders need to show.
796        assert_eq!(Color::rgb(0.4, 0.4, 0.4).to_hsv(), (0.0, 0.0, 0.4));
797    }
798
799    #[test]
800    fn color_hex_and_component_parsing() {
801        // Every hex width, with and without the `#`.
802        let coral = Color::hex(0xE86A3C);
803        for s in ["#e86a3c", "E86A3C", "#e86a3cff"] {
804            let p = Color::parse(s).unwrap();
805            assert!((p.r - coral.r).abs() < 1e-9, "{s}");
806            assert!(
807                (p.g - coral.g).abs() < 1e-9 && (p.b - coral.b).abs() < 1e-9,
808                "{s}"
809            );
810            assert_eq!(p.a, 1.0, "{s}");
811        }
812        assert_eq!(Color::parse("#f00").unwrap(), Color::rgb(1.0, 0.0, 0.0));
813        assert_eq!(
814            Color::parse("#0000").unwrap(),
815            Color::rgba(0.0, 0.0, 0.0, 0.0)
816        );
817        assert_eq!(Color::parse("#00000080").unwrap().a, 128.0 / 255.0);
818        // The lossless component form, what a native pick crosses a boundary as.
819        assert_eq!(Color::parse("1 0 0").unwrap(), Color::rgb(1.0, 0.0, 0.0));
820        let c = Color::rgba(0.937_254_901, 0.4, 0.298, 0.6);
821        assert_eq!(
822            Color::parse(&c.to_string()).unwrap(),
823            c,
824            "Display round-trips"
825        );
826        // 8-bit round trip through the human form.
827        assert_eq!(Color::parse(&coral.to_hex_string()).unwrap(), coral);
828        assert_eq!(
829            Color::rgba(0.0, 0.0, 0.0, 0.5).to_hex_string(),
830            "#00000080",
831            "alpha appears only when it is not opaque"
832        );
833        for bad in ["", "#12345", "not-a-color", "1 0", "1 0 0 0 0", "#gg0000"] {
834            assert!(Color::parse(bad).is_none(), "{bad:?} rejected");
835        }
836    }
837
838    #[test]
839    fn proposal_cache_key_quantizes() {
840        assert_eq!(
841            Proposal::new(Some(100.02), None).cache_key(),
842            Proposal::new(Some(100.04), None).cache_key()
843        );
844        assert_ne!(
845            Proposal::new(Some(100.0), None).cache_key(),
846            Proposal::UNCONSTRAINED.cache_key()
847        );
848    }
849}