Skip to main content

agg_gui/
theme.rs

1//! Theme system — dark / light mode colour palettes.
2//!
3//! # Overview
4//!
5//! [`Visuals`] holds every colour used by the widget library.  Two built-in
6//! palettes are provided via [`Visuals::dark`] and [`Visuals::light`].
7//!
8//! The *current* visuals are stored in a thread-local so widgets can access
9//! them from `paint()` without an extra parameter.  Call [`set_visuals`] once
10//! per frame (before painting) to apply a palette; call [`current_visuals`] to
11//! read it from inside a widget.
12//!
13//! [`DrawCtx::visuals()`](crate::draw_ctx::DrawCtx::visuals) is a convenience
14//! that delegates to [`current_visuals`], so widget paint methods only need
15//! `ctx.visuals()`.
16
17use std::cell::RefCell;
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use crate::color::Color;
21
22// ---------------------------------------------------------------------------
23// Theme preference
24// ---------------------------------------------------------------------------
25
26/// User preference for which palette to apply.
27#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
28pub enum ThemePreference {
29    #[default]
30    Dark,
31    Light,
32    /// Follow the OS setting.  Unimplemented for now — falls back to `Dark`.
33    System,
34}
35
36impl ThemePreference {
37    pub fn key(self) -> &'static str {
38        match self {
39            ThemePreference::Dark => "dark",
40            ThemePreference::Light => "light",
41            ThemePreference::System => "system",
42        }
43    }
44
45    pub fn from_key(key: &str) -> Option<Self> {
46        match key {
47            "dark" => Some(ThemePreference::Dark),
48            "light" => Some(ThemePreference::Light),
49            "system" => Some(ThemePreference::System),
50            _ => None,
51        }
52    }
53}
54
55/// Built-in accent swatches exposed by the demo and usable by hosts.
56#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
57pub enum AccentColor {
58    #[default]
59    Blue,
60    Purple,
61    Pink,
62    Red,
63    Orange,
64    Yellow,
65    Green,
66    Teal,
67}
68
69impl AccentColor {
70    pub const ALL: [AccentColor; 8] = [
71        AccentColor::Blue,
72        AccentColor::Purple,
73        AccentColor::Pink,
74        AccentColor::Red,
75        AccentColor::Orange,
76        AccentColor::Yellow,
77        AccentColor::Green,
78        AccentColor::Teal,
79    ];
80
81    pub fn color(self) -> Color {
82        match self {
83            AccentColor::Blue => Color::rgb(0.22, 0.45, 0.88),
84            AccentColor::Purple => Color::rgb(0.48, 0.36, 0.86),
85            AccentColor::Pink => Color::rgb(0.78, 0.28, 0.58),
86            AccentColor::Red => Color::rgb(0.82, 0.24, 0.24),
87            AccentColor::Orange => Color::rgb(0.90, 0.46, 0.18),
88            AccentColor::Yellow => Color::rgb(0.82, 0.62, 0.16),
89            AccentColor::Green => Color::rgb(0.20, 0.62, 0.34),
90            AccentColor::Teal => Color::rgb(0.14, 0.62, 0.66),
91        }
92    }
93
94    pub fn label(self) -> &'static str {
95        match self {
96            AccentColor::Blue => "Blue",
97            AccentColor::Purple => "Purple",
98            AccentColor::Pink => "Pink",
99            AccentColor::Red => "Red",
100            AccentColor::Orange => "Orange",
101            AccentColor::Yellow => "Yellow",
102            AccentColor::Green => "Green",
103            AccentColor::Teal => "Teal",
104        }
105    }
106
107    pub fn key(self) -> &'static str {
108        match self {
109            AccentColor::Blue => "blue",
110            AccentColor::Purple => "purple",
111            AccentColor::Pink => "pink",
112            AccentColor::Red => "red",
113            AccentColor::Orange => "orange",
114            AccentColor::Yellow => "yellow",
115            AccentColor::Green => "green",
116            AccentColor::Teal => "teal",
117        }
118    }
119
120    pub fn from_key(key: &str) -> Option<Self> {
121        match key {
122            "blue" => Some(AccentColor::Blue),
123            "purple" => Some(AccentColor::Purple),
124            "pink" => Some(AccentColor::Pink),
125            "red" => Some(AccentColor::Red),
126            "orange" => Some(AccentColor::Orange),
127            "yellow" => Some(AccentColor::Yellow),
128            "green" => Some(AccentColor::Green),
129            "teal" => Some(AccentColor::Teal),
130            _ => None,
131        }
132    }
133}
134
135// ---------------------------------------------------------------------------
136// Visuals (complete colour palette)
137// ---------------------------------------------------------------------------
138
139/// All colours used by the widget library.
140///
141/// The canonical way to access the active palette inside `Widget::paint` is:
142/// ```ignore
143/// let v = ctx.visuals();
144/// ctx.set_fill_color(v.window_fill);
145/// ```
146#[derive(Clone, Debug)]
147pub struct Visuals {
148    // ── Chrome ────────────────────────────────────────────────────────────────
149    /// Canvas / app background (behind all floating windows).
150    pub bg_color: Color,
151    /// Sidebar / panel background.
152    pub panel_fill: Color,
153    /// Top menu bar background.
154    pub top_bar_bg: Color,
155
156    // ── Floating window ───────────────────────────────────────────────────────
157    /// Window content-area background.
158    pub window_fill: Color,
159    /// Window title bar background (idle).
160    pub window_title_fill: Color,
161    /// Window title bar background while dragging.
162    pub window_title_fill_drag: Color,
163    /// Drop-shadow colour (semi-transparent black/dark).
164    pub window_shadow: Color,
165    /// Thin border drawn around the window.
166    pub window_stroke: Color,
167    /// Title bar text colour.
168    pub window_title_text: Color,
169    /// Close button background (idle).
170    pub window_close_bg: Color,
171    /// Close button background (hovered).
172    pub window_close_bg_hovered: Color,
173    /// Close button × glyph colour.
174    pub window_close_fg: Color,
175    /// Resize edge / corner highlight colour when hovered (not yet dragging).
176    pub window_resize_hover: Color,
177    /// Resize edge / corner highlight colour while actively dragging to resize.
178    pub window_resize_active: Color,
179
180    // ── Text ──────────────────────────────────────────────────────────────────
181    /// Body text colour.
182    pub text_color: Color,
183    /// Secondary / dimmed text (hints, labels).
184    pub text_dim: Color,
185    /// Hyperlink colour (idle).
186    pub text_link: Color,
187    /// Hyperlink colour (hovered).
188    pub text_link_hovered: Color,
189
190    // ── Accent / primary action colour ────────────────────────────────────────
191    /// Used for checked states, active tabs, slider fill, button backgrounds.
192    pub accent: Color,
193    /// Accent colour when hovered.
194    pub accent_hovered: Color,
195    /// Accent colour when pressed / active.
196    pub accent_pressed: Color,
197    /// Low-opacity accent used for focus rings.
198    pub accent_focus: Color,
199
200    // ── Interactive widgets (checkbox, radio, drag-value, …) ──────────────────
201    /// Widget background when unchecked / idle.
202    pub widget_bg: Color,
203    /// Widget background when hovered (unchecked).
204    pub widget_bg_hovered: Color,
205    /// Widget border / outline (unchecked).
206    pub widget_stroke: Color,
207    /// Widget border / outline (checked / active).
208    pub widget_stroke_active: Color,
209
210    // ── Slider / progress bar track ───────────────────────────────────────────
211    pub track_bg: Color,
212
213    // ── Scrollbar ─────────────────────────────────────────────────────────────
214    pub scroll_track: Color,
215    pub scroll_thumb: Color,
216    pub scroll_thumb_hovered: Color,
217    pub scroll_thumb_dragging: Color,
218
219    // ── Separator / divider ───────────────────────────────────────────────────
220    pub separator: Color,
221
222    // ── Text selection highlight ──────────────────────────────────────────────
223    /// Background colour behind selected text while the widget is focused.
224    pub selection_bg: Color,
225    /// Background colour behind selected text while the widget is NOT focused.
226    /// Uses a neutral grey to signal that the selection is inactive.
227    pub selection_bg_unfocused: Color,
228}
229
230impl Visuals {
231    fn accent_hovered(accent: Color) -> Color {
232        if accent == AccentColor::Blue.color() {
233            return Color::rgb(0.30, 0.52, 0.92);
234        }
235        mix_color(accent, Color::white(), 0.18)
236    }
237
238    fn accent_pressed(accent: Color) -> Color {
239        if accent == AccentColor::Blue.color() {
240            return Color::rgb(0.16, 0.36, 0.72);
241        }
242        mix_color(accent, Color::black(), 0.18)
243    }
244
245    /// Return this palette with its primary accent replaced.
246    pub fn with_accent(mut self, accent: Color) -> Self {
247        let hovered = Self::accent_hovered(accent);
248        let pressed = Self::accent_pressed(accent);
249        let dark =
250            0.299 * self.bg_color.r + 0.587 * self.bg_color.g + 0.114 * self.bg_color.b < 0.5;
251        self.accent = accent;
252        self.accent_hovered = hovered;
253        self.accent_pressed = pressed;
254        self.accent_focus = accent.with_alpha(0.45);
255        self.text_link = if dark { hovered } else { pressed };
256        self.text_link_hovered = if dark {
257            mix_color(hovered, Color::white(), 0.12)
258        } else {
259            accent
260        };
261        self.widget_stroke_active = pressed;
262        self.selection_bg = accent.with_alpha(0.45);
263        self
264    }
265
266    /// Return this palette with one of the built-in accent swatches applied.
267    pub fn with_accent_color(self, accent: AccentColor) -> Self {
268        self.with_accent(accent.color())
269    }
270
271    /// Dark-mode palette matching egui's approximate dark colour scheme.
272    pub fn dark() -> Self {
273        let accent = Color::rgb(0.22, 0.45, 0.88);
274        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
275        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
276        Self {
277            // Chrome
278            bg_color: Color::rgb(0.10, 0.10, 0.12),
279            panel_fill: Color::rgb(0.13, 0.13, 0.15),
280            top_bar_bg: Color::rgb(0.15, 0.15, 0.17),
281            // Window
282            window_fill: Color::rgb(0.15, 0.15, 0.18),
283            window_title_fill: Color::rgb(0.20, 0.20, 0.24),
284            window_title_fill_drag: Color::rgb(0.16, 0.16, 0.20),
285            window_shadow: Color::rgba(0.0, 0.0, 0.0, 0.35),
286            window_stroke: Color::rgba(1.0, 1.0, 1.0, 0.08),
287            window_title_text: Color::rgba(1.0, 1.0, 1.0, 0.90),
288            window_close_bg: Color::rgba(1.0, 1.0, 1.0, 0.12),
289            window_close_bg_hovered: Color::rgba(1.0, 1.0, 1.0, 0.25),
290            window_close_fg: Color::rgba(1.0, 1.0, 1.0, 0.80),
291            window_resize_hover: Color::rgba(1.0, 1.0, 1.0, 0.40),
292            window_resize_active: Color::rgba(1.0, 1.0, 1.0, 0.80),
293            // Text
294            text_color: Color::rgb(0.90, 0.90, 0.92),
295            text_dim: Color::rgba(0.90, 0.90, 0.92, 0.50),
296            text_link: Color::rgb(0.45, 0.65, 1.00),
297            text_link_hovered: Color::rgb(0.35, 0.55, 0.90),
298            // Accent
299            accent,
300            accent_hovered,
301            accent_pressed,
302            accent_focus: Color::rgba(0.22, 0.45, 0.88, 0.45),
303            // Widgets
304            widget_bg: Color::rgb(0.22, 0.22, 0.26),
305            widget_bg_hovered: Color::rgb(0.28, 0.28, 0.33),
306            widget_stroke: Color::rgba(0.60, 0.60, 0.65, 0.60),
307            widget_stroke_active: accent_pressed,
308            // Track
309            track_bg: Color::rgb(0.25, 0.25, 0.28),
310            // Scrollbar
311            scroll_track: Color::rgba(1.0, 1.0, 1.0, 0.04),
312            scroll_thumb: Color::rgba(1.0, 1.0, 1.0, 0.18),
313            scroll_thumb_hovered: Color::rgba(1.0, 1.0, 1.0, 0.32),
314            scroll_thumb_dragging: Color::rgba(1.0, 1.0, 1.0, 0.45),
315            // Separator
316            separator: Color::rgba(1.0, 1.0, 1.0, 0.10),
317            // Selection
318            selection_bg: Color::rgba(0.22, 0.45, 0.88, 0.45),
319            selection_bg_unfocused: Color::rgba(0.60, 0.60, 0.65, 0.35),
320        }
321    }
322
323    /// Light-mode palette matching egui's approximate light colour scheme.
324    pub fn light() -> Self {
325        let accent = Color::rgb(0.22, 0.45, 0.88);
326        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
327        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
328        Self {
329            // Chrome
330            bg_color: Color::rgb(0.90, 0.90, 0.92),
331            panel_fill: Color::rgb(0.92, 0.92, 0.95),
332            top_bar_bg: Color::rgb(0.88, 0.88, 0.91),
333            // Window
334            window_fill: Color::rgb(0.97, 0.97, 0.98),
335            window_title_fill: Color::rgb(0.87, 0.87, 0.91),
336            window_title_fill_drag: Color::rgb(0.80, 0.80, 0.85),
337            window_shadow: Color::rgba(0.0, 0.0, 0.0, 0.18),
338            window_stroke: Color::rgba(0.0, 0.0, 0.0, 0.15),
339            window_title_text: Color::rgba(0.05, 0.05, 0.10, 0.90),
340            window_close_bg: Color::rgba(0.0, 0.0, 0.0, 0.08),
341            window_close_bg_hovered: Color::rgba(0.0, 0.0, 0.0, 0.18),
342            window_close_fg: Color::rgba(0.0, 0.0, 0.0, 0.65),
343            window_resize_hover: Color::rgba(0.0, 0.0, 0.0, 0.30),
344            window_resize_active: Color::rgba(0.0, 0.0, 0.0, 0.65),
345            // Text
346            text_color: Color::rgb(0.08, 0.08, 0.10),
347            text_dim: Color::rgba(0.08, 0.08, 0.10, 0.50),
348            text_link: Color::rgb(0.15, 0.35, 0.75),
349            text_link_hovered: Color::rgb(0.10, 0.28, 0.62),
350            // Accent
351            accent,
352            accent_hovered,
353            accent_pressed,
354            accent_focus: Color::rgba(0.22, 0.45, 0.88, 0.45),
355            // Widgets
356            widget_bg: Color::rgb(1.00, 1.00, 1.00),
357            widget_bg_hovered: Color::rgb(0.92, 0.93, 0.95),
358            widget_stroke: Color::rgb(0.75, 0.76, 0.78),
359            widget_stroke_active: accent_pressed,
360            // Track
361            track_bg: Color::rgb(0.85, 0.86, 0.88),
362            // Scrollbar
363            scroll_track: Color::rgba(0.0, 0.0, 0.0, 0.04),
364            scroll_thumb: Color::rgba(0.0, 0.0, 0.0, 0.18),
365            scroll_thumb_hovered: Color::rgba(0.0, 0.0, 0.0, 0.32),
366            scroll_thumb_dragging: Color::rgba(0.0, 0.0, 0.0, 0.45),
367            // Separator
368            separator: Color::rgba(0.0, 0.0, 0.0, 0.12),
369            // Selection
370            selection_bg: Color::rgba(0.22, 0.45, 0.88, 0.45),
371            selection_bg_unfocused: Color::rgba(0.45, 0.45, 0.50, 0.35),
372        }
373    }
374
375    /// Choose a palette from a [`ThemePreference`].  `System` falls back to dark.
376    pub fn for_preference(pref: ThemePreference) -> Self {
377        match pref {
378            ThemePreference::Light => Self::light(),
379            _ => Self::dark(),
380        }
381    }
382}
383
384fn mix_color(a: Color, b: Color, t: f32) -> Color {
385    let u = t.clamp(0.0, 1.0);
386    Color::rgba(
387        a.r + (b.r - a.r) * u,
388        a.g + (b.g - a.g) * u,
389        a.b + (b.b - a.b) * u,
390        a.a + (b.a - a.a) * u,
391    )
392}
393
394// ---------------------------------------------------------------------------
395// Thread-local active visuals
396// ---------------------------------------------------------------------------
397
398thread_local! {
399    static VISUALS: RefCell<Visuals> = RefCell::new(Visuals::dark());
400}
401
402/// Monotonic counter bumped every time `set_visuals` installs a new palette.
403///
404/// Backbuffered widgets (e.g. `Label`) compare this against the epoch they
405/// last rasterised at and self-invalidate on mismatch — without this, a
406/// `Label` whose color follows `visuals.text_color` would keep blitting the
407/// bitmap it baked in the old palette after a dark/light flip, leaving
408/// stale-coloured text until some other mutation invalidated the cache.
409static VISUALS_EPOCH: AtomicU64 = AtomicU64::new(1);
410
411/// Current visuals epoch.  See [`VISUALS_EPOCH`] docstring for how the
412/// widget layer uses it.
413pub fn current_visuals_epoch() -> u64 {
414    VISUALS_EPOCH.load(Ordering::Relaxed)
415}
416
417/// Replace the active [`Visuals`].
418///
419/// Call this once per frame *before* painting, typically from the platform
420/// render loop after reading the user's `ThemePreference`.
421pub fn set_visuals(v: Visuals) {
422    VISUALS.with(|cell| *cell.borrow_mut() = v);
423    VISUALS_EPOCH.fetch_add(1, Ordering::Relaxed);
424}
425
426/// Clone and return the active [`Visuals`].
427///
428/// Widget `paint()` methods call this (via [`DrawCtx::visuals`]) to look up
429/// colours at render time rather than at construction time.
430pub fn current_visuals() -> Visuals {
431    VISUALS.with(|cell| cell.borrow().clone())
432}