Skip to main content

embedded_gui/
style.rs

1use embedded_graphics_core::pixelcolor::{Rgb565, RgbColor};
2
3use crate::{font::FontId, geometry::EdgeInsets};
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub struct Border {
7    pub color: Rgb565,
8    pub width: u8,
9}
10
11impl Border {
12    pub const fn none() -> Self {
13        Self {
14            color: Rgb565::BLACK,
15            width: 0,
16        }
17    }
18
19    pub const fn one(color: Rgb565) -> Self {
20        Self { color, width: 1 }
21    }
22}
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Shadow {
26    pub color: Rgb565,
27    pub opacity: u8,
28    pub offset_x: i8,
29    pub offset_y: i8,
30    pub spread: u8,
31}
32
33impl Shadow {
34    pub const fn none() -> Option<Self> {
35        None
36    }
37
38    pub const fn soft() -> Self {
39        Self {
40            color: Rgb565::BLACK,
41            opacity: 96,
42            offset_x: 1,
43            offset_y: 2,
44            spread: 2,
45        }
46    }
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum GradientDirection {
51    Vertical,
52    Horizontal,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct LinearGradient {
57    pub start: Rgb565,
58    pub end: Rgb565,
59    pub direction: GradientDirection,
60}
61
62impl LinearGradient {
63    pub const fn vertical(start: Rgb565, end: Rgb565) -> Self {
64        Self {
65            start,
66            end,
67            direction: GradientDirection::Vertical,
68        }
69    }
70
71    pub const fn horizontal(start: Rgb565, end: Rgb565) -> Self {
72        Self {
73            start,
74            end,
75            direction: GradientDirection::Horizontal,
76        }
77    }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub struct AlphaLinearGradient {
82    pub start_color: Rgb565,
83    pub start_alpha: u8,
84    pub end_color: Rgb565,
85    pub end_alpha: u8,
86    pub direction: GradientDirection,
87}
88
89impl AlphaLinearGradient {
90    pub const fn new(
91        start_color: Rgb565,
92        start_alpha: u8,
93        end_color: Rgb565,
94        end_alpha: u8,
95        direction: GradientDirection,
96    ) -> Self {
97        Self {
98            start_color,
99            start_alpha,
100            end_color,
101            end_alpha,
102            direction,
103        }
104    }
105
106    pub const fn vertical(
107        start_color: Rgb565,
108        start_alpha: u8,
109        end_color: Rgb565,
110        end_alpha: u8,
111    ) -> Self {
112        Self::new(
113            start_color,
114            start_alpha,
115            end_color,
116            end_alpha,
117            GradientDirection::Vertical,
118        )
119    }
120
121    pub const fn horizontal(
122        start_color: Rgb565,
123        start_alpha: u8,
124        end_color: Rgb565,
125        end_alpha: u8,
126    ) -> Self {
127        Self::new(
128            start_color,
129            start_alpha,
130            end_color,
131            end_alpha,
132            GradientDirection::Horizontal,
133        )
134    }
135
136    pub fn sample(&self, t: u8) -> (Rgb565, u8) {
137        let color = lerp_rgb565_public(self.start_color, self.end_color, t);
138        let alpha = lerp_u8(self.start_alpha, self.end_alpha, t);
139        (color, alpha)
140    }
141}
142
143#[derive(Clone, Copy, Debug, PartialEq)]
144pub struct AlphaRadialGradient {
145    pub center_x: f32,
146    pub center_y: f32,
147    pub radius: f32,
148    pub start_color: Rgb565,
149    pub start_alpha: u8,
150    pub end_color: Rgb565,
151    pub end_alpha: u8,
152}
153
154impl AlphaRadialGradient {
155    pub fn new(
156        center_x: f32,
157        center_y: f32,
158        radius: f32,
159        start_color: Rgb565,
160        start_alpha: u8,
161        end_color: Rgb565,
162        end_alpha: u8,
163    ) -> Self {
164        Self {
165            center_x,
166            center_y,
167            radius: if radius <= 0.0 { 1.0 } else { radius },
168            start_color,
169            start_alpha,
170            end_color,
171            end_alpha,
172        }
173    }
174
175    pub fn sample_at_dist(&self, dist: f32) -> (Rgb565, u8) {
176        let t = (dist / self.radius).clamp(0.0, 1.0);
177        let t_u8 = (t * 255.0) as u8;
178        let color = lerp_rgb565_public(self.start_color, self.end_color, t_u8);
179        let alpha = lerp_u8(self.start_alpha, self.end_alpha, t_u8);
180        (color, alpha)
181    }
182}
183
184#[inline]
185pub fn lerp_u8(a: u8, b: u8, t: u8) -> u8 {
186    let t = t as u32;
187    let inv = 255u32 - t;
188    (((a as u32 * inv) + (b as u32 * t)) / 255) as u8
189}
190
191#[inline]
192pub fn lerp_rgb565_public(a: Rgb565, b: Rgb565, t: u8) -> Rgb565 {
193    let t = t as u16;
194    let inv = 255u16.saturating_sub(t);
195    let r = ((a.r() as u16 * inv) + (b.r() as u16 * t)) / 255;
196    let g = ((a.g() as u16 * inv) + (b.g() as u16 * t)) / 255;
197    let bb = ((a.b() as u16 * inv) + (b.b() as u16 * t)) / 255;
198    Rgb565::new(r as u8, g as u8, bb as u8)
199}
200
201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
202pub struct Style {
203    pub background: Option<Rgb565>,
204    pub gradient: Option<LinearGradient>,
205    pub font: FontId,
206    pub foreground: Rgb565,
207    pub text: Rgb565,
208    pub accent: Rgb565,
209    pub opacity: u8,
210    pub corner_radius: u8,
211    pub shadow: Option<Shadow>,
212    pub border: Border,
213    pub padding: EdgeInsets,
214}
215
216impl Style {
217    pub const fn new() -> Self {
218        Self {
219            background: None,
220            gradient: None,
221            font: FontId::Tiny3x5,
222            foreground: Rgb565::WHITE,
223            text: Rgb565::WHITE,
224            accent: Rgb565::new(0, 42, 31),
225            opacity: 255,
226            corner_radius: 0,
227            shadow: Shadow::none(),
228            border: Border::none(),
229            padding: EdgeInsets::all(0),
230        }
231    }
232
233    pub const fn panel() -> Self {
234        Self {
235            background: Some(Rgb565::new(2, 4, 8)),
236            gradient: Some(LinearGradient::vertical(
237                Rgb565::new(4, 8, 12),
238                Rgb565::new(1, 2, 5),
239            )),
240            font: FontId::Tiny3x5,
241            foreground: Rgb565::WHITE,
242            text: Rgb565::WHITE,
243            accent: Rgb565::new(0, 42, 31),
244            opacity: 255,
245            corner_radius: 2,
246            shadow: Some(Shadow::soft()),
247            border: Border::one(Rgb565::new(8, 16, 20)),
248            padding: EdgeInsets::all(2),
249        }
250    }
251
252    pub const fn label() -> Self {
253        Self {
254            background: None,
255            gradient: None,
256            font: FontId::Tiny3x5,
257            foreground: Rgb565::WHITE,
258            text: Rgb565::WHITE,
259            accent: Rgb565::new(0, 42, 31),
260            opacity: 255,
261            corner_radius: 0,
262            shadow: Shadow::none(),
263            border: Border::none(),
264            padding: EdgeInsets::all(0),
265        }
266    }
267
268    pub const fn button() -> Self {
269        Self {
270            background: Some(Rgb565::new(4, 8, 12)),
271            gradient: Some(LinearGradient::vertical(
272                Rgb565::new(6, 12, 16),
273                Rgb565::new(2, 4, 8),
274            )),
275            font: FontId::Medium4x7,
276            foreground: Rgb565::WHITE,
277            text: Rgb565::WHITE,
278            accent: Rgb565::new(0, 48, 40),
279            opacity: 255,
280            corner_radius: 2,
281            shadow: Some(Shadow {
282                color: Rgb565::BLACK,
283                opacity: 88,
284                offset_x: 1,
285                offset_y: 1,
286                spread: 1,
287            }),
288            border: Border::one(Rgb565::new(12, 24, 28)),
289            padding: EdgeInsets::symmetric(3, 2),
290        }
291    }
292
293    pub const fn progress() -> Self {
294        Self {
295            background: Some(Rgb565::new(3, 4, 5)),
296            gradient: Some(LinearGradient::horizontal(
297                Rgb565::new(3, 5, 6),
298                Rgb565::new(1, 2, 3),
299            )),
300            font: FontId::Tiny3x5,
301            foreground: Rgb565::new(0, 50, 18),
302            text: Rgb565::WHITE,
303            accent: Rgb565::new(0, 50, 18),
304            opacity: 255,
305            corner_radius: 1,
306            shadow: Shadow::none(),
307            border: Border::one(Rgb565::new(9, 14, 14)),
308            padding: EdgeInsets::all(1),
309        }
310    }
311
312    pub const fn selected(mut self, selected: bool) -> Self {
313        if selected {
314            self.background = Some(self.accent);
315            self.border = Border::one(Rgb565::WHITE);
316        }
317        self
318    }
319
320    pub const fn with_font_id(mut self, font: FontId) -> Self {
321        self.font = font;
322        self
323    }
324
325    pub fn with_font(mut self, font: impl Into<FontId>) -> Self {
326        self.font = font.into();
327        self
328    }
329}
330
331impl Default for Style {
332    fn default() -> Self {
333        Self::new()
334    }
335}
336
337#[derive(Clone, Copy, Debug, PartialEq, Eq)]
338pub struct StateStyle {
339    pub style: Style,
340}
341
342impl StateStyle {
343    pub const fn new(style: Style) -> Self {
344        Self { style }
345    }
346}
347
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub struct WidgetStyle {
350    pub normal: Style,
351    pub focused: Style,
352    pub pressed: Style,
353    pub disabled: Style,
354}
355
356impl WidgetStyle {
357    pub const fn new(normal: Style) -> Self {
358        Self {
359            normal,
360            focused: normal.selected(true),
361            pressed: normal.selected(true),
362            disabled: Style {
363                background: normal.background,
364                gradient: normal.gradient,
365                font: normal.font,
366                foreground: Rgb565::new(8, 12, 12),
367                text: Rgb565::new(12, 18, 18),
368                accent: normal.accent,
369                opacity: 170,
370                corner_radius: normal.corner_radius,
371                shadow: normal.shadow,
372                border: normal.border,
373                padding: normal.padding,
374            },
375        }
376    }
377
378    pub const fn with_focused(mut self, focused: Style) -> Self {
379        self.focused = focused;
380        self
381    }
382
383    pub const fn with_pressed(mut self, pressed: Style) -> Self {
384        self.pressed = pressed;
385        self
386    }
387
388    pub const fn with_disabled(mut self, disabled: Style) -> Self {
389        self.disabled = disabled;
390        self
391    }
392
393    pub const fn resolve(self, state: VisualState) -> Style {
394        match state {
395            VisualState::Normal => self.normal,
396            VisualState::Focused => self.focused,
397            VisualState::Pressed => self.pressed,
398            VisualState::Disabled => self.disabled,
399        }
400    }
401
402    pub const fn with_state_override(mut self, state: VisualState, style: Style) -> Self {
403        match state {
404            VisualState::Normal => self.normal = style,
405            VisualState::Focused => self.focused = style,
406            VisualState::Pressed => self.pressed = style,
407            VisualState::Disabled => self.disabled = style,
408        }
409        self
410    }
411
412    pub fn resolve_interpolated(self, from: VisualState, to: VisualState, t: f32) -> Style {
413        let a = self.resolve(from);
414        let b = self.resolve(to);
415        lerp_style(a, b, t)
416    }
417}
418
419impl From<Style> for WidgetStyle {
420    fn from(style: Style) -> Self {
421        Self::new(style)
422    }
423}
424
425impl From<StateStyle> for WidgetStyle {
426    fn from(style: StateStyle) -> Self {
427        Self::new(style.style)
428    }
429}
430
431#[derive(Clone, Copy, Debug, PartialEq, Eq)]
432pub struct Theme {
433    pub panel: Style,
434    pub label: Style,
435    pub button: Style,
436    pub progress: Style,
437    pub toggle: Style,
438    pub checkbox: Style,
439    pub slider: Style,
440    pub value_label: Style,
441    pub icon_button: Style,
442    pub list: Style,
443    pub dialog: Style,
444    pub toast: Style,
445    pub tabs: Style,
446    pub meter: Style,
447    pub focus_ring: Rgb565,
448}
449
450impl Theme {
451    pub const fn dark() -> Self {
452        Self {
453            panel: Style::panel(),
454            label: Style::label(),
455            button: Style::button(),
456            progress: Style::progress(),
457            toggle: Style::button(),
458            checkbox: Style::button(),
459            slider: Style::button(),
460            value_label: Style::panel(),
461            icon_button: Style::button(),
462            list: Style::button(),
463            dialog: Style {
464                background: Some(Rgb565::new(5, 8, 14)),
465                gradient: Some(LinearGradient::vertical(
466                    Rgb565::new(7, 12, 18),
467                    Rgb565::new(2, 4, 8),
468                )),
469                font: FontId::Scaled6x10,
470                foreground: Rgb565::WHITE,
471                text: Rgb565::WHITE,
472                accent: Rgb565::new(31, 44, 0),
473                opacity: 255,
474                corner_radius: 3,
475                shadow: Some(Shadow {
476                    color: Rgb565::BLACK,
477                    opacity: 120,
478                    offset_x: 2,
479                    offset_y: 2,
480                    spread: 3,
481                }),
482                border: Border::one(Rgb565::WHITE),
483                padding: EdgeInsets::all(4),
484            },
485            toast: Style {
486                background: Some(Rgb565::new(8, 10, 2)),
487                gradient: Some(LinearGradient::vertical(
488                    Rgb565::new(10, 14, 4),
489                    Rgb565::new(5, 6, 1),
490                )),
491                font: FontId::Medium4x7,
492                foreground: Rgb565::WHITE,
493                text: Rgb565::WHITE,
494                accent: Rgb565::new(31, 48, 0),
495                opacity: 255,
496                corner_radius: 2,
497                shadow: Some(Shadow {
498                    color: Rgb565::BLACK,
499                    opacity: 72,
500                    offset_x: 1,
501                    offset_y: 1,
502                    spread: 1,
503                }),
504                border: Border::one(Rgb565::new(18, 22, 6)),
505                padding: EdgeInsets::symmetric(4, 2),
506            },
507            tabs: Style::button(),
508            meter: Style::progress(),
509            focus_ring: Rgb565::new(31, 56, 0),
510        }
511    }
512}
513
514impl Default for Theme {
515    fn default() -> Self {
516        Self::dark()
517    }
518}
519
520pub fn lerp_style(a: Style, b: Style, t: f32) -> Style {
521    let t = t.clamp(0.0, 1.0);
522    let blend = |c1: Rgb565, c2: Rgb565| {
523        let lerp = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
524        Rgb565::new(
525            lerp(c1.r(), c2.r()),
526            lerp(c1.g(), c2.g()),
527            lerp(c1.b(), c2.b()),
528        )
529    };
530    Style {
531        background: Some(blend(
532            a.background.unwrap_or(Rgb565::BLACK),
533            b.background.unwrap_or(Rgb565::BLACK),
534        )),
535        gradient: a.gradient.or(b.gradient),
536        font: a.font,
537        foreground: blend(a.foreground, b.foreground),
538        text: blend(a.text, b.text),
539        accent: blend(a.accent, b.accent),
540        opacity: (a.opacity as f32 + (b.opacity as f32 - a.opacity as f32) * t) as u8,
541        corner_radius: (a.corner_radius as f32
542            + (b.corner_radius as f32 - a.corner_radius as f32) * t) as u8,
543        shadow: a.shadow.or(b.shadow),
544        border: Border {
545            color: blend(a.border.color, b.border.color),
546            width: (a.border.width as f32 + (b.border.width as f32 - a.border.width as f32) * t)
547                as u8,
548        },
549        padding: a.padding,
550    }
551}
552
553pub fn lerp_theme(a: Theme, b: Theme, t: f32) -> Theme {
554    let t = t.clamp(0.0, 1.0);
555    let blend_color = |c1: Rgb565, c2: Rgb565| {
556        let lerp = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t) as u8;
557        Rgb565::new(
558            lerp(c1.r(), c2.r()),
559            lerp(c1.g(), c2.g()),
560            lerp(c1.b(), c2.b()),
561        )
562    };
563    Theme {
564        panel: lerp_style(a.panel, b.panel, t),
565        label: lerp_style(a.label, b.label, t),
566        button: lerp_style(a.button, b.button, t),
567        progress: lerp_style(a.progress, b.progress, t),
568        toggle: lerp_style(a.toggle, b.toggle, t),
569        checkbox: lerp_style(a.checkbox, b.checkbox, t),
570        slider: lerp_style(a.slider, b.slider, t),
571        value_label: lerp_style(a.value_label, b.value_label, t),
572        icon_button: lerp_style(a.icon_button, b.icon_button, t),
573        list: lerp_style(a.list, b.list, t),
574        dialog: lerp_style(a.dialog, b.dialog, t),
575        toast: lerp_style(a.toast, b.toast, t),
576        tabs: lerp_style(a.tabs, b.tabs, t),
577        meter: lerp_style(a.meter, b.meter, t),
578        focus_ring: blend_color(a.focus_ring, b.focus_ring),
579    }
580}
581
582#[derive(Clone, Copy, Debug, PartialEq)]
583pub struct StyleTransition {
584    pub from: VisualState,
585    pub to: VisualState,
586    pub animation: crate::Animation,
587}
588
589impl StyleTransition {
590    pub const fn new(
591        from: VisualState,
592        to: VisualState,
593        duration_ms: u32,
594        easing: crate::Easing,
595    ) -> Self {
596        Self {
597            from,
598            to,
599            animation: crate::Animation::new(0.0, 1.0, duration_ms, easing),
600        }
601    }
602
603    pub fn tick(&mut self, dt_ms: u32) {
604        self.animation.tick(dt_ms);
605    }
606
607    pub fn style(&self, styles: WidgetStyle) -> Style {
608        styles.resolve_interpolated(self.from, self.to, self.animation.value())
609    }
610}
611
612#[derive(Clone, Copy, Debug, PartialEq, Eq)]
613pub enum VisualState {
614    Normal,
615    Focused,
616    Pressed,
617    Disabled,
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use embedded_graphics_core::pixelcolor::WebColors;
624
625    #[test]
626    fn test_border_and_shadow_presets() {
627        let none_border = Border::none();
628        assert_eq!(none_border.width, 0);
629
630        let one_border = Border::one(Rgb565::CSS_RED);
631        assert_eq!(one_border.width, 1);
632        assert_eq!(one_border.color, Rgb565::CSS_RED);
633
634        assert_eq!(Shadow::none(), None);
635        let soft_shadow = Shadow::soft();
636        assert_eq!(soft_shadow.opacity, 96);
637        assert_eq!(soft_shadow.offset_x, 1);
638    }
639
640    #[test]
641    fn test_style_resolution_and_state_overrides() {
642        let base_style = Style {
643            background: Some(Rgb565::CSS_BLUE),
644            gradient: None,
645            font: FontId::Tiny3x5,
646            foreground: Rgb565::CSS_WHITE,
647            text: Rgb565::CSS_WHITE,
648            accent: Rgb565::CSS_RED,
649            opacity: 255,
650            corner_radius: 0,
651            shadow: Shadow::none(),
652            border: Border::none(),
653            padding: EdgeInsets::all(4),
654        };
655
656        let focused_style = Style {
657            background: Some(Rgb565::CSS_YELLOW),
658            ..base_style
659        };
660
661        let widget_style =
662            WidgetStyle::new(base_style).with_state_override(VisualState::Focused, focused_style);
663
664        assert_eq!(widget_style.resolve(VisualState::Normal), base_style);
665        assert_eq!(widget_style.resolve(VisualState::Focused), focused_style);
666        assert_eq!(
667            widget_style.resolve(VisualState::Pressed),
668            base_style.selected(true)
669        );
670    }
671
672    #[test]
673    fn test_style_lerp_and_transition() {
674        let s1 = Style {
675            background: Some(Rgb565::new(0, 0, 0)),
676            gradient: None,
677            font: FontId::Tiny3x5,
678            foreground: Rgb565::new(0, 0, 0),
679            text: Rgb565::new(0, 0, 0),
680            accent: Rgb565::new(0, 0, 0),
681            opacity: 0,
682            corner_radius: 0,
683            shadow: Shadow::none(),
684            border: Border::none(),
685            padding: EdgeInsets::all(0),
686        };
687
688        let s2 = Style {
689            background: Some(Rgb565::new(31, 63, 31)),
690            gradient: None,
691            font: FontId::Tiny3x5,
692            foreground: Rgb565::new(31, 63, 31),
693            text: Rgb565::new(31, 63, 31),
694            accent: Rgb565::new(31, 63, 31),
695            opacity: 255,
696            corner_radius: 8,
697            shadow: Shadow::none(),
698            border: Border::one(Rgb565::new(31, 63, 31)),
699            padding: EdgeInsets::all(10),
700        };
701
702        let mid = lerp_style(s1, s2, 0.5);
703        assert!((mid.background.unwrap().r() as i32 - 15).abs() <= 1);
704        assert_eq!(mid.corner_radius, 4);
705
706        let widget_style = WidgetStyle::new(s1).with_state_override(VisualState::Focused, s2);
707
708        let mut transition = StyleTransition::new(
709            VisualState::Normal,
710            VisualState::Focused,
711            100,
712            crate::Easing::Linear,
713        );
714
715        transition.tick(50);
716        let current_style = transition.style(widget_style);
717        assert_eq!(current_style.corner_radius, 4);
718    }
719}