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}