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/// Targetable sub-component part of a widget.
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum WidgetPart {
623    Main,
624    Indicator,
625    Knob,
626    Scrollbar,
627    Custom(u8),
628}
629
630/// Bitmask representing active visual states (supports combining states like CHECKED | PRESSED).
631#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
632pub struct VisualStateMask(pub u8);
633
634impl VisualStateMask {
635    pub const NORMAL: Self = Self(1 << 0);
636    pub const FOCUSED: Self = Self(1 << 1);
637    pub const PRESSED: Self = Self(1 << 2);
638    pub const DISABLED: Self = Self(1 << 3);
639    pub const CHECKED: Self = Self(1 << 4);
640
641    pub const fn empty() -> Self {
642        Self(0)
643    }
644
645    pub const fn contains(self, other: Self) -> bool {
646        (self.0 & other.0) == other.0
647    }
648
649    pub const fn with(self, other: Self) -> Self {
650        Self(self.0 | other.0)
651    }
652
653    pub const fn from_visual_state(state: VisualState) -> Self {
654        match state {
655            VisualState::Normal => Self::NORMAL,
656            VisualState::Focused => Self::FOCUSED,
657            VisualState::Pressed => Self::PRESSED,
658            VisualState::Disabled => Self::DISABLED,
659        }
660    }
661}
662
663/// A cascading rule that applies a style to a specific widget part under matching visual states.
664#[derive(Clone, Copy, Debug, PartialEq, Eq)]
665pub struct PartStyleRule {
666    pub part: WidgetPart,
667    pub state_mask: VisualStateMask,
668    pub style: Style,
669}
670
671/// A multi-part style descriptor managing distinct visual rules for parts of a compound widget.
672#[derive(Clone, Copy, Debug, PartialEq, Eq)]
673pub struct MultiPartStyle<const RULES: usize = 4> {
674    pub base_style: Style,
675    rules: [Option<PartStyleRule>; RULES],
676}
677
678impl<const RULES: usize> Default for MultiPartStyle<RULES> {
679    fn default() -> Self {
680        Self::new(Style::new())
681    }
682}
683
684impl<const RULES: usize> MultiPartStyle<RULES> {
685    pub const fn new(base_style: Style) -> Self {
686        Self {
687            base_style,
688            rules: [None; RULES],
689        }
690    }
691
692    pub const fn with_part_rule(
693        mut self,
694        part: WidgetPart,
695        state_mask: VisualStateMask,
696        style: Style,
697    ) -> Self {
698        let mut i = 0;
699        while i < RULES {
700            if self.rules[i].is_none() {
701                self.rules[i] = Some(PartStyleRule {
702                    part,
703                    state_mask,
704                    style,
705                });
706                return self;
707            }
708            i += 1;
709        }
710        self
711    }
712
713    pub fn resolve(&self, part: WidgetPart, state: VisualState) -> Style {
714        let mask = VisualStateMask::from_visual_state(state);
715        self.resolve_mask(part, mask)
716    }
717
718    pub fn resolve_mask(&self, part: WidgetPart, state_mask: VisualStateMask) -> Style {
719        for rule in self.rules.iter().flatten() {
720            if rule.part == part && (rule.state_mask.0 == 0 || rule.state_mask.contains(state_mask))
721            {
722                return rule.style;
723            }
724        }
725        self.base_style
726    }
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732    use embedded_graphics_core::pixelcolor::WebColors;
733
734    #[test]
735    fn test_border_and_shadow_presets() {
736        let none_border = Border::none();
737        assert_eq!(none_border.width, 0);
738
739        let one_border = Border::one(Rgb565::CSS_RED);
740        assert_eq!(one_border.width, 1);
741        assert_eq!(one_border.color, Rgb565::CSS_RED);
742
743        assert_eq!(Shadow::none(), None);
744        let soft_shadow = Shadow::soft();
745        assert_eq!(soft_shadow.opacity, 96);
746        assert_eq!(soft_shadow.offset_x, 1);
747    }
748
749    #[test]
750    fn test_style_resolution_and_state_overrides() {
751        let base_style = Style {
752            background: Some(Rgb565::CSS_BLUE),
753            gradient: None,
754            font: FontId::Tiny3x5,
755            foreground: Rgb565::CSS_WHITE,
756            text: Rgb565::CSS_WHITE,
757            accent: Rgb565::CSS_RED,
758            opacity: 255,
759            corner_radius: 0,
760            shadow: Shadow::none(),
761            border: Border::none(),
762            padding: EdgeInsets::all(4),
763        };
764
765        let focused_style = Style {
766            background: Some(Rgb565::CSS_YELLOW),
767            ..base_style
768        };
769
770        let widget_style =
771            WidgetStyle::new(base_style).with_state_override(VisualState::Focused, focused_style);
772
773        assert_eq!(widget_style.resolve(VisualState::Normal), base_style);
774        assert_eq!(widget_style.resolve(VisualState::Focused), focused_style);
775        assert_eq!(
776            widget_style.resolve(VisualState::Pressed),
777            base_style.selected(true)
778        );
779    }
780
781    #[test]
782    fn test_style_lerp_and_transition() {
783        let s1 = Style {
784            background: Some(Rgb565::new(0, 0, 0)),
785            gradient: None,
786            font: FontId::Tiny3x5,
787            foreground: Rgb565::new(0, 0, 0),
788            text: Rgb565::new(0, 0, 0),
789            accent: Rgb565::new(0, 0, 0),
790            opacity: 0,
791            corner_radius: 0,
792            shadow: Shadow::none(),
793            border: Border::none(),
794            padding: EdgeInsets::all(0),
795        };
796
797        let s2 = Style {
798            background: Some(Rgb565::new(31, 63, 31)),
799            gradient: None,
800            font: FontId::Tiny3x5,
801            foreground: Rgb565::new(31, 63, 31),
802            text: Rgb565::new(31, 63, 31),
803            accent: Rgb565::new(31, 63, 31),
804            opacity: 255,
805            corner_radius: 8,
806            shadow: Shadow::none(),
807            border: Border::one(Rgb565::new(31, 63, 31)),
808            padding: EdgeInsets::all(10),
809        };
810
811        let mid = lerp_style(s1, s2, 0.5);
812        assert!((mid.background.unwrap().r() as i32 - 15).abs() <= 1);
813        assert_eq!(mid.corner_radius, 4);
814
815        let widget_style = WidgetStyle::new(s1).with_state_override(VisualState::Focused, s2);
816
817        let mut transition = StyleTransition::new(
818            VisualState::Normal,
819            VisualState::Focused,
820            100,
821            crate::Easing::Linear,
822        );
823
824        transition.tick(50);
825        let current_style = transition.style(widget_style);
826        assert_eq!(current_style.corner_radius, 4);
827    }
828
829    #[test]
830    fn test_multipart_style_resolution() {
831        let base_style = Style::new();
832        let knob_style = Style {
833            corner_radius: 10,
834            ..base_style
835        };
836        let indicator_pressed_style = Style {
837            corner_radius: 5,
838            ..base_style
839        };
840
841        let multipart = MultiPartStyle::<4>::new(base_style)
842            .with_part_rule(WidgetPart::Knob, VisualStateMask::empty(), knob_style)
843            .with_part_rule(
844                WidgetPart::Indicator,
845                VisualStateMask::PRESSED,
846                indicator_pressed_style,
847            );
848
849        // Knob gets knob_style regardless of normal state because state_mask is empty/wildcard
850        assert_eq!(
851            multipart.resolve(WidgetPart::Knob, VisualState::Normal),
852            knob_style
853        );
854
855        // Indicator in Normal state falls back to base_style
856        assert_eq!(
857            multipart.resolve(WidgetPart::Indicator, VisualState::Normal),
858            base_style
859        );
860
861        // Indicator in Pressed state resolves to indicator_pressed_style
862        assert_eq!(
863            multipart.resolve(WidgetPart::Indicator, VisualState::Pressed),
864            indicator_pressed_style
865        );
866    }
867}