oxiui_theme/spec.rs
1//! Border and shadow specifications, plus elevation → shadow presets.
2//!
3//! The single-edge [`BorderSpec`] is paired with [`BorderSpecs`] for per-side
4//! borders (top/right/bottom/left). Shadows come in two flavours: a single
5//! representative [`ShadowSpec`] via [`elevation_shadow`], and the full
6//! ambient + key pair stack via [`elevation_shadows`] following Material
7//! Design's two-layer elevation model.
8
9use oxiui_core::Color;
10
11/// Border line style.
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum BorderStyle {
14 /// No border drawn.
15 None,
16 /// A solid line.
17 Solid,
18 /// A dashed line.
19 Dashed,
20 /// A dotted line.
21 Dotted,
22 /// A double line.
23 Double,
24}
25
26/// A border specification: width, style, and colour.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct BorderSpec {
29 /// Border width in logical pixels.
30 pub width: f32,
31 /// Line style.
32 pub style: BorderStyle,
33 /// Border colour.
34 pub color: Color,
35}
36
37impl BorderSpec {
38 /// A solid border of the given width and colour.
39 pub const fn solid(width: f32, color: Color) -> Self {
40 Self {
41 width,
42 style: BorderStyle::Solid,
43 color,
44 }
45 }
46
47 /// No border.
48 pub const fn none() -> Self {
49 Self {
50 width: 0.0,
51 style: BorderStyle::None,
52 color: Color(0, 0, 0, 0),
53 }
54 }
55
56 /// Returns `true` if this border would draw nothing.
57 pub fn is_invisible(&self) -> bool {
58 self.style == BorderStyle::None || self.width <= 0.0 || self.color.3 == 0
59 }
60}
61
62/// Per-side border specification: each edge of a rectangle may carry its own
63/// [`BorderSpec`]. Use [`BorderSpecs::uniform`] when all four edges agree.
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct BorderSpecs {
66 /// Top edge.
67 pub top: BorderSpec,
68 /// Right edge.
69 pub right: BorderSpec,
70 /// Bottom edge.
71 pub bottom: BorderSpec,
72 /// Left edge.
73 pub left: BorderSpec,
74}
75
76impl BorderSpecs {
77 /// Build a per-side spec where every edge is the same [`BorderSpec`].
78 pub const fn uniform(spec: BorderSpec) -> Self {
79 Self {
80 top: spec,
81 right: spec,
82 bottom: spec,
83 left: spec,
84 }
85 }
86
87 /// Build a per-side spec with no border on any edge.
88 pub const fn none() -> Self {
89 Self::uniform(BorderSpec::none())
90 }
91
92 /// Build a per-side spec with the same `width`, `style`, and `color`.
93 pub const fn solid(width: f32, color: Color) -> Self {
94 Self::uniform(BorderSpec::solid(width, color))
95 }
96
97 /// `true` if every edge would draw nothing.
98 pub fn is_invisible(&self) -> bool {
99 self.top.is_invisible()
100 && self.right.is_invisible()
101 && self.bottom.is_invisible()
102 && self.left.is_invisible()
103 }
104
105 /// `true` if every edge is identical (allowing renderers to take a fast
106 /// uniform-border path).
107 pub fn is_uniform(&self) -> bool {
108 self.top == self.right && self.right == self.bottom && self.bottom == self.left
109 }
110}
111
112/// A box-shadow specification.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub struct ShadowSpec {
115 /// Horizontal offset in logical pixels.
116 pub offset_x: f32,
117 /// Vertical offset in logical pixels.
118 pub offset_y: f32,
119 /// Blur radius in logical pixels.
120 pub blur: f32,
121 /// Spread radius in logical pixels (grows the shadow before blurring).
122 pub spread: f32,
123 /// Shadow colour (usually semi-transparent black).
124 pub color: Color,
125 /// Whether the shadow is drawn inside the box (inset) rather than outside.
126 pub inset: bool,
127}
128
129impl ShadowSpec {
130 /// A drop shadow (outset) with the given parameters.
131 pub const fn drop(offset_y: f32, blur: f32, color: Color) -> Self {
132 Self {
133 offset_x: 0.0,
134 offset_y,
135 blur,
136 spread: 0.0,
137 color,
138 inset: false,
139 }
140 }
141
142 /// Construct a shadow from explicit offset, blur, and RGBA byte array.
143 ///
144 /// `color_rgba` is `[r, g, b, a]` where each channel is `0..=255`.
145 /// `spread` defaults to 0 and `inset` defaults to `false`.
146 pub fn new(offset_x: f32, offset_y: f32, blur_radius: f32, color_rgba: [u8; 4]) -> Self {
147 Self {
148 offset_x,
149 offset_y,
150 blur: blur_radius,
151 spread: 0.0,
152 color: Color(color_rgba[0], color_rgba[1], color_rgba[2], color_rgba[3]),
153 inset: false,
154 }
155 }
156
157 /// Construct a directional drop shadow with a default semi-transparent black colour.
158 ///
159 /// Equivalent to `ShadowSpec::new(offset_x, offset_y, blur, [0, 0, 0, 160])`.
160 pub fn drop_shadow(offset_x: f32, offset_y: f32, blur: f32) -> Self {
161 Self::new(offset_x, offset_y, blur, [0, 0, 0, 160])
162 }
163
164 /// Builder: set the spread radius (positive expands, negative contracts).
165 pub fn with_spread(mut self, spread: f32) -> Self {
166 self.spread = spread;
167 self
168 }
169
170 /// Builder: set the `inset` flag.
171 pub fn with_inset(mut self, inset: bool) -> Self {
172 self.inset = inset;
173 self
174 }
175
176 /// Encode the shadow colour as a packed `0xAARRGGBB` `u32`.
177 ///
178 /// # Example
179 /// ```
180 /// # use oxiui_theme::ShadowSpec;
181 /// let spec = ShadowSpec::new(0.0, 0.0, 0.0, [255, 0, 0, 128]);
182 /// assert_eq!(spec.to_pixel_color(), 0x80FF_0000);
183 /// ```
184 pub fn to_pixel_color(&self) -> u32 {
185 let Color(r, g, b, a) = self.color;
186 ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
187 }
188
189 /// Returns `true` if this shadow would render nothing.
190 pub fn is_invisible(&self) -> bool {
191 self.color.3 == 0
192 }
193}
194
195/// Convert a continuous elevation value (in logical dp, typical range 0–24) to a
196/// single representative [`ShadowSpec`].
197///
198/// Elevation 0 produces a fully transparent, zero-blur shadow. Higher values yield
199/// progressively larger blur and downward offset following a Material Design-style
200/// curve.
201///
202/// | elevation | blur (approx) | offset_y (approx) |
203/// |-----------|---------------|--------------------|
204/// | 0 | 0 | 0 |
205/// | 2 | 4 | 2 |
206/// | 4 | 8 | 4 |
207/// | 8 | 16 | 8 |
208///
209/// # Examples
210/// ```
211/// # use oxiui_theme::elevation_to_shadow;
212/// let s = elevation_to_shadow(0.0);
213/// assert_eq!(s.to_pixel_color() & 0xFF, 0); // alpha == 0 at elevation 0
214/// let s4 = elevation_to_shadow(4.0);
215/// assert!(s4.blur > 0.0);
216/// ```
217pub fn elevation_to_shadow(elevation: f32) -> ShadowSpec {
218 if elevation <= 0.0 {
219 return ShadowSpec {
220 offset_x: 0.0,
221 offset_y: 0.0,
222 blur: 0.0,
223 spread: 0.0,
224 color: Color(0, 0, 0, 0),
225 inset: false,
226 };
227 }
228 let e = elevation.max(0.0);
229 let blur = e * 2.0;
230 let offset_y = e * 1.0;
231 // Alpha scales from ~48 at elevation 1 up to ~160 at elevation 24.
232 let alpha = (40.0 + e * 5.0).min(160.0) as u8;
233 ShadowSpec {
234 offset_x: 0.0,
235 offset_y,
236 blur,
237 spread: 0.0,
238 color: Color(0, 0, 0, alpha),
239 inset: false,
240 }
241}
242
243/// Returns the conventional drop-shadow stack for elevation `level` (0..=5).
244///
245/// Level 0 is no shadow; higher levels are larger and softer. The colour is a
246/// semi-transparent black scaled with elevation. Many shadows in real design
247/// systems stack two layers (ambient + key); here we return a single
248/// representative shadow per level for simplicity.
249pub fn elevation_shadow(level: usize) -> Option<ShadowSpec> {
250 match level {
251 0 => None,
252 1 => Some(ShadowSpec::drop(1.0, 2.0, Color(0, 0, 0, 56))),
253 2 => Some(ShadowSpec::drop(2.0, 4.0, Color(0, 0, 0, 64))),
254 3 => Some(ShadowSpec::drop(4.0, 8.0, Color(0, 0, 0, 72))),
255 4 => Some(ShadowSpec::drop(8.0, 16.0, Color(0, 0, 0, 84))),
256 _ => Some(ShadowSpec::drop(12.0, 24.0, Color(0, 0, 0, 96))),
257 }
258}
259
260/// Returns a Material Design-style **ambient + key** two-shadow stack for the
261/// given `elevation` (logical dp, typical range 0–24).
262///
263/// Material Design uses two shadows per elevated surface:
264/// - **Ambient** — diffuse, spread wide, low opacity.
265/// - **Key** — directional, smaller, higher opacity.
266///
267/// The returned `Vec` always contains exactly **two** [`ShadowSpec`]s
268/// `[ambient, key]` for elevation > 0, or two invisible (transparent) shadows
269/// for elevation == 0.
270///
271/// # Examples
272/// ```
273/// # use oxiui_theme::spec::elevation_shadows;
274/// let stack = elevation_shadows(4);
275/// assert_eq!(stack.len(), 2);
276/// let (ambient, key) = (&stack[0], &stack[1]);
277/// assert!(ambient.blur > key.blur); // ambient is broader
278/// ```
279pub fn elevation_shadows(elevation: u32) -> Vec<ShadowSpec> {
280 // Formulas approximate the Material Design 2 dp → shadow mapping.
281 // Ambient: large blur, low alpha; Key: smaller blur, higher alpha.
282 if elevation == 0 {
283 return vec![
284 ShadowSpec {
285 offset_x: 0.0,
286 offset_y: 0.0,
287 blur: 0.0,
288 spread: 0.0,
289 color: Color(0, 0, 0, 0),
290 inset: false,
291 },
292 ShadowSpec {
293 offset_x: 0.0,
294 offset_y: 0.0,
295 blur: 0.0,
296 spread: 0.0,
297 color: Color(0, 0, 0, 0),
298 inset: false,
299 },
300 ];
301 }
302 let dp = elevation as f32;
303 // Ambient shadow: grows as sqrt of elevation, wide blur, low alpha.
304 let ambient_blur = (dp * 3.0).max(1.0);
305 let ambient_y = dp * 0.5;
306 let ambient_alpha = ((12.0 + dp * 1.5).min(60.0)) as u8;
307 // Key shadow: proportional to elevation, tighter blur, higher alpha.
308 let key_blur = (dp * 1.5).max(1.0);
309 let key_y = dp * 1.0;
310 let key_alpha = ((20.0 + dp * 2.5).min(100.0)) as u8;
311 vec![
312 ShadowSpec {
313 // ambient
314 offset_x: 0.0,
315 offset_y: ambient_y,
316 blur: ambient_blur,
317 spread: 0.0,
318 color: Color(0, 0, 0, ambient_alpha),
319 inset: false,
320 },
321 ShadowSpec {
322 // key
323 offset_x: 0.0,
324 offset_y: key_y,
325 blur: key_blur,
326 spread: 0.0,
327 color: Color(0, 0, 0, key_alpha),
328 inset: false,
329 },
330 ]
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn border_visibility() {
339 assert!(BorderSpec::none().is_invisible());
340 assert!(!BorderSpec::solid(1.0, Color(255, 255, 255, 255)).is_invisible());
341 // Zero alpha is invisible.
342 assert!(BorderSpec::solid(2.0, Color(255, 255, 255, 0)).is_invisible());
343 }
344
345 #[test]
346 fn shadow_visibility() {
347 assert!(!ShadowSpec::drop(2.0, 4.0, Color(0, 0, 0, 100)).is_invisible());
348 assert!(ShadowSpec::drop(2.0, 4.0, Color(0, 0, 0, 0)).is_invisible());
349 }
350
351 #[test]
352 fn elevation_grows_with_level() {
353 assert!(elevation_shadow(0).is_none());
354 let s1 = elevation_shadow(1).expect("level 1 has a shadow");
355 let s5 = elevation_shadow(5).expect("level 5 has a shadow");
356 assert!(s5.blur > s1.blur);
357 assert!(s5.offset_y > s1.offset_y);
358 // Out-of-range clamps to the strongest.
359 assert_eq!(elevation_shadow(99), elevation_shadow(5));
360 }
361
362 #[test]
363 fn elevation_shadow_count() {
364 let stack = elevation_shadows(4);
365 assert_eq!(stack.len(), 2, "must return exactly 2 ShadowSpec values");
366 }
367
368 #[test]
369 fn elevation_zero_returns_invisible_pair() {
370 let stack = elevation_shadows(0);
371 assert_eq!(stack.len(), 2);
372 assert!(stack[0].is_invisible());
373 assert!(stack[1].is_invisible());
374 }
375
376 #[test]
377 fn elevation_shadows_increases_with_level() {
378 let stack_low = elevation_shadows(2);
379 let stack_high = elevation_shadows(8);
380 // The ambient (index 0) blur must grow with elevation.
381 assert!(
382 stack_high[0].blur > stack_low[0].blur,
383 "ambient blur must increase: {} vs {}",
384 stack_high[0].blur,
385 stack_low[0].blur,
386 );
387 }
388
389 #[test]
390 fn border_style_double_exists() {
391 let b = BorderSpec {
392 width: 2.0,
393 style: BorderStyle::Double,
394 color: Color(0, 0, 0, 255),
395 };
396 assert_eq!(b.style, BorderStyle::Double);
397 }
398}