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}