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}