Skip to main content

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}