Skip to main content

retroglyph_ui/
theme.rs

1//! [`Theme`]: named color roles for a light/dark-aware app.
2
3use retroglyph_core::color::{Color, Style};
4
5use crate::interact::Response;
6
7/// A palette of named color roles, rather than a CSS-style cascade: draw
8/// code picks the role it means (`theme.accent`, `theme.border`) and the
9/// active [`Theme`] decides what color that resolves to.
10///
11/// This crate has no opinion on *how* an app picks between
12/// [`DARK`](Self::DARK) and [`LIGHT`](Self::LIGHT) (a manual toggle key, a
13/// [`SystemTheme`](retroglyph_core::event::SystemTheme) from
14/// [`Event::ThemeChanged`](retroglyph_core::event::Event::ThemeChanged), or just
15/// always the same one): it only owns the two palettes themselves, so an
16/// app doesn't have to invent one from scratch.
17///
18/// # Examples
19///
20/// ```
21/// use retroglyph_ui::theme::Theme;
22///
23/// let theme = Theme::DARK;
24/// assert_eq!(theme.fg, Theme::DARK.fg);
25/// assert_ne!(theme.bg, Theme::LIGHT.bg);
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct Theme {
30    /// The window/screen background, behind every panel.
31    pub bg: Color,
32    /// A panel's own background, layered over `bg`.
33    pub panel_bg: Color,
34    /// Panel borders and dividers.
35    pub border: Color,
36    /// A panel title bar's background.
37    pub title_bg: Color,
38    /// Default (non-emphasized) text.
39    pub fg: Color,
40    /// Emphasis: selection, focus rings, primary actions.
41    pub accent: Color,
42    /// An interactive widget's background while hovered.
43    pub hover_bg: Color,
44    /// An interactive widget's background while pressed.
45    pub press_bg: Color,
46    /// De-emphasized text (hints, secondary labels).
47    pub dim: Color,
48    /// Foreground for a disabled interactive widget. Distinct from [`dim`](Self::dim): `dim`
49    /// marks idle-but-usable text as secondary, while `disabled` marks a widget the user
50    /// cannot interact with at all, and the two need to read differently at a glance.
51    pub disabled: Color,
52    /// Destructive/error emphasis: delete actions, validation failures, critical alerts.
53    pub danger: Color,
54    /// Positive/confirmation emphasis: completed states, successful actions.
55    pub success: Color,
56}
57
58impl Theme {
59    /// A dark palette: light text on a near-black background.
60    pub const DARK: Self = Self {
61        bg: Color::rgb(16, 16, 24),
62        panel_bg: Color::rgb(22, 22, 32),
63        border: Color::rgb(70, 74, 96),
64        title_bg: Color::rgb(30, 32, 48),
65        fg: Color::rgb(190, 192, 208),
66        accent: Color::rgb(90, 170, 250),
67        hover_bg: Color::rgb(40, 44, 64),
68        press_bg: Color::rgb(60, 110, 170),
69        dim: Color::rgb(110, 112, 130),
70        disabled: Color::rgb(80, 82, 96),
71        danger: Color::rgb(220, 90, 90),
72        success: Color::rgb(80, 200, 120),
73    };
74
75    /// A light palette: dark text on a near-white background. Same role
76    /// relationships as [`DARK`](Self::DARK) (accent stays a legible blue,
77    /// `hover_bg`/`press_bg` stay a step apart from `panel_bg`), inverted
78    /// for contrast against a light background rather than just flipping
79    /// each channel.
80    ///
81    /// Contrast is higher than a typical OS light theme:
82    /// retroglyph's pseudo-graphics (gauges, progress bars, log lines)
83    /// draw with a 2-color paletted look where every panel-bg/border/text
84    /// pair has to be distinct at a glance with no sub-pixel anti-aliasing
85    /// to soften the edges.
86    pub const LIGHT: Self = Self {
87        bg: Color::rgb(240, 240, 246),
88        panel_bg: Color::rgb(255, 255, 255),
89        border: Color::rgb(160, 164, 180),
90        title_bg: Color::rgb(224, 226, 240),
91        fg: Color::rgb(20, 22, 32),
92        accent: Color::rgb(20, 100, 210),
93        hover_bg: Color::rgb(230, 236, 248),
94        press_bg: Color::rgb(160, 194, 240),
95        dim: Color::rgb(130, 132, 150),
96        disabled: Color::rgb(190, 192, 202),
97        danger: Color::rgb(200, 60, 60),
98        success: Color::rgb(30, 140, 75),
99    };
100
101    /// The background for an interactive widget in `response`'s current state, over `base`
102    /// when idle. Delegates to [`WidgetState::of`] for the precedence order; see there for why
103    /// `disabled` now takes priority over everything else.
104    ///
105    /// `base` is caller-supplied rather than defaulted to [`panel_bg`](Self::panel_bg) so this
106    /// composes with widgets that already take a backdrop, e.g. a bar drawn over
107    /// [`title_bg`](Self::title_bg) instead of a panel.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use retroglyph_ui::theme::Theme;
113    /// use retroglyph_ui::interact::Interaction;
114    /// use retroglyph_core::grid::{Pos, Rect};
115    ///
116    /// let theme = Theme::DARK;
117    /// let mut interaction = Interaction::<u32>::default();
118    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
119    /// assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.panel_bg);
120    /// ```
121    #[must_use]
122    pub const fn bg_for<Id>(&self, response: &Response<Id>, base: Color) -> Color {
123        match WidgetState::of(response) {
124            WidgetState::Pressed => self.press_bg,
125            WidgetState::Hovered => self.hover_bg,
126            WidgetState::Disabled | WidgetState::Focused | WidgetState::Idle => base,
127        }
128    }
129
130    /// The foreground for an interactive widget in `response`'s current state. Delegates to
131    /// [`WidgetState::of`] for the precedence order.
132    ///
133    /// Idle interactive text reads as [`dim`](Self::dim) rather than [`fg`](Self::fg): an
134    /// interactive widget that looks identical to static text at rest gives no visual hint
135    /// that it's interactive at all, so the state that actually needs [`fg`](Self::fg)'s full
136    /// contrast is hover, not idle.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// use retroglyph_ui::theme::Theme;
142    /// use retroglyph_ui::interact::Interaction;
143    /// use retroglyph_core::grid::{Pos, Rect};
144    ///
145    /// let theme = Theme::DARK;
146    /// let mut interaction = Interaction::<u32>::default();
147    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
148    /// assert_eq!(theme.fg_for(&response), theme.dim);
149    /// ```
150    #[must_use]
151    pub const fn fg_for<Id>(&self, response: &Response<Id>) -> Color {
152        match WidgetState::of(response) {
153            WidgetState::Disabled => self.disabled,
154            WidgetState::Pressed | WidgetState::Focused => self.accent,
155            WidgetState::Hovered => self.fg,
156            WidgetState::Idle => self.dim,
157        }
158    }
159
160    /// The resolved [`Style`] for an interactive widget in `response`'s current state, over
161    /// `base` when idle. Delegates to [`WidgetState::of`] for the precedence order.
162    ///
163    /// Resolves both channels at once against a single, shared precedence order, rather than
164    /// composing [`bg_for`](Self::bg_for) and [`fg_for`](Self::fg_for) independently: that's the
165    /// only place a press (`accent` on `press_bg`) and a focus ring (`accent` on `base`, no
166    /// background change) can be told apart instead of both collapsing into the same `accent`
167    /// foreground.
168    ///
169    /// `base` is caller-supplied for the same reason as [`bg_for`](Self::bg_for): so this
170    /// composes with widgets that already have their own backdrop.
171    ///
172    /// This uses [`disabled`](Self::disabled) for the disabled foreground, distinct from
173    /// [`dim`](Self::dim)'s idle look.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use retroglyph_ui::theme::Theme;
179    /// use retroglyph_ui::interact::Interaction;
180    /// use retroglyph_core::grid::{Pos, Rect};
181    ///
182    /// let theme = Theme::DARK;
183    /// let mut interaction = Interaction::<u32>::default();
184    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
185    /// let style = theme.style_for(&response, theme.panel_bg);
186    /// assert_eq!(style.foreground(), theme.dim);
187    /// assert_eq!(style.background(), theme.panel_bg);
188    /// ```
189    #[must_use]
190    pub fn style_for<Id>(&self, response: &Response<Id>, base: Color) -> Style {
191        match WidgetState::of(response) {
192            WidgetState::Disabled => Style::new().fg(self.disabled).bg(base),
193            WidgetState::Pressed => Style::new().fg(self.accent).bg(self.press_bg),
194            WidgetState::Focused => Style::new().fg(self.accent).bg(base),
195            WidgetState::Hovered => Style::new().fg(self.fg).bg(self.hover_bg),
196            WidgetState::Idle => Style::new().fg(self.dim).bg(base),
197        }
198    }
199}
200
201/// The single resolved state of an interactive widget, derived from a [`Response`]'s
202/// independent flags into one precedence order.
203///
204/// Precedence: [`Disabled`](Self::Disabled), then [`Pressed`](Self::Pressed), then
205/// [`Focused`](Self::Focused), then [`Hovered`](Self::Hovered), then [`Idle`](Self::Idle).
206///
207/// [`Theme`]'s `*_for` methods all derive this once via [`of`](Self::of) rather than each
208/// re-deriving their own precedence chain from [`Response`]'s flags, which is how `bg_for`/
209/// `fg_for` and `style_for` used to disagree on whether `disabled` mattered at all.
210///
211/// `#[non_exhaustive]`: widget state may grow further variants (e.g. a `Selected` state) without
212/// that being a breaking change for callers who already match with a wildcard arm.
213#[non_exhaustive]
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215pub enum WidgetState {
216    /// [`Response::disabled`] is `true`, regardless of any other flag.
217    Disabled,
218    /// [`Response::pressed`] is `true` and the widget isn't disabled.
219    Pressed,
220    /// [`Response::focused`] is `true` and the widget is neither disabled nor pressed.
221    Focused,
222    /// [`Response::hovered`] is `true` and the widget is neither disabled, pressed, nor focused.
223    Hovered,
224    /// None of the above: the widget's resting state.
225    Idle,
226}
227
228impl WidgetState {
229    /// Derives the single resolved state from `response`'s independent flags.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// use retroglyph_ui::theme::WidgetState;
235    /// use retroglyph_ui::interact::Interaction;
236    /// use retroglyph_core::grid::Rect;
237    ///
238    /// let mut interaction = Interaction::<u32>::default();
239    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
240    /// assert_eq!(WidgetState::of(&response), WidgetState::Idle);
241    /// ```
242    #[must_use]
243    pub const fn of<Id>(response: &Response<Id>) -> Self {
244        if response.disabled() {
245            Self::Disabled
246        } else if response.pressed() {
247            Self::Pressed
248        } else if response.focused() {
249            Self::Focused
250        } else if response.hovered() {
251            Self::Hovered
252        } else {
253            Self::Idle
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn dark_and_light_are_distinct() {
264        assert_ne!(Theme::DARK, Theme::LIGHT);
265    }
266
267    #[cfg(feature = "serde")]
268    #[test]
269    fn serializes_and_deserializes() {
270        let json = serde_json::to_string(&Theme::DARK).expect("serialize");
271        let round_tripped: Theme = serde_json::from_str(&json).expect("deserialize");
272        assert_eq!(round_tripped, Theme::DARK);
273    }
274
275    #[test]
276    fn bg_for_is_base_when_idle() {
277        let theme = Theme::DARK;
278        let response: Response<()> = Response::default();
279        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.panel_bg);
280    }
281
282    #[test]
283    fn bg_for_is_hover_bg_when_hovered() {
284        let theme = Theme::DARK;
285        let response: Response<()> = Response {
286            hovered: true,
287            ..Response::default()
288        };
289        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.hover_bg);
290    }
291
292    #[test]
293    fn bg_for_prefers_press_bg_over_hover_bg() {
294        let theme = Theme::DARK;
295        let response: Response<()> = Response {
296            hovered: true,
297            pressed: true,
298            ..Response::default()
299        };
300        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.press_bg);
301    }
302
303    #[test]
304    fn bg_for_is_base_when_disabled_even_if_pressed() {
305        let theme = Theme::DARK;
306        let response: Response<()> = Response {
307            disabled: true,
308            pressed: true,
309            ..Response::default()
310        };
311        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.panel_bg);
312    }
313
314    #[test]
315    fn fg_for_is_dim_when_idle() {
316        let theme = Theme::DARK;
317        let response: Response<()> = Response::default();
318        assert_eq!(theme.fg_for(&response), theme.dim);
319    }
320
321    #[test]
322    fn fg_for_is_fg_when_hovered() {
323        let theme = Theme::DARK;
324        let response: Response<()> = Response {
325            hovered: true,
326            ..Response::default()
327        };
328        assert_eq!(theme.fg_for(&response), theme.fg);
329    }
330
331    #[test]
332    fn fg_for_prefers_accent_over_hover_when_focused() {
333        let theme = Theme::DARK;
334        let response: Response<()> = Response {
335            hovered: true,
336            focused: true,
337            ..Response::default()
338        };
339        assert_eq!(theme.fg_for(&response), theme.accent);
340    }
341
342    #[test]
343    fn fg_for_prefers_accent_over_hover_when_pressed() {
344        let theme = Theme::DARK;
345        let response: Response<()> = Response {
346            hovered: true,
347            pressed: true,
348            ..Response::default()
349        };
350        assert_eq!(theme.fg_for(&response), theme.accent);
351    }
352
353    #[test]
354    fn fg_for_is_disabled_even_if_pressed() {
355        let theme = Theme::DARK;
356        let response: Response<()> = Response {
357            disabled: true,
358            pressed: true,
359            ..Response::default()
360        };
361        assert_eq!(theme.fg_for(&response), theme.disabled);
362    }
363
364    #[test]
365    fn widget_state_of_precedence_disabled_over_everything() {
366        let response: Response<()> = Response {
367            disabled: true,
368            pressed: true,
369            focused: true,
370            hovered: true,
371            ..Response::default()
372        };
373        assert_eq!(WidgetState::of(&response), WidgetState::Disabled);
374    }
375
376    #[test]
377    fn widget_state_of_is_idle_by_default() {
378        let response: Response<()> = Response::default();
379        assert_eq!(WidgetState::of(&response), WidgetState::Idle);
380    }
381
382    #[test]
383    fn style_for_is_dim_on_base_when_idle() {
384        let theme = Theme::DARK;
385        let response: Response<()> = Response::default();
386        let style = theme.style_for(&response, theme.panel_bg);
387        assert_eq!(style.foreground(), theme.dim);
388        assert_eq!(style.background(), theme.panel_bg);
389    }
390
391    #[test]
392    fn style_for_is_disabled_on_base_when_disabled() {
393        let theme = Theme::DARK;
394        let response: Response<()> = Response {
395            disabled: true,
396            ..Response::default()
397        };
398        let style = theme.style_for(&response, theme.panel_bg);
399        assert_eq!(style.foreground(), theme.disabled);
400        assert_eq!(style.background(), theme.panel_bg);
401    }
402
403    #[test]
404    fn style_for_is_fg_on_hover_bg_when_hovered() {
405        let theme = Theme::DARK;
406        let response: Response<()> = Response {
407            hovered: true,
408            ..Response::default()
409        };
410        let style = theme.style_for(&response, theme.panel_bg);
411        assert_eq!(style.foreground(), theme.fg);
412        assert_eq!(style.background(), theme.hover_bg);
413    }
414
415    #[test]
416    fn style_for_is_accent_on_base_when_focused_and_not_hovered() {
417        let theme = Theme::DARK;
418        let response: Response<()> = Response {
419            focused: true,
420            ..Response::default()
421        };
422        let style = theme.style_for(&response, theme.panel_bg);
423        assert_eq!(style.foreground(), theme.accent);
424        assert_eq!(style.background(), theme.panel_bg);
425    }
426
427    #[test]
428    fn style_for_prefers_focused_over_hovered() {
429        let theme = Theme::DARK;
430        let response: Response<()> = Response {
431            hovered: true,
432            focused: true,
433            ..Response::default()
434        };
435        let style = theme.style_for(&response, theme.panel_bg);
436        assert_eq!(style.foreground(), theme.accent);
437        assert_eq!(style.background(), theme.panel_bg);
438    }
439
440    #[test]
441    fn style_for_is_accent_on_press_bg_when_pressed() {
442        let theme = Theme::DARK;
443        let response: Response<()> = Response {
444            pressed: true,
445            focused: true,
446            hovered: true,
447            ..Response::default()
448        };
449        let style = theme.style_for(&response, theme.panel_bg);
450        assert_eq!(style.foreground(), theme.accent);
451        assert_eq!(style.background(), theme.press_bg);
452    }
453
454    #[test]
455    fn style_for_prefers_disabled_over_everything_else() {
456        let theme = Theme::DARK;
457        let response: Response<()> = Response {
458            disabled: true,
459            pressed: true,
460            focused: true,
461            hovered: true,
462            ..Response::default()
463        };
464        let style = theme.style_for(&response, theme.panel_bg);
465        assert_eq!(style.foreground(), theme.disabled);
466        assert_eq!(style.background(), theme.panel_bg);
467    }
468
469    #[test]
470    fn disabled_is_distinct_from_dim() {
471        assert_ne!(Theme::DARK.disabled, Theme::DARK.dim);
472        assert_ne!(Theme::LIGHT.disabled, Theme::LIGHT.dim);
473    }
474
475    #[test]
476    fn danger_and_success_are_distinct_per_theme() {
477        assert_ne!(Theme::DARK.danger, Theme::DARK.success);
478        assert_ne!(Theme::LIGHT.danger, Theme::LIGHT.success);
479    }
480
481    #[test]
482    fn dark_background_is_darker_than_light_background() {
483        let Color::Rgb {
484            r: dr,
485            g: dg,
486            b: db,
487        } = Theme::DARK.bg
488        else {
489            unreachable!()
490        };
491        let Color::Rgb {
492            r: lr,
493            g: lg,
494            b: lb,
495        } = Theme::LIGHT.bg
496        else {
497            unreachable!()
498        };
499        let dark_luma = u32::from(dr) + u32::from(dg) + u32::from(db);
500        let light_luma = u32::from(lr) + u32::from(lg) + u32::from(lb);
501        assert!(dark_luma < light_luma);
502    }
503}