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
36// ---------------------------------------------------------------------------
37// Visuals (complete colour palette)
38// ---------------------------------------------------------------------------
39
40/// All colours used by the widget library.
41///
42/// The canonical way to access the active palette inside `Widget::paint` is:
43/// ```ignore
44/// let v = ctx.visuals();
45/// ctx.set_fill_color(v.window_fill);
46/// ```
47#[derive(Clone, Debug)]
48pub struct Visuals {
49    // ── Chrome ────────────────────────────────────────────────────────────────
50    /// Canvas / app background (behind all floating windows).
51    pub bg_color:               Color,
52    /// Sidebar / panel background.
53    pub panel_fill:             Color,
54    /// Top menu bar background.
55    pub top_bar_bg:             Color,
56
57    // ── Floating window ───────────────────────────────────────────────────────
58    /// Window content-area background.
59    pub window_fill:            Color,
60    /// Window title bar background (idle).
61    pub window_title_fill:      Color,
62    /// Window title bar background while dragging.
63    pub window_title_fill_drag: Color,
64    /// Drop-shadow colour (semi-transparent black/dark).
65    pub window_shadow:          Color,
66    /// Thin border drawn around the window.
67    pub window_stroke:          Color,
68    /// Title bar text colour.
69    pub window_title_text:      Color,
70    /// Close button background (idle).
71    pub window_close_bg:        Color,
72    /// Close button background (hovered).
73    pub window_close_bg_hovered: Color,
74    /// Close button × glyph colour.
75    pub window_close_fg:        Color,
76    /// Resize edge / corner highlight colour when hovered (not yet dragging).
77    pub window_resize_hover:    Color,
78    /// Resize edge / corner highlight colour while actively dragging to resize.
79    pub window_resize_active:   Color,
80
81    // ── Text ──────────────────────────────────────────────────────────────────
82    /// Body text colour.
83    pub text_color:             Color,
84    /// Secondary / dimmed text (hints, labels).
85    pub text_dim:               Color,
86    /// Hyperlink colour (idle).
87    pub text_link:              Color,
88    /// Hyperlink colour (hovered).
89    pub text_link_hovered:      Color,
90
91    // ── Accent / primary action colour ────────────────────────────────────────
92    /// Used for checked states, active tabs, slider fill, button backgrounds.
93    pub accent:                 Color,
94    /// Accent colour when hovered.
95    pub accent_hovered:         Color,
96    /// Accent colour when pressed / active.
97    pub accent_pressed:         Color,
98    /// Low-opacity accent used for focus rings.
99    pub accent_focus:           Color,
100
101    // ── Interactive widgets (checkbox, radio, drag-value, …) ──────────────────
102    /// Widget background when unchecked / idle.
103    pub widget_bg:              Color,
104    /// Widget background when hovered (unchecked).
105    pub widget_bg_hovered:      Color,
106    /// Widget border / outline (unchecked).
107    pub widget_stroke:          Color,
108    /// Widget border / outline (checked / active).
109    pub widget_stroke_active:   Color,
110
111    // ── Slider / progress bar track ───────────────────────────────────────────
112    pub track_bg:               Color,
113
114    // ── Scrollbar ─────────────────────────────────────────────────────────────
115    pub scroll_track:              Color,
116    pub scroll_thumb:              Color,
117    pub scroll_thumb_hovered:      Color,
118    pub scroll_thumb_dragging:     Color,
119
120    // ── Separator / divider ───────────────────────────────────────────────────
121    pub separator:              Color,
122
123    // ── Text selection highlight ──────────────────────────────────────────────
124    /// Background colour behind selected text while the widget is focused.
125    pub selection_bg:           Color,
126    /// Background colour behind selected text while the widget is NOT focused.
127    /// Uses a neutral grey to signal that the selection is inactive.
128    pub selection_bg_unfocused: Color,
129}
130
131impl Visuals {
132    /// Dark-mode palette matching egui's approximate dark colour scheme.
133    pub fn dark() -> Self {
134        let accent         = Color::rgb(0.22, 0.45, 0.88);
135        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
136        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
137        Self {
138            // Chrome
139            bg_color:               Color::rgb(0.10, 0.10, 0.12),
140            panel_fill:             Color::rgb(0.13, 0.13, 0.15),
141            top_bar_bg:             Color::rgb(0.15, 0.15, 0.17),
142            // Window
143            window_fill:            Color::rgb(0.15, 0.15, 0.18),
144            window_title_fill:      Color::rgb(0.20, 0.20, 0.24),
145            window_title_fill_drag: Color::rgb(0.16, 0.16, 0.20),
146            window_shadow:          Color::rgba(0.0,  0.0,  0.0,  0.35),
147            window_stroke:          Color::rgba(1.0,  1.0,  1.0,  0.08),
148            window_title_text:      Color::rgba(1.0,  1.0,  1.0,  0.90),
149            window_close_bg:        Color::rgba(1.0,  1.0,  1.0,  0.12),
150            window_close_bg_hovered:Color::rgba(1.0,  1.0,  1.0,  0.25),
151            window_close_fg:        Color::rgba(1.0,  1.0,  1.0,  0.80),
152            window_resize_hover:    Color::rgba(1.0,  1.0,  1.0,  0.40),
153            window_resize_active:   Color::rgba(1.0,  1.0,  1.0,  0.80),
154            // Text
155            text_color:             Color::rgb(0.90, 0.90, 0.92),
156            text_dim:               Color::rgba(0.90, 0.90, 0.92, 0.50),
157            text_link:              Color::rgb(0.45, 0.65, 1.00),
158            text_link_hovered:      Color::rgb(0.35, 0.55, 0.90),
159            // Accent
160            accent,
161            accent_hovered,
162            accent_pressed,
163            accent_focus:           Color::rgba(0.22, 0.45, 0.88, 0.45),
164            // Widgets
165            widget_bg:              Color::rgb(0.22, 0.22, 0.26),
166            widget_bg_hovered:      Color::rgb(0.28, 0.28, 0.33),
167            widget_stroke:          Color::rgba(0.60, 0.60, 0.65, 0.60),
168            widget_stroke_active:   accent_pressed,
169            // Track
170            track_bg:               Color::rgb(0.25, 0.25, 0.28),
171            // Scrollbar
172            scroll_track:           Color::rgba(1.0,  1.0,  1.0,  0.04),
173            scroll_thumb:           Color::rgba(1.0,  1.0,  1.0,  0.18),
174            scroll_thumb_hovered:   Color::rgba(1.0,  1.0,  1.0,  0.32),
175            scroll_thumb_dragging:  Color::rgba(1.0,  1.0,  1.0,  0.45),
176            // Separator
177            separator:              Color::rgba(1.0,  1.0,  1.0,  0.10),
178            // Selection
179            selection_bg:           Color::rgba(0.22, 0.45, 0.88, 0.45),
180            selection_bg_unfocused: Color::rgba(0.60, 0.60, 0.65, 0.35),
181        }
182    }
183
184    /// Light-mode palette matching egui's approximate light colour scheme.
185    pub fn light() -> Self {
186        let accent         = Color::rgb(0.22, 0.45, 0.88);
187        let accent_hovered = Color::rgb(0.30, 0.52, 0.92);
188        let accent_pressed = Color::rgb(0.16, 0.36, 0.72);
189        Self {
190            // Chrome
191            bg_color:               Color::rgb(0.90, 0.90, 0.92),
192            panel_fill:             Color::rgb(0.92, 0.92, 0.95),
193            top_bar_bg:             Color::rgb(0.88, 0.88, 0.91),
194            // Window
195            window_fill:            Color::rgb(0.97, 0.97, 0.98),
196            window_title_fill:      Color::rgb(0.87, 0.87, 0.91),
197            window_title_fill_drag: Color::rgb(0.80, 0.80, 0.85),
198            window_shadow:          Color::rgba(0.0,  0.0,  0.0,  0.18),
199            window_stroke:          Color::rgba(0.0,  0.0,  0.0,  0.15),
200            window_title_text:      Color::rgba(0.05, 0.05, 0.10, 0.90),
201            window_close_bg:        Color::rgba(0.0,  0.0,  0.0,  0.08),
202            window_close_bg_hovered:Color::rgba(0.0,  0.0,  0.0,  0.18),
203            window_close_fg:        Color::rgba(0.0,  0.0,  0.0,  0.65),
204            window_resize_hover:    Color::rgba(0.0,  0.0,  0.0,  0.30),
205            window_resize_active:   Color::rgba(0.0,  0.0,  0.0,  0.65),
206            // Text
207            text_color:             Color::rgb(0.08, 0.08, 0.10),
208            text_dim:               Color::rgba(0.08, 0.08, 0.10, 0.50),
209            text_link:              Color::rgb(0.15, 0.35, 0.75),
210            text_link_hovered:      Color::rgb(0.10, 0.28, 0.62),
211            // Accent
212            accent,
213            accent_hovered,
214            accent_pressed,
215            accent_focus:           Color::rgba(0.22, 0.45, 0.88, 0.45),
216            // Widgets
217            widget_bg:              Color::rgb(1.00, 1.00, 1.00),
218            widget_bg_hovered:      Color::rgb(0.92, 0.93, 0.95),
219            widget_stroke:          Color::rgb(0.75, 0.76, 0.78),
220            widget_stroke_active:   accent_pressed,
221            // Track
222            track_bg:               Color::rgb(0.85, 0.86, 0.88),
223            // Scrollbar
224            scroll_track:           Color::rgba(0.0,  0.0,  0.0,  0.04),
225            scroll_thumb:           Color::rgba(0.0,  0.0,  0.0,  0.18),
226            scroll_thumb_hovered:   Color::rgba(0.0,  0.0,  0.0,  0.32),
227            scroll_thumb_dragging:  Color::rgba(0.0,  0.0,  0.0,  0.45),
228            // Separator
229            separator:              Color::rgba(0.0,  0.0,  0.0,  0.12),
230            // Selection
231            selection_bg:           Color::rgba(0.22, 0.45, 0.88, 0.45),
232            selection_bg_unfocused: Color::rgba(0.45, 0.45, 0.50, 0.35),
233        }
234    }
235
236    /// Choose a palette from a [`ThemePreference`].  `System` falls back to dark.
237    pub fn for_preference(pref: ThemePreference) -> Self {
238        match pref {
239            ThemePreference::Light => Self::light(),
240            _ => Self::dark(),
241        }
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Thread-local active visuals
247// ---------------------------------------------------------------------------
248
249thread_local! {
250    static VISUALS: RefCell<Visuals> = RefCell::new(Visuals::dark());
251}
252
253/// Monotonic counter bumped every time `set_visuals` installs a new palette.
254///
255/// Backbuffered widgets (e.g. `Label`) compare this against the epoch they
256/// last rasterised at and self-invalidate on mismatch — without this, a
257/// `Label` whose color follows `visuals.text_color` would keep blitting the
258/// bitmap it baked in the old palette after a dark/light flip, leaving
259/// stale-coloured text until some other mutation invalidated the cache.
260static VISUALS_EPOCH: AtomicU64 = AtomicU64::new(1);
261
262/// Current visuals epoch.  See [`VISUALS_EPOCH`] docstring for how the
263/// widget layer uses it.
264pub fn current_visuals_epoch() -> u64 {
265    VISUALS_EPOCH.load(Ordering::Relaxed)
266}
267
268/// Replace the active [`Visuals`].
269///
270/// Call this once per frame *before* painting, typically from the platform
271/// render loop after reading the user's `ThemePreference`.
272pub fn set_visuals(v: Visuals) {
273    VISUALS.with(|cell| *cell.borrow_mut() = v);
274    VISUALS_EPOCH.fetch_add(1, Ordering::Relaxed);
275}
276
277/// Clone and return the active [`Visuals`].
278///
279/// Widget `paint()` methods call this (via [`DrawCtx::visuals`]) to look up
280/// colours at render time rather than at construction time.
281pub fn current_visuals() -> Visuals {
282    VISUALS.with(|cell| cell.borrow().clone())
283}