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::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;
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, disabled-looking text).
47    pub dim: Color,
48}
49
50impl Theme {
51    /// A dark palette: light text on a near-black background.
52    pub const DARK: Self = Self {
53        bg: Color::Rgb {
54            r: 16,
55            g: 16,
56            b: 24,
57        },
58        panel_bg: Color::Rgb {
59            r: 22,
60            g: 22,
61            b: 32,
62        },
63        border: Color::Rgb {
64            r: 70,
65            g: 74,
66            b: 96,
67        },
68        title_bg: Color::Rgb {
69            r: 30,
70            g: 32,
71            b: 48,
72        },
73        fg: Color::Rgb {
74            r: 190,
75            g: 192,
76            b: 208,
77        },
78        accent: Color::Rgb {
79            r: 90,
80            g: 170,
81            b: 250,
82        },
83        hover_bg: Color::Rgb {
84            r: 40,
85            g: 44,
86            b: 64,
87        },
88        press_bg: Color::Rgb {
89            r: 60,
90            g: 110,
91            b: 170,
92        },
93        dim: Color::Rgb {
94            r: 110,
95            g: 112,
96            b: 130,
97        },
98    };
99
100    /// A light palette: dark text on a near-white background. Same role
101    /// relationships as [`DARK`](Self::DARK) (accent stays a legible blue,
102    /// `hover_bg`/`press_bg` stay a step apart from `panel_bg`), inverted
103    /// for contrast against a light background rather than just flipping
104    /// each channel.
105    ///
106    /// Contrast is higher than a typical OS light theme:
107    /// retroglyph's pseudo-graphics (gauges, progress bars, log lines)
108    /// draw with a 2-color paletted look where every panel-bg/border/text
109    /// pair has to be distinct at a glance with no sub-pixel anti-aliasing
110    /// to soften the edges.
111    pub const LIGHT: Self = Self {
112        bg: Color::Rgb {
113            r: 240,
114            g: 240,
115            b: 246,
116        },
117        panel_bg: Color::Rgb {
118            r: 255,
119            g: 255,
120            b: 255,
121        },
122        border: Color::Rgb {
123            r: 160,
124            g: 164,
125            b: 180,
126        },
127        title_bg: Color::Rgb {
128            r: 224,
129            g: 226,
130            b: 240,
131        },
132        fg: Color::Rgb {
133            r: 20,
134            g: 22,
135            b: 32,
136        },
137        accent: Color::Rgb {
138            r: 20,
139            g: 100,
140            b: 210,
141        },
142        hover_bg: Color::Rgb {
143            r: 230,
144            g: 236,
145            b: 248,
146        },
147        press_bg: Color::Rgb {
148            r: 160,
149            g: 194,
150            b: 240,
151        },
152        dim: Color::Rgb {
153            r: 130,
154            g: 132,
155            b: 150,
156        },
157    };
158
159    /// The background for an interactive widget in `response`'s current state, over `base`
160    /// when idle. Precedence: [`pressed`](Response::pressed), then
161    /// [`hovered`](Response::hovered), then `base`.
162    ///
163    /// `base` is caller-supplied rather than defaulted to [`panel_bg`](Self::panel_bg) so this
164    /// composes with widgets that already take a backdrop, e.g. a bar drawn over
165    /// [`title_bg`](Self::title_bg) instead of a panel.
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use retroglyph_ui::Theme;
171    /// use retroglyph_ui::Interaction;
172    /// use retroglyph_core::grid::{Pos, Rect};
173    ///
174    /// let theme = Theme::DARK;
175    /// let mut interaction = Interaction::<u32>::default();
176    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
177    /// assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.panel_bg);
178    /// ```
179    #[must_use]
180    pub const fn bg_for<Id>(&self, response: &Response<Id>, base: Color) -> Color {
181        if response.pressed() {
182            self.press_bg
183        } else if response.hovered() {
184            self.hover_bg
185        } else {
186            base
187        }
188    }
189
190    /// The foreground for an interactive widget in `response`'s current state. Precedence:
191    /// [`pressed`](Response::pressed) or [`focused`](Response::focused), then
192    /// [`hovered`](Response::hovered), then [`dim`](Self::dim).
193    ///
194    /// Idle interactive text reads as [`dim`](Self::dim) rather than [`fg`](Self::fg): an
195    /// interactive widget that looks identical to static text at rest gives no visual hint
196    /// that it's interactive at all, so the state that actually needs [`fg`](Self::fg)'s full
197    /// contrast is hover, not idle.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// use retroglyph_ui::Theme;
203    /// use retroglyph_ui::Interaction;
204    /// use retroglyph_core::grid::{Pos, Rect};
205    ///
206    /// let theme = Theme::DARK;
207    /// let mut interaction = Interaction::<u32>::default();
208    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
209    /// assert_eq!(theme.fg_for(&response), theme.dim);
210    /// ```
211    #[must_use]
212    pub const fn fg_for<Id>(&self, response: &Response<Id>) -> Color {
213        if response.pressed() || response.focused() {
214            self.accent
215        } else if response.hovered() {
216            self.fg
217        } else {
218            self.dim
219        }
220    }
221
222    /// The resolved [`Style`] for an interactive widget in `response`'s current state, over
223    /// `base` when idle. Precedence: [`disabled`](Response::disabled), then
224    /// [`pressed`](Response::pressed), then [`focused`](Response::focused), then
225    /// [`hovered`](Response::hovered), then idle.
226    ///
227    /// Unlike [`bg_for`](Self::bg_for)/[`fg_for`](Self::fg_for), which resolve each channel
228    /// independently, `style_for` resolves both at once against a single, shared precedence
229    /// order: that's the only place `disabled` can cleanly take priority over everything else,
230    /// and the only place a press (`accent` on `press_bg`) and a focus ring (`accent` on `base`,
231    /// no background change) can be told apart instead of both collapsing into the same `accent`
232    /// foreground.
233    ///
234    /// `base` is caller-supplied for the same reason as [`bg_for`](Self::bg_for): so this
235    /// composes with widgets that already have their own backdrop.
236    ///
237    /// This uses [`dim`](Self::dim) for the disabled foreground, same as idle; retroglyph has
238    /// no separate disabled color role yet.
239    ///
240    /// # Examples
241    ///
242    /// ```
243    /// use retroglyph_ui::Theme;
244    /// use retroglyph_ui::Interaction;
245    /// use retroglyph_core::grid::{Pos, Rect};
246    ///
247    /// let theme = Theme::DARK;
248    /// let mut interaction = Interaction::<u32>::default();
249    /// let response = interaction.interact(Rect::new(0, 0, 1, 1), 1, Default::default());
250    /// let style = theme.style_for(&response, theme.panel_bg);
251    /// assert_eq!(style.foreground(), theme.dim);
252    /// assert_eq!(style.background(), theme.panel_bg);
253    /// ```
254    #[must_use]
255    pub fn style_for<Id>(&self, response: &Response<Id>, base: Color) -> Style {
256        if response.disabled() {
257            return Style::new().fg(self.dim).bg(base);
258        }
259        if response.pressed() {
260            return Style::new().fg(self.accent).bg(self.press_bg);
261        }
262        if response.focused() {
263            return Style::new().fg(self.accent).bg(base);
264        }
265        if response.hovered() {
266            return Style::new().fg(self.fg).bg(self.hover_bg);
267        }
268        Style::new().fg(self.dim).bg(base)
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn dark_and_light_are_distinct() {
278        assert_ne!(Theme::DARK, Theme::LIGHT);
279    }
280
281    #[cfg(feature = "serde")]
282    #[test]
283    fn serializes_and_deserializes() {
284        let json = serde_json::to_string(&Theme::DARK).expect("serialize");
285        let round_tripped: Theme = serde_json::from_str(&json).expect("deserialize");
286        assert_eq!(round_tripped, Theme::DARK);
287    }
288
289    #[test]
290    fn bg_for_is_base_when_idle() {
291        let theme = Theme::DARK;
292        let response: Response<()> = Response::default();
293        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.panel_bg);
294    }
295
296    #[test]
297    fn bg_for_is_hover_bg_when_hovered() {
298        let theme = Theme::DARK;
299        let response: Response<()> = Response {
300            hovered: true,
301            ..Response::default()
302        };
303        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.hover_bg);
304    }
305
306    #[test]
307    fn bg_for_prefers_press_bg_over_hover_bg() {
308        let theme = Theme::DARK;
309        let response: Response<()> = Response {
310            hovered: true,
311            pressed: true,
312            ..Response::default()
313        };
314        assert_eq!(theme.bg_for(&response, theme.panel_bg), theme.press_bg);
315    }
316
317    #[test]
318    fn fg_for_is_dim_when_idle() {
319        let theme = Theme::DARK;
320        let response: Response<()> = Response::default();
321        assert_eq!(theme.fg_for(&response), theme.dim);
322    }
323
324    #[test]
325    fn fg_for_is_fg_when_hovered() {
326        let theme = Theme::DARK;
327        let response: Response<()> = Response {
328            hovered: true,
329            ..Response::default()
330        };
331        assert_eq!(theme.fg_for(&response), theme.fg);
332    }
333
334    #[test]
335    fn fg_for_prefers_accent_over_hover_when_focused() {
336        let theme = Theme::DARK;
337        let response: Response<()> = Response {
338            hovered: true,
339            focused: true,
340            ..Response::default()
341        };
342        assert_eq!(theme.fg_for(&response), theme.accent);
343    }
344
345    #[test]
346    fn fg_for_prefers_accent_over_hover_when_pressed() {
347        let theme = Theme::DARK;
348        let response: Response<()> = Response {
349            hovered: true,
350            pressed: true,
351            ..Response::default()
352        };
353        assert_eq!(theme.fg_for(&response), theme.accent);
354    }
355
356    #[test]
357    fn style_for_is_dim_on_base_when_idle() {
358        let theme = Theme::DARK;
359        let response: Response<()> = Response::default();
360        let style = theme.style_for(&response, theme.panel_bg);
361        assert_eq!(style.foreground(), theme.dim);
362        assert_eq!(style.background(), theme.panel_bg);
363    }
364
365    #[test]
366    fn style_for_is_fg_on_hover_bg_when_hovered() {
367        let theme = Theme::DARK;
368        let response: Response<()> = Response {
369            hovered: true,
370            ..Response::default()
371        };
372        let style = theme.style_for(&response, theme.panel_bg);
373        assert_eq!(style.foreground(), theme.fg);
374        assert_eq!(style.background(), theme.hover_bg);
375    }
376
377    #[test]
378    fn style_for_is_accent_on_base_when_focused_and_not_hovered() {
379        let theme = Theme::DARK;
380        let response: Response<()> = Response {
381            focused: true,
382            ..Response::default()
383        };
384        let style = theme.style_for(&response, theme.panel_bg);
385        assert_eq!(style.foreground(), theme.accent);
386        assert_eq!(style.background(), theme.panel_bg);
387    }
388
389    #[test]
390    fn style_for_prefers_focused_over_hovered() {
391        let theme = Theme::DARK;
392        let response: Response<()> = Response {
393            hovered: true,
394            focused: true,
395            ..Response::default()
396        };
397        let style = theme.style_for(&response, theme.panel_bg);
398        assert_eq!(style.foreground(), theme.accent);
399        assert_eq!(style.background(), theme.panel_bg);
400    }
401
402    #[test]
403    fn style_for_is_accent_on_press_bg_when_pressed() {
404        let theme = Theme::DARK;
405        let response: Response<()> = Response {
406            pressed: true,
407            focused: true,
408            hovered: true,
409            ..Response::default()
410        };
411        let style = theme.style_for(&response, theme.panel_bg);
412        assert_eq!(style.foreground(), theme.accent);
413        assert_eq!(style.background(), theme.press_bg);
414    }
415
416    #[test]
417    fn style_for_prefers_disabled_over_everything_else() {
418        let theme = Theme::DARK;
419        let response: Response<()> = Response {
420            disabled: true,
421            pressed: true,
422            focused: true,
423            hovered: true,
424            ..Response::default()
425        };
426        let style = theme.style_for(&response, theme.panel_bg);
427        assert_eq!(style.foreground(), theme.dim);
428        assert_eq!(style.background(), theme.panel_bg);
429    }
430
431    #[test]
432    fn dark_background_is_darker_than_light_background() {
433        let Color::Rgb {
434            r: dr,
435            g: dg,
436            b: db,
437        } = Theme::DARK.bg
438        else {
439            unreachable!()
440        };
441        let Color::Rgb {
442            r: lr,
443            g: lg,
444            b: lb,
445        } = Theme::LIGHT.bg
446        else {
447            unreachable!()
448        };
449        let dark_luma = u32::from(dr) + u32::from(dg) + u32::from(db);
450        let light_luma = u32::from(lr) + u32::from(lg) + u32::from(lb);
451        assert!(dark_luma < light_luma);
452    }
453}