Skip to main content

rlvgl_ui/
theme.rs

1// SPDX-License-Identifier: MIT
2//! Theme and token definitions for styling
3//! [`rlvgl-widgets`](rlvgl_widgets) components.
4
5use crate::style::Color;
6use rlvgl_core::style::Style;
7use rlvgl_core::widget::Rect;
8
9/// Named color scheme used by themed component variants.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ColorScheme {
12    /// Primary brand color.
13    #[default]
14    Primary,
15    /// Neutral grayscale color.
16    Neutral,
17    /// Informational blue color.
18    Info,
19    /// Success green color.
20    Success,
21    /// Warning amber color.
22    Warning,
23    /// Destructive or error red color.
24    Danger,
25}
26
27/// Visual treatment for a themed component.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum Variant {
30    /// Filled background using the scheme color.
31    #[default]
32    Solid,
33    /// Low-emphasis filled background derived from the scheme color.
34    Subtle,
35    /// Transparent background with a scheme-colored border.
36    Outline,
37    /// Transparent background with no border.
38    Ghost,
39}
40
41/// Component sizing tier.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum ComponentSize {
44    /// Extra-small controls.
45    Xs,
46    /// Small controls.
47    Sm,
48    /// Medium controls.
49    #[default]
50    Md,
51    /// Large controls.
52    Lg,
53}
54
55/// Resolved colors for a [`ColorScheme`].
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct SchemeColors {
58    /// Strong scheme color used by solid backgrounds and accents.
59    pub solid: Color,
60    /// Text color that contrasts with [`solid`](Self::solid).
61    pub contrast: Color,
62    /// Low-emphasis background derived from [`solid`](Self::solid).
63    pub subtle: Color,
64    /// Medium-emphasis fill derived from [`solid`](Self::solid).
65    pub muted: Color,
66    /// Border color derived from [`solid`](Self::solid).
67    pub border: Color,
68}
69
70/// Resolved sizing metrics for a [`ComponentSize`].
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub struct ComponentSizeTokens {
73    /// Recommended minimum control height in pixels.
74    pub min_height: i32,
75    /// Recommended gap between internal or adjacent items.
76    pub gap: i32,
77    /// Recommended corner radius.
78    pub radius: u8,
79    /// Recommended border width.
80    pub border_width: u8,
81}
82
83impl ComponentSizeTokens {
84    /// Create a rect at `(x, y)` using `width` and this size's minimum height.
85    pub const fn rect(self, x: i32, y: i32, width: i32) -> Rect {
86        Rect {
87            x,
88            y,
89            width,
90            height: self.min_height,
91        }
92    }
93
94    /// Create an origin rect using `width` and this size's minimum height.
95    pub const fn origin_rect(self, width: i32) -> Rect {
96        self.rect(0, 0, width)
97    }
98}
99
100/// Resolved themed component styling.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct ComponentStyle {
103    /// Main style for the component's primary visual part.
104    pub style: Style,
105    /// Text color for labels in this variant.
106    pub text_color: Color,
107    /// Accent color for filled bars, indicators, selected rows, or knobs.
108    pub accent_color: Color,
109    /// Sizing metrics associated with the style.
110    pub size: ComponentSizeTokens,
111}
112
113/// Spacing token values used across widgets.
114#[derive(Debug, Clone, Copy)]
115pub struct Spacing {
116    /// Extra small spacing.
117    pub xs: u8,
118    /// Small spacing.
119    pub sm: u8,
120    /// Medium spacing.
121    pub md: u8,
122    /// Large spacing.
123    pub lg: u8,
124    /// Extra large spacing.
125    pub xl: u8,
126}
127
128impl Default for Spacing {
129    fn default() -> Self {
130        Self {
131            xs: 2,
132            sm: 4,
133            md: 8,
134            lg: 16,
135            xl: 24,
136        }
137    }
138}
139
140/// Radius token values for rounding corners.
141#[derive(Debug, Clone, Copy)]
142pub struct Radii {
143    /// No rounding.
144    pub none: u8,
145    /// Small rounding.
146    pub sm: u8,
147    /// Medium rounding.
148    pub md: u8,
149    /// Large rounding.
150    pub lg: u8,
151    /// Fully circular.
152    pub full: u8,
153}
154
155impl Default for Radii {
156    fn default() -> Self {
157        Self {
158            none: 0,
159            sm: 2,
160            md: 4,
161            lg: 8,
162            full: 255,
163        }
164    }
165}
166
167/// Color token values.
168#[derive(Debug, Clone, Copy)]
169pub struct Colors {
170    /// Primary brand color.
171    pub primary: Color,
172    /// Background surface color.
173    pub background: Color,
174    /// Default text color.
175    pub text: Color,
176}
177
178impl Default for Colors {
179    fn default() -> Self {
180        Self {
181            primary: Color(98, 0, 238, 255),
182            background: Color(255, 255, 255, 255),
183            text: Color(0, 0, 0, 255),
184        }
185    }
186}
187
188/// Font token identifiers.
189#[derive(Debug, Clone, Copy)]
190pub struct Fonts {
191    /// Small font name.
192    pub small: &'static str,
193    /// Body font name.
194    pub body: &'static str,
195    /// Heading font name.
196    pub heading: &'static str,
197}
198
199impl Default for Fonts {
200    fn default() -> Self {
201        Self {
202            small: "tiny",
203            body: "default",
204            heading: "bold",
205        }
206    }
207}
208
209/// Token namespaces for theming.
210#[derive(Debug, Clone, Copy, Default)]
211pub struct Tokens {
212    /// Spacing tokens.
213    pub spacing: Spacing,
214    /// Color tokens.
215    pub colors: Colors,
216    /// Radius tokens.
217    pub radii: Radii,
218    /// Font tokens.
219    pub fonts: Fonts,
220}
221
222/// Collection of tokens representing a theme.
223#[derive(Debug, Clone, Copy)]
224pub struct Theme {
225    /// Token values used to style widgets.
226    pub tokens: Tokens,
227}
228
229impl Theme {
230    /// Construct the default Material light theme.
231    pub fn material_light() -> Self {
232        Self {
233            tokens: Tokens::default(),
234        }
235    }
236
237    /// Resolve a semantic color scheme.
238    pub fn scheme(&self, scheme: ColorScheme) -> SchemeColors {
239        let solid = match scheme {
240            ColorScheme::Primary => self.tokens.colors.primary,
241            ColorScheme::Neutral => Color(86, 94, 104, 255),
242            ColorScheme::Info => Color(0, 122, 255, 255),
243            ColorScheme::Success => Color(24, 150, 90, 255),
244            ColorScheme::Warning => Color(230, 162, 0, 255),
245            ColorScheme::Danger => Color(210, 60, 60, 255),
246        };
247        let contrast = match scheme {
248            ColorScheme::Warning => Color(25, 25, 25, 255),
249            _ => Color(255, 255, 255, 255),
250        };
251        let bg = self.tokens.colors.background;
252        SchemeColors {
253            solid,
254            contrast,
255            subtle: solid.lerp(bg, 4, 5),
256            muted: solid.lerp(bg, 3, 5),
257            border: solid.lerp(bg, 1, 2),
258        }
259    }
260
261    /// Resolve sizing metrics for a component size tier.
262    pub fn component_size(&self, size: ComponentSize) -> ComponentSizeTokens {
263        match size {
264            ComponentSize::Xs => ComponentSizeTokens {
265                min_height: 14,
266                gap: i32::from(self.tokens.spacing.xs),
267                radius: self.tokens.radii.sm,
268                border_width: 1,
269            },
270            ComponentSize::Sm => ComponentSizeTokens {
271                min_height: 18,
272                gap: i32::from(self.tokens.spacing.sm),
273                radius: self.tokens.radii.sm,
274                border_width: 1,
275            },
276            ComponentSize::Md => ComponentSizeTokens {
277                min_height: 24,
278                gap: i32::from(self.tokens.spacing.md),
279                radius: self.tokens.radii.md,
280                border_width: 1,
281            },
282            ComponentSize::Lg => ComponentSizeTokens {
283                min_height: 32,
284                gap: i32::from(self.tokens.spacing.lg),
285                radius: self.tokens.radii.lg,
286                border_width: 2,
287            },
288        }
289    }
290
291    /// Create a control rect using the given component size's minimum height.
292    pub fn control_rect(&self, x: i32, y: i32, width: i32, size: ComponentSize) -> Rect {
293        self.component_size(size).rect(x, y, width)
294    }
295
296    /// Create an origin control rect using the given component size's minimum height.
297    pub fn origin_control_rect(&self, width: i32, size: ComponentSize) -> Rect {
298        self.component_size(size).origin_rect(width)
299    }
300
301    /// Resolve a complete component style from a scheme, variant, and size.
302    pub fn component_style(
303        &self,
304        scheme: ColorScheme,
305        variant: Variant,
306        size: ComponentSize,
307    ) -> ComponentStyle {
308        let colors = self.scheme(scheme);
309        let size_tokens = self.component_size(size);
310        let transparent_bg = transparent(self.tokens.colors.background);
311        let transparent_border = transparent(colors.solid);
312        let (bg_color, border_color, border_width, text_color, accent_color) = match variant {
313            Variant::Solid => (colors.solid, colors.solid, 0, colors.contrast, colors.solid),
314            Variant::Subtle => (colors.subtle, colors.border, 0, colors.solid, colors.solid),
315            Variant::Outline => (
316                transparent_bg,
317                colors.solid,
318                size_tokens.border_width,
319                colors.solid,
320                colors.solid,
321            ),
322            Variant::Ghost => (
323                transparent_bg,
324                transparent_border,
325                0,
326                colors.solid,
327                colors.solid,
328            ),
329        };
330
331        ComponentStyle {
332            style: Style {
333                bg_color,
334                border_color,
335                border_width,
336                alpha: 255,
337                radius: size_tokens.radius,
338            },
339            text_color,
340            accent_color,
341            size: size_tokens,
342        }
343    }
344
345    /// Apply the theme globally.
346    ///
347    /// Currently a placeholder until LVGL integration is implemented.
348    ///
349    /// # Deprecation (LPAR-07)
350    ///
351    /// **Deprecated.** Use [`rlvgl_core::theme::LparTheme::apply_to_node`]
352    /// instead. See LPAR-07 §9.4 for migration guidance.
353    #[deprecated(
354        since = "0.2.2",
355        note = "use LparTheme::apply_to_node; see LPAR-07 §9.4"
356    )]
357    pub fn apply_global(&self) {
358        // TODO: Bridge tokens to LVGL once available.
359    }
360}
361
362fn transparent(color: Color) -> Color {
363    Color(color.0, color.1, color.2, 0)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn component_style_resolves_solid_variant() {
372        let theme = Theme::material_light();
373        let style = theme.component_style(ColorScheme::Primary, Variant::Solid, ComponentSize::Md);
374
375        assert_eq!(style.style.bg_color, theme.tokens.colors.primary);
376        assert_eq!(style.style.border_width, 0);
377        assert_eq!(style.text_color, Color(255, 255, 255, 255));
378        assert_eq!(style.size.min_height, 24);
379    }
380
381    #[test]
382    fn component_style_resolves_outline_variant() {
383        let theme = Theme::material_light();
384        let style = theme.component_style(ColorScheme::Danger, Variant::Outline, ComponentSize::Lg);
385
386        assert_eq!(style.style.bg_color.3, 0);
387        assert_eq!(style.style.border_width, 2);
388        assert_eq!(style.text_color, style.accent_color);
389        assert_eq!(style.size.radius, theme.tokens.radii.lg);
390    }
391
392    #[test]
393    fn component_size_tokens_create_rects() {
394        let theme = Theme::material_light();
395        let size = theme.component_size(ComponentSize::Sm);
396
397        assert_eq!(
398            size.rect(1, 2, 30),
399            Rect {
400                x: 1,
401                y: 2,
402                width: 30,
403                height: 18
404            }
405        );
406        assert_eq!(size.origin_rect(40).height, 18);
407    }
408
409    #[test]
410    fn theme_creates_control_rects_from_sizes() {
411        let theme = Theme::material_light();
412
413        assert_eq!(
414            theme.control_rect(4, 5, 60, ComponentSize::Lg),
415            Rect {
416                x: 4,
417                y: 5,
418                width: 60,
419                height: 32
420            }
421        );
422        assert_eq!(theme.origin_control_rect(60, ComponentSize::Xs).height, 14);
423    }
424}