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