Skip to main content

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    pub fn init_with(theme: Theme, cx: &mut App) {
53        let mut themes = HashMap::new();
54        themes.insert("light".into(), Theme::light());
55        themes.insert("dark".into(), Theme::dark());
56        let id = theme.id.clone();
57        themes.insert(id.clone(), theme);
58        // gpui does not surface the OS `prefers-reduced-motion` setting, so the
59        // env var stands in for it; `set_reduce_motion` is the app-level
60        // override, matching v3's `data-reduce-motion` precedence.
61        let reduce_motion = std::env::var("HEROGPUI_REDUCE_MOTION")
62            .is_ok_and(|v| v != "0" && !v.eq_ignore_ascii_case("false"));
63        cx.set_global(Self {
64            active: id,
65            themes,
66            follow_system_appearance: false,
67            appearance_observers: HashMap::new(),
68        });
69        // The global has to exist first: GPUI's setter refreshes every window
70        // when the value changes, and a themed window repainting before
71        // `set_global` would panic looking for the provider. GPUI's own default
72        // is a plain `false` seeded at app construction (it reads no OS
73        // setting), so writing the env-var seed here clobbers nothing.
74        cx.set_reduce_motion(reduce_motion);
75    }
76
77    pub fn get(cx: &App) -> &Self {
78        cx.global::<ThemeProvider>()
79    }
80
81    pub fn theme(&self) -> &Theme {
82        self.themes.get(&self.active).expect("active theme missing")
83    }
84
85    pub fn active_id(&self) -> &SharedString {
86        &self.active
87    }
88
89    /// Registers and activates a custom theme.
90    pub fn register(&mut self, theme: Theme) {
91        self.active = theme.id.clone();
92        self.themes.insert(theme.id.clone(), theme);
93    }
94
95    /// Activates a previously registered theme by id.
96    pub fn set_active(&mut self, id: impl Into<SharedString>) {
97        self.active = id.into();
98    }
99
100    /// Whether the OS light/dark appearance is being followed — the state a
101    /// three-way "Light / Dark / System" setting needs to render itself.
102    pub fn follows_system_appearance(&self) -> bool {
103        self.follow_system_appearance
104    }
105}
106
107/// Convenience extension trait giving every GPUI context access to the theme.
108///
109/// Works with `&App`, `&mut App`, `Context<T>` (they deref to `App`).
110pub trait ActiveTheme {
111    fn theme(&self) -> &Theme;
112    fn colors(&self) -> &ThemeColors;
113    fn layout(&self) -> &LayoutTheme;
114    fn components(&self) -> &crate::ComponentThemes;
115    fn role(&self, color: Color) -> &RoleColor;
116    fn is_dark_theme(&self) -> bool;
117    /// Whether animations should be suppressed. Components must check this
118    /// before animating; v3 requires no opt-in from the caller.
119    fn reduce_motion(&self) -> bool;
120}
121
122impl ActiveTheme for App {
123    fn theme(&self) -> &Theme {
124        ThemeProvider::get(self).theme()
125    }
126
127    fn colors(&self) -> &ThemeColors {
128        &self.theme().colors
129    }
130
131    fn layout(&self) -> &LayoutTheme {
132        &self.theme().layout
133    }
134
135    fn components(&self) -> &crate::ComponentThemes {
136        &self.theme().components
137    }
138
139    fn role(&self, color: Color) -> &RoleColor {
140        match color {
141            Color::Default => &self.colors().default,
142            Color::Accent => &self.colors().accent,
143            Color::Success => &self.colors().success,
144            Color::Warning => &self.colors().warning,
145            Color::Danger => &self.colors().danger,
146        }
147    }
148
149    fn is_dark_theme(&self) -> bool {
150        self.theme().is_dark()
151    }
152
153    fn reduce_motion(&self) -> bool {
154        // GPUI's flag is the single source of truth (see `ThemeProvider`), and
155        // `App::reduce_motion` is spelled out rather than called as
156        // `self.reduce_motion()` because that would resolve to this very trait
157        // method through the inherent-first rule only by luck — inherent wins,
158        // so the shorthand happens to work and reads like infinite recursion.
159        App::reduce_motion(self)
160    }
161}
162
163/// Sets the global theme and schedules every open window to repaint.
164pub fn set_theme(theme: Theme, cx: &mut App) {
165    let provider = cx.global_mut::<ThemeProvider>();
166    provider.register(theme);
167    cx.refresh_windows();
168}
169
170/// Activates one of the registered themes by id (`"light"`, `"dark"`, custom)
171/// and schedules every open window to repaint.
172pub fn use_theme(id: impl Into<SharedString>, cx: &mut App) {
173    let provider = cx.global_mut::<ThemeProvider>();
174    provider.set_active(id);
175    cx.refresh_windows();
176}
177
178/// Sets the app-level reduced-motion preference — the equivalent of putting
179/// `data-reduce-motion="true"` on the document element — and schedules every
180/// open window to repaint. Every animated component honours it without opt-in,
181/// and so does every plain `gpui::Animation`: the value is stored in GPUI's own
182/// global, which its animation elements consult themselves.
183pub fn set_reduce_motion(v: bool, cx: &mut App) {
184    cx.set_reduce_motion(v);
185    // GPUI's setter already refreshes on a *change*; this repaints on a
186    // no-change write too, which is the contract `theme_repaint.rs` pins for
187    // every provider mutation.
188    cx.refresh_windows();
189}
190
191/// Flips the reduced-motion preference.
192pub fn toggle_reduce_motion(cx: &mut App) {
193    let next = !App::reduce_motion(cx);
194    set_reduce_motion(next, cx);
195}
196
197/// Follows the OS light/dark appearance for as long as `window` stays open,
198/// activating the registered `"light"` or `"dark"` theme to match.
199///
200/// Opt-in on purpose: without this call the app keeps whatever theme it
201/// activated, so pinning a single appearance stays possible. Call it once per
202/// window, from the window's root-view constructor. A custom theme pair can
203/// join in by registering under the ids `"light"` and `"dark"`, which is what
204/// the switch reads.
205///
206/// The current appearance is applied immediately, so a window opened on a dark
207/// desktop does not paint one light frame first.
208///
209/// `App::should_auto_hide_scrollbars` is read by the painted `Scrollbar`
210/// overlay: thumbs hide after idle when the OS preference is on, and stay
211/// painted when it is off.
212pub fn follow_system_appearance(window: &mut Window, cx: &mut App) {
213    // The per-window `appearance()` over `App::window_appearance()`: it is the
214    // value the observer below reports, so the immediate sync and every later
215    // sync agree, and the app-level platform query is the one that misbehaves
216    // on Linux (longbridge/gpui-kit#104).
217    let subscription = window.observe_window_appearance(|window, cx| {
218        apply_system_appearance(window.appearance(), cx);
219    });
220    let handle = window.window_handle();
221    let appearance = window.appearance();
222
223    let provider = cx.global_mut::<ThemeProvider>();
224    provider.follow_system_appearance = true;
225    // Moving the `Subscription` into the global is what keeps the observer
226    // alive; binding it to a local here would unsubscribe at the end of this
227    // function and the OS switch would silently stop arriving.
228    provider.appearance_observers.insert(handle, subscription);
229
230    apply_system_appearance(appearance, cx);
231}
232
233/// Stops following the OS appearance, leaving the active theme as it is.
234pub fn stop_following_system_appearance(cx: &mut App) {
235    let provider = cx.global_mut::<ThemeProvider>();
236    provider.follow_system_appearance = false;
237    // Dropping the subscriptions is the unsubscribe. Clearing only the flag
238    // would leave live observers calling back into a mode that is off.
239    provider.appearance_observers.clear();
240}
241
242/// Activates the theme matching one OS appearance, if the mode is still on.
243fn apply_system_appearance(appearance: WindowAppearance, cx: &mut App) {
244    let provider = ThemeProvider::get(cx);
245    if !provider.follows_system_appearance() {
246        return;
247    }
248    let id = match Appearance::from(appearance) {
249        Appearance::Light => "light",
250        Appearance::Dark => "dark",
251    };
252    // A window reports its appearance on unrelated occasions too (and every
253    // followed window reports the same OS switch), so skip the no-op rather
254    // than repainting every window once per redundant notification.
255    if provider.active_id().as_ref() == id {
256        return;
257    }
258    use_theme(id, cx);
259}
260
261/// Switches between the light and dark defaults.
262pub fn toggle_light_dark(cx: &mut App) {
263    let dark = cx.theme().is_dark();
264    let next = if dark { "light" } else { "dark" };
265    use_theme(next, cx);
266}