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}