Skip to main content

frust_theme/
shape.rs

1//! [`ShapeScale::neutral`]: the design-language-free 10 corner-radius tokens
2//! (post-Expressive scale shape).
3//!
4//! This crate constructs no other `ShapeScale` — a design system builds its
5//! own from its own plugin crate (`frust-material`'s `tokens` module carries
6//! the Material 3 corner-radius scale these numbers originate from, source
7//! cited there; `frust-cupertino`'s carries the iOS-idiom mapping). The
8//! values below are the M3 numbers reused verbatim: a corner-radius *scale*
9//! isn't a branded artifact the way a color palette is, so there's no
10//! design-language-specific value here to invent (see
11//! `crate::theme::Theme::neutral`'s module docs).
12//!
13//! Source: <https://m3.material.io/styles/shape/corner-radius-scale>
14//! (verified 2026-07-17; the pre-Expressive scale had 7 tokens, not
15//! 10). Radii are dp, treated 1:1 as logical px, matching
16//! this crate's other length fields.
17
18/// The 10 Material 3 corner-radius tokens, dp (logical px). `full` is
19/// represented as [`f64::INFINITY`] rather than a separate enum variant —
20/// resolve it against a concrete box size with [`ShapeScale::resolve`],
21/// which clamps an infinite radius to half the box's shorter side (a pill
22/// shape) and passes any finite radius through unchanged.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct ShapeScale {
25    pub none: f64,
26    pub extra_small: f64,
27    pub small: f64,
28    pub medium: f64,
29    pub large: f64,
30    pub large_increased: f64,
31    pub extra_large: f64,
32    pub extra_large_increased: f64,
33    pub extra_extra_large: f64,
34    /// Always [`f64::INFINITY`] — resolve with [`ShapeScale::resolve`].
35    pub full: f64,
36}
37
38impl ShapeScale {
39    /// The neutral, design-language-free shape scale — see the module docs
40    /// for why these are the M3 numbers reused verbatim.
41    pub const fn neutral() -> Self {
42        Self {
43            none: 0.0,
44            extra_small: 4.0,
45            small: 8.0,
46            medium: 12.0,
47            large: 16.0,
48            large_increased: 20.0,
49            extra_large: 28.0,
50            extra_large_increased: 32.0,
51            extra_extra_large: 48.0,
52            full: f64::INFINITY,
53        }
54    }
55
56    /// Resolves a corner radius against a box's shorter side (`min(width,
57    /// height)`), clamping an infinite ([`ShapeScale::full`]) radius to a
58    /// pill shape (`shorter_side / 2.0`) and any finite radius to itself
59    /// (already-finite tokens never need clamping in the M3 scale, but a
60    /// caller-supplied custom radius larger than the box would produce a
61    /// visually broken corner, so this also caps at the pill radius as a
62    /// safety net).
63    pub fn resolve(radius: f64, width: f64, height: f64) -> f64 {
64        let pill = width.min(height) / 2.0;
65        if radius.is_infinite() {
66            pill
67        } else {
68            radius.min(pill)
69        }
70    }
71
72    /// Computes the inner corner radius for a concentrically-nested element.
73    ///
74    /// The **concentric-corner principle** (Liquid Glass, iOS 26+; see WWDC
75    /// 2025 session 219 and Apple HIG) defines inner and outer shapes'
76    /// rounded corners as sharing the same geometric center, with the inner
77    /// radius derived from the outer radius minus the inset depth. This
78    /// applies when a glass container (outer, radius `outer_radius`) nests a
79    /// child container (inner, inset by `inset` on all sides); the child's
80    /// corner radius is `max(outer_radius - inset, 0.0)`.
81    ///
82    /// # Formula
83    ///
84    /// - If `outer_radius` is infinite ([`f64::INFINITY`]), return infinite
85    ///   (a full pill shape has no inner maximum).
86    /// - Otherwise, return `max(outer_radius - inset, 0.0)` (clamped at zero
87    ///   to prevent negative radii).
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use frust_theme::ShapeScale;
93    ///
94    /// // Normal nesting: glass bar (outer r=20) with inner pill (inset 4)
95    /// assert_eq!(ShapeScale::concentric_inner(20.0, 4.0), 16.0);
96    ///
97    /// // Inset larger than outer: clamp at zero
98    /// assert_eq!(ShapeScale::concentric_inner(8.0, 12.0), 0.0);
99    ///
100    /// // Infinite outer radius stays infinite (no clamping)
101    /// assert!(ShapeScale::concentric_inner(f64::INFINITY, 4.0).is_infinite());
102    /// ```
103    pub fn concentric_inner(outer_radius: f64, inset: f64) -> f64 {
104        if outer_radius.is_infinite() {
105            f64::INFINITY
106        } else {
107            (outer_radius - inset).max(0.0)
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn matches_table() {
118        let s = ShapeScale::neutral();
119        assert_eq!(s.none, 0.0);
120        assert_eq!(s.extra_small, 4.0);
121        assert_eq!(s.small, 8.0);
122        assert_eq!(s.medium, 12.0);
123        assert_eq!(s.large, 16.0);
124        assert_eq!(s.large_increased, 20.0);
125        assert_eq!(s.extra_large, 28.0);
126        assert_eq!(s.extra_large_increased, 32.0);
127        assert_eq!(s.extra_extra_large, 48.0);
128        assert!(s.full.is_infinite());
129    }
130
131    #[test]
132    fn full_resolves_to_pill_radius() {
133        let s = ShapeScale::neutral();
134        assert_eq!(ShapeScale::resolve(s.full, 100.0, 40.0), 20.0);
135    }
136
137    #[test]
138    fn finite_radius_passes_through_when_smaller_than_pill() {
139        let s = ShapeScale::neutral();
140        assert_eq!(ShapeScale::resolve(s.medium, 200.0, 200.0), 12.0);
141    }
142
143    #[test]
144    fn finite_radius_clamps_when_larger_than_pill() {
145        assert_eq!(ShapeScale::resolve(1000.0, 40.0, 20.0), 10.0);
146    }
147
148    #[test]
149    fn concentric_inner_normal_nesting() {
150        // Glass bar (outer r=20) with inner element (inset 4)
151        assert_eq!(ShapeScale::concentric_inner(20.0, 4.0), 16.0);
152    }
153
154    #[test]
155    fn concentric_inner_zero_outer_radius() {
156        // Zero outer radius stays zero regardless of inset
157        assert_eq!(ShapeScale::concentric_inner(0.0, 5.0), 0.0);
158    }
159
160    #[test]
161    fn concentric_inner_zero_inset() {
162        // No inset: inner radius equals outer radius
163        assert_eq!(ShapeScale::concentric_inner(20.0, 0.0), 20.0);
164    }
165
166    #[test]
167    fn concentric_inner_clamps_at_zero() {
168        // Inset larger than outer radius: clamp at zero (no negative radii)
169        assert_eq!(ShapeScale::concentric_inner(8.0, 12.0), 0.0);
170    }
171
172    #[test]
173    fn concentric_inner_inset_equals_outer() {
174        // Exact match: inset equals outer radius, result is zero
175        assert_eq!(ShapeScale::concentric_inner(15.0, 15.0), 0.0);
176    }
177
178    #[test]
179    fn concentric_inner_with_infinity() {
180        // Infinite outer radius stays infinite (a full pill has no inner limit)
181        assert!(ShapeScale::concentric_inner(f64::INFINITY, 4.0).is_infinite());
182        assert!(ShapeScale::concentric_inner(f64::INFINITY, 100.0).is_infinite());
183    }
184}