herogpui_theme/provider.rs
1//! Global theme provider — the HeroGPUI equivalent of `HeroUIProvider`.
2
3use std::collections::HashMap;
4
5use gpui::{AnyWindowHandle, App, Global, SharedString, Subscription, Window, WindowAppearance};
6
7use crate::layout::LayoutTheme;
8use crate::semantic::{RoleColor, ThemeColors};
9use crate::theme::{Appearance, Theme};
10use herogpui_core::Color;
11
12/// Holds the active theme, any registered custom themes, and the opt-in
13/// "follow the OS appearance" mode.
14///
15/// The reduced-motion preference is deliberately *not* a field here. GPUI owns
16/// that flag (`App::reduce_motion`) and its own `Animation`/spring elements read
17/// it directly, so a copy on this global would be a second source of truth: a
18/// consumer app writing a plain `gpui::Animation`, or any GPUI internal such as
19/// animated-image playback, would keep animating after HeroGPUI was told to
20/// stop. `ActiveTheme::reduce_motion` therefore reads GPUI's flag and
21/// [`set_reduce_motion`] writes it, so the two cannot disagree.
22pub struct ThemeProvider {
23 active: SharedString,
24 themes: HashMap<SharedString, Theme>,
25 /// Set only by [`follow_system_appearance`]; nothing else may turn it on.
26 /// Following the OS is opt-in because an app that pins `Appearance::Light`
27 /// through `init_with`/`use_theme` means it, and must keep it.
28 follow_system_appearance: bool,
29 /// One retained appearance observer per followed window, keyed so a root
30 /// view that re-registers on rebuild replaces its observer instead of
31 /// stacking a second one. Retention is the point: `Subscription` is
32 /// unsubscribe-on-drop, so a discarded handle syncs once and then goes
33 /// quiet forever — the classic failure here.
34 appearance_observers: HashMap<AnyWindowHandle, Subscription>,
35}
36
37impl Global for ThemeProvider {}
38
39impl ThemeProvider {
40 /// Registers the provider with the default light theme.
41 ///
42 /// Call this once before opening the first window. Rendering a themed
43 /// component before initialization panics.
44 pub fn init(cx: &mut App) {
45 Self::init_with(Theme::light(), cx);
46 }
47
48 /// Registers the provider starting from an explicit theme.
49 ///
50 /// Call this once before opening the first window. Rendering a themed
51 /// component before initialization panics.
52 ///
53 /// The reduced-motion preference is left as it is: GPUI does not surface
54 /// the OS `prefers-reduced-motion` setting and this crate reads no
55 /// environment variable, so an application that offers the preference
56 /// sets it explicitly with [`set_reduce_motion`] (before or after this
57 /// call) from its own settings.
58 pub fn init_with(theme: Theme, cx: &mut App) {
59 let mut themes = HashMap::new();
60 themes.insert("light".into(), Theme::light());
61 themes.insert("dark".into(), Theme::dark());
62 let id = theme.id.clone();
63 themes.insert(id.clone(), theme);
64 cx.set_global(Self {
65 active: id,
66 themes,
67 follow_system_appearance: false,
68 appearance_observers: HashMap::new(),
69 });
70 }
71
72 /// Returns the global provider. Panics if `init` has not registered it.
73 pub fn get(cx: &App) -> &Self {
74 cx.global::<ThemeProvider>()
75 }
76
77 /// The active theme.
78 pub fn theme(&self) -> &Theme {
79 // `set_active` refuses ids that are not registered and `register`
80 // inserts before it activates, so the active id always resolves.
81 // Should that invariant ever break, fall back to the built-in light
82 // theme rather than panicking on every frame.
83 self.themes
84 .get(&self.active)
85 .or_else(|| self.themes.get("light"))
86 .expect("the built-in light theme is registered by `init_with` and never removed")
87 }
88
89 /// Whether a theme with this id has been registered.
90 pub fn contains(&self, id: &str) -> bool {
91 self.themes.contains_key(id)
92 }
93
94 /// The id of the active theme.
95 pub fn active_id(&self) -> &SharedString {
96 &self.active
97 }
98
99 /// Registers and activates a custom theme.
100 pub fn register(&mut self, theme: Theme) {
101 self.active = theme.id.clone();
102 self.themes.insert(theme.id.clone(), theme);
103 }
104
105 /// Registers a theme without activating it, replacing any theme with
106 /// the same id. The active theme is unchanged (re-inserting the active
107 /// id swaps its tokens in place).
108 pub fn insert(&mut self, theme: Theme) {
109 self.themes.insert(theme.id.clone(), theme);
110 }
111
112 /// The ids of every registered theme, sorted.
113 pub fn theme_ids(&self) -> Vec<SharedString> {
114 let mut ids: Vec<_> = self.themes.keys().cloned().collect();
115 ids.sort();
116 ids
117 }
118
119 /// Activates a previously registered theme by id.
120 ///
121 /// An id that was never registered is refused: the active theme stays as
122 /// it was and the error carries the rejected id.
123 pub fn set_active(&mut self, id: impl Into<SharedString>) -> Result<(), UnknownThemeError> {
124 let id = id.into();
125 if !self.themes.contains_key(&id) {
126 return Err(UnknownThemeError { id });
127 }
128 self.active = id;
129 Ok(())
130 }
131
132 /// Whether the OS light/dark appearance is being followed — the state a
133 /// three-way "Light / Dark / System" setting needs to render itself.
134 pub fn follows_system_appearance(&self) -> bool {
135 self.follow_system_appearance
136 }
137}
138
139/// Convenience extension trait giving every GPUI context access to the theme.
140///
141/// Works with `&App`, `&mut App`, `Context<T>` (they deref to `App`).
142pub trait ActiveTheme {
143 /// The active theme.
144 fn theme(&self) -> &Theme;
145 /// The active theme's semantic colors.
146 fn colors(&self) -> &ThemeColors;
147 /// The active theme's layout tokens.
148 fn layout(&self) -> &LayoutTheme;
149 /// The active theme's typed component defaults and recipes.
150 fn components(&self) -> &crate::ComponentThemes;
151 /// The active theme's [`RoleColor`] for a semantic [`Color`].
152 fn role(&self, color: Color) -> &RoleColor;
153 /// Whether the active theme's appearance is dark.
154 fn is_dark_theme(&self) -> bool;
155 /// Whether animations should be suppressed. Components must check this
156 /// before animating; v3 requires no opt-in from the caller.
157 fn reduce_motion(&self) -> bool;
158}
159
160impl ActiveTheme for App {
161 fn theme(&self) -> &Theme {
162 ThemeProvider::get(self).theme()
163 }
164
165 fn colors(&self) -> &ThemeColors {
166 &self.theme().colors
167 }
168
169 fn layout(&self) -> &LayoutTheme {
170 &self.theme().layout
171 }
172
173 fn components(&self) -> &crate::ComponentThemes {
174 &self.theme().components
175 }
176
177 fn role(&self, color: Color) -> &RoleColor {
178 match color {
179 Color::Default => &self.colors().default,
180 Color::Accent => &self.colors().accent,
181 Color::Success => &self.colors().success,
182 Color::Warning => &self.colors().warning,
183 Color::Danger => &self.colors().danger,
184 }
185 }
186
187 fn is_dark_theme(&self) -> bool {
188 self.theme().is_dark()
189 }
190
191 fn reduce_motion(&self) -> bool {
192 // GPUI's flag is the single source of truth (see `ThemeProvider`), and
193 // `App::reduce_motion` is spelled out rather than called as
194 // `self.reduce_motion()` because that would resolve to this very trait
195 // method through the inherent-first rule only by luck — inherent wins,
196 // so the shorthand happens to work and reads like infinite recursion.
197 App::reduce_motion(self)
198 }
199}
200
201/// Sets the global theme and schedules every open window to repaint.
202pub fn set_theme(theme: Theme, cx: &mut App) {
203 let provider = cx.global_mut::<ThemeProvider>();
204 provider.register(theme);
205 cx.refresh_windows();
206}
207
208/// The error [`use_theme`] and [`ThemeProvider::set_active`] return for an id
209/// that no registered theme carries (ids are case-sensitive: `"Dark"` is not
210/// `"dark"`).
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub struct UnknownThemeError {
213 /// The id that was asked for.
214 pub id: SharedString,
215}
216
217impl std::fmt::Display for UnknownThemeError {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 write!(
220 f,
221 "no theme is registered under the id {:?}",
222 self.id.as_ref()
223 )
224 }
225}
226
227impl std::error::Error for UnknownThemeError {}
228
229/// Activates one of the registered themes by id (`"light"`, `"dark"`, custom)
230/// and schedules every open window to repaint.
231///
232/// An unknown id is an error, not a panic: the active theme is left unchanged,
233/// no window repaints, and the rejected id comes back in
234/// [`UnknownThemeError`].
235pub fn use_theme(id: impl Into<SharedString>, cx: &mut App) -> Result<(), UnknownThemeError> {
236 let provider = cx.global_mut::<ThemeProvider>();
237 provider.set_active(id)?;
238 cx.refresh_windows();
239 Ok(())
240}
241
242/// Sets the app-level reduced-motion preference — the equivalent of putting
243/// `data-reduce-motion="true"` on the document element — and schedules every
244/// open window to repaint. Every animated component honours it without opt-in,
245/// and so does every plain `gpui::Animation`: the value is stored in GPUI's own
246/// global, which its animation elements consult themselves.
247///
248/// This is the only way the preference is set: the library reads no
249/// environment variable or OS setting for it (GPUI exposes none), so an app
250/// wires it to its own settings, a command-line flag, or an environment
251/// variable of its choosing.
252///
253/// ```
254/// # fn startup(cx: &mut gpui::App) {
255/// herogpui_theme::ThemeProvider::init(cx);
256/// // e.g. the gallery's `HEROGPUI_REDUCE_MOTION=1`:
257/// if std::env::var("MY_APP_REDUCE_MOTION").is_ok_and(|v| v == "1") {
258/// herogpui_theme::set_reduce_motion(true, cx);
259/// }
260/// # }
261/// ```
262pub fn set_reduce_motion(v: bool, cx: &mut App) {
263 cx.set_reduce_motion(v);
264 // GPUI's setter already refreshes on a *change*; this repaints on a
265 // no-change write too, which is the contract `theme_repaint.rs` pins for
266 // every provider mutation.
267 cx.refresh_windows();
268}
269
270/// Flips the reduced-motion preference.
271pub fn toggle_reduce_motion(cx: &mut App) {
272 let next = !App::reduce_motion(cx);
273 set_reduce_motion(next, cx);
274}
275
276/// Follows the OS light/dark appearance for as long as `window` stays open,
277/// activating the registered `"light"` or `"dark"` theme to match.
278///
279/// Opt-in on purpose: without this call the app keeps whatever theme it
280/// activated, so pinning a single appearance stays possible. Call it once per
281/// window, from the window's root-view constructor. A custom theme pair can
282/// join in by registering under the ids `"light"` and `"dark"`, which is what
283/// the switch reads.
284///
285/// The current appearance is applied immediately, so a window opened on a dark
286/// desktop does not paint one light frame first.
287///
288/// `App::should_auto_hide_scrollbars` is read by the painted `Scrollbar`
289/// overlay: thumbs hide after idle when the OS preference is on, and stay
290/// painted when it is off.
291pub fn follow_system_appearance(window: &mut Window, cx: &mut App) {
292 // The per-window `appearance()` over `App::window_appearance()`: it is the
293 // value the observer below reports, so the immediate sync and every later
294 // sync agree, and the app-level platform query is the one that misbehaves
295 // on Linux (longbridge/gpui-kit#104).
296 let subscription = window.observe_window_appearance(|window, cx| {
297 apply_system_appearance(window.appearance(), cx);
298 });
299 let handle = window.window_handle();
300 let appearance = window.appearance();
301
302 let provider = cx.global_mut::<ThemeProvider>();
303 provider.follow_system_appearance = true;
304 // Moving the `Subscription` into the global is what keeps the observer
305 // alive; binding it to a local here would unsubscribe at the end of this
306 // function and the OS switch would silently stop arriving.
307 provider.appearance_observers.insert(handle, subscription);
308
309 apply_system_appearance(appearance, cx);
310}
311
312/// Stops following the OS appearance, leaving the active theme as it is.
313pub fn stop_following_system_appearance(cx: &mut App) {
314 let provider = cx.global_mut::<ThemeProvider>();
315 provider.follow_system_appearance = false;
316 // Dropping the subscriptions is the unsubscribe. Clearing only the flag
317 // would leave live observers calling back into a mode that is off.
318 provider.appearance_observers.clear();
319}
320
321/// Activates the theme matching one OS appearance, if the mode is still on.
322fn apply_system_appearance(appearance: WindowAppearance, cx: &mut App) {
323 let provider = ThemeProvider::get(cx);
324 if !provider.follows_system_appearance() {
325 return;
326 }
327 let id = match Appearance::from(appearance) {
328 Appearance::Light => "light",
329 Appearance::Dark => "dark",
330 };
331 // A window reports its appearance on unrelated occasions too (and every
332 // followed window reports the same OS switch), so skip the no-op rather
333 // than repainting every window once per redundant notification.
334 if provider.active_id().as_ref() == id {
335 return;
336 }
337 // Both ids are registered by `init_with`; a custom pair replaces them
338 // under the same ids, so this cannot miss.
339 let _ = use_theme(id, cx);
340}
341
342/// Switches between the light and dark defaults.
343pub fn toggle_light_dark(cx: &mut App) {
344 let dark = cx.theme().is_dark();
345 let next = if dark { "light" } else { "dark" };
346 // Registered by `init_with`, so this cannot miss.
347 let _ = use_theme(next, cx);
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use gpui::TestAppContext;
354
355 /// `init` must not own the preference: a value the app set first (from
356 /// its settings, before the provider existed) survives initialization,
357 /// and a default app stays `false`.
358 #[gpui::test]
359 fn init_leaves_the_reduce_motion_preference_alone(cx: &mut TestAppContext) {
360 cx.update(|cx| {
361 ThemeProvider::init(cx);
362 assert!(!cx.reduce_motion());
363 });
364 cx.update(|cx| {
365 cx.set_reduce_motion(true);
366 ThemeProvider::init_with(Theme::dark(), cx);
367 assert!(cx.reduce_motion());
368 set_reduce_motion(false, cx);
369 assert!(!ActiveTheme::reduce_motion(cx));
370 });
371 }
372}