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    /// `true` when this palette reads as a dark theme (background
249    /// luminance below 50%). Used by widgets that maintain their own
250    /// light/dark token sets (e.g. the on-screen keyboard) to follow
251    /// the app theme without a separate preference plumbed through.
252    pub fn is_dark(&self) -> bool {
253        0.299 * self.bg_color.r + 0.587 * self.bg_color.g + 0.114 * self.bg_color.b < 0.5
254    }
255
256    /// Return this palette with its primary accent replaced.
257    pub fn with_accent(mut self, accent: Color) -> Self {
258        let hovered = Self::accent_hovered(accent);
259        let pressed = Self::accent_pressed(accent);
260        let dark = self.is_dark();
261        self.accent = accent;
262        self.accent_hovered = hovered;
263        self.accent_pressed = pressed;
264        self.accent_focus = accent.with_alpha(0.45);
265        self.text_link = if dark { hovered } else { pressed };
266        self.text_link_hovered = if dark {
267            mix_color(hovered, Color::white(), 0.12)
268        } else {
269            accent
270        };
271        self.widget_stroke_active = pressed;
272        self.selection_bg = accent.with_alpha(0.45);
273        self
274    }
275
276    /// Return this palette with one of the built-in accent swatches applied.
277    pub fn with_accent_color(self, accent: AccentColor) -> Self {
278        self.with_accent(accent.color())
279    }
280
281    /// Dark-mode palette matching egui's approximate dark colour scheme.
282    pub fn dark() -> Self {
283        let accent = Color::rgb(0.22, 0.45, 0.88);
284        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
285        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
286        Self {
287            // Chrome
288            bg_color: Color::rgb(0.10, 0.10, 0.12),
289            panel_fill: Color::rgb(0.13, 0.13, 0.15),
290            top_bar_bg: Color::rgb(0.15, 0.15, 0.17),
291            // Window
292            window_fill: Color::rgb(0.15, 0.15, 0.18),
293            window_title_fill: Color::rgb(0.20, 0.20, 0.24),
294            window_title_fill_drag: Color::rgb(0.16, 0.16, 0.20),
295            window_shadow: Color::rgba(0.0, 0.0, 0.0, 0.35),
296            window_stroke: Color::rgba(1.0, 1.0, 1.0, 0.08),
297            window_title_text: Color::rgba(1.0, 1.0, 1.0, 0.90),
298            window_close_bg: Color::rgba(1.0, 1.0, 1.0, 0.12),
299            window_close_bg_hovered: Color::rgba(1.0, 1.0, 1.0, 0.25),
300            window_close_fg: Color::rgba(1.0, 1.0, 1.0, 0.80),
301            window_resize_hover: Color::rgba(1.0, 1.0, 1.0, 0.40),
302            window_resize_active: Color::rgba(1.0, 1.0, 1.0, 0.80),
303            // Text
304            text_color: Color::rgb(0.90, 0.90, 0.92),
305            text_dim: Color::rgba(0.90, 0.90, 0.92, 0.50),
306            text_link: Color::rgb(0.45, 0.65, 1.00),
307            text_link_hovered: Color::rgb(0.35, 0.55, 0.90),
308            // Accent
309            accent,
310            accent_hovered,
311            accent_pressed,
312            accent_focus: Color::rgba(0.22, 0.45, 0.88, 0.45),
313            // Widgets
314            widget_bg: Color::rgb(0.22, 0.22, 0.26),
315            widget_bg_hovered: Color::rgb(0.28, 0.28, 0.33),
316            widget_stroke: Color::rgba(0.60, 0.60, 0.65, 0.60),
317            widget_stroke_active: accent_pressed,
318            // Track
319            track_bg: Color::rgb(0.25, 0.25, 0.28),
320            // Scrollbar
321            scroll_track: Color::rgba(1.0, 1.0, 1.0, 0.04),
322            scroll_thumb: Color::rgba(1.0, 1.0, 1.0, 0.18),
323            scroll_thumb_hovered: Color::rgba(1.0, 1.0, 1.0, 0.32),
324            scroll_thumb_dragging: Color::rgba(1.0, 1.0, 1.0, 0.45),
325            // Separator
326            separator: Color::rgba(1.0, 1.0, 1.0, 0.10),
327            // Selection
328            selection_bg: Color::rgba(0.22, 0.45, 0.88, 0.45),
329            selection_bg_unfocused: Color::rgba(0.60, 0.60, 0.65, 0.35),
330        }
331    }
332
333    /// Light-mode palette matching egui's approximate light colour scheme.
334    pub fn light() -> Self {
335        let accent = Color::rgb(0.22, 0.45, 0.88);
336        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
337        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
338        Self {
339            // Chrome
340            bg_color: Color::rgb(0.90, 0.90, 0.92),
341            panel_fill: Color::rgb(0.92, 0.92, 0.95),
342            top_bar_bg: Color::rgb(0.88, 0.88, 0.91),
343            // Window
344            window_fill: Color::rgb(0.97, 0.97, 0.98),
345            window_title_fill: Color::rgb(0.87, 0.87, 0.91),
346            window_title_fill_drag: Color::rgb(0.80, 0.80, 0.85),
347            window_shadow: Color::rgba(0.0, 0.0, 0.0, 0.18),
348            window_stroke: Color::rgba(0.0, 0.0, 0.0, 0.15),
349            window_title_text: Color::rgba(0.05, 0.05, 0.10, 0.90),
350            window_close_bg: Color::rgba(0.0, 0.0, 0.0, 0.08),
351            window_close_bg_hovered: Color::rgba(0.0, 0.0, 0.0, 0.18),
352            window_close_fg: Color::rgba(0.0, 0.0, 0.0, 0.65),
353            window_resize_hover: Color::rgba(0.0, 0.0, 0.0, 0.30),
354            window_resize_active: Color::rgba(0.0, 0.0, 0.0, 0.65),
355            // Text
356            text_color: Color::rgb(0.08, 0.08, 0.10),
357            text_dim: Color::rgba(0.08, 0.08, 0.10, 0.50),
358            text_link: Color::rgb(0.15, 0.35, 0.75),
359            text_link_hovered: Color::rgb(0.10, 0.28, 0.62),
360            // Accent
361            accent,
362            accent_hovered,
363            accent_pressed,
364            accent_focus: Color::rgba(0.22, 0.45, 0.88, 0.45),
365            // Widgets
366            widget_bg: Color::rgb(1.00, 1.00, 1.00),
367            widget_bg_hovered: Color::rgb(0.92, 0.93, 0.95),
368            widget_stroke: Color::rgb(0.75, 0.76, 0.78),
369            widget_stroke_active: accent_pressed,
370            // Track
371            track_bg: Color::rgb(0.85, 0.86, 0.88),
372            // Scrollbar
373            scroll_track: Color::rgba(0.0, 0.0, 0.0, 0.04),
374            scroll_thumb: Color::rgba(0.0, 0.0, 0.0, 0.18),
375            scroll_thumb_hovered: Color::rgba(0.0, 0.0, 0.0, 0.32),
376            scroll_thumb_dragging: Color::rgba(0.0, 0.0, 0.0, 0.45),
377            // Separator
378            separator: Color::rgba(0.0, 0.0, 0.0, 0.12),
379            // Selection
380            selection_bg: Color::rgba(0.22, 0.45, 0.88, 0.45),
381            selection_bg_unfocused: Color::rgba(0.45, 0.45, 0.50, 0.35),
382        }
383    }
384
385    /// Choose a palette from a [`ThemePreference`].  `System` falls back to dark.
386    pub fn for_preference(pref: ThemePreference) -> Self {
387        match pref {
388            ThemePreference::Light => Self::light(),
389            _ => Self::dark(),
390        }
391    }
392}
393
394fn mix_color(a: Color, b: Color, t: f32) -> Color {
395    let u = t.clamp(0.0, 1.0);
396    Color::rgba(
397        a.r + (b.r - a.r) * u,
398        a.g + (b.g - a.g) * u,
399        a.b + (b.b - a.b) * u,
400        a.a + (b.a - a.a) * u,
401    )
402}
403
404// ---------------------------------------------------------------------------
405// Thread-local active visuals
406// ---------------------------------------------------------------------------
407
408thread_local! {
409    static VISUALS: RefCell<Visuals> = RefCell::new(Visuals::dark());
410}
411
412/// Monotonic counter bumped every time `set_visuals` installs a new palette.
413///
414/// Backbuffered widgets (e.g. `Label`) compare this against the epoch they
415/// last rasterised at and self-invalidate on mismatch — without this, a
416/// `Label` whose color follows `visuals.text_color` would keep blitting the
417/// bitmap it baked in the old palette after a dark/light flip, leaving
418/// stale-coloured text until some other mutation invalidated the cache.
419static VISUALS_EPOCH: AtomicU64 = AtomicU64::new(1);
420
421/// Current visuals epoch.  See [`VISUALS_EPOCH`] docstring for how the
422/// widget layer uses it.
423pub fn current_visuals_epoch() -> u64 {
424    VISUALS_EPOCH.load(Ordering::Relaxed)
425}
426
427/// Replace the active [`Visuals`].
428///
429/// Call this once per frame *before* painting, typically from the platform
430/// render loop after reading the user's `ThemePreference`.
431pub fn set_visuals(v: Visuals) {
432    VISUALS.with(|cell| *cell.borrow_mut() = v);
433    VISUALS_EPOCH.fetch_add(1, Ordering::Relaxed);
434}
435
436/// Clone and return the active [`Visuals`].
437///
438/// Widget `paint()` methods call this (via [`DrawCtx::visuals`]) to look up
439/// colours at render time rather than at construction time.
440pub fn current_visuals() -> Visuals {
441    VISUALS.with(|cell| cell.borrow().clone())
442}