herogpui-theme 0.14.0

HeroUI design tokens and theming system for HeroGPUI
Documentation
//! Global theme provider — the HeroGPUI equivalent of `HeroUIProvider`.

use std::collections::HashMap;

use gpui::{AnyWindowHandle, App, Global, SharedString, Subscription, Window, WindowAppearance};

use crate::layout::LayoutTheme;
use crate::semantic::{RoleColor, ThemeColors};
use crate::theme::{Appearance, Theme};
use herogpui_core::Color;

/// Holds the active theme, any registered custom themes, and the opt-in
/// "follow the OS appearance" mode.
///
/// The reduced-motion preference is deliberately *not* a field here. GPUI owns
/// that flag (`App::reduce_motion`) and its own `Animation`/spring elements read
/// it directly, so a copy on this global would be a second source of truth: a
/// consumer app writing a plain `gpui::Animation`, or any GPUI internal such as
/// animated-image playback, would keep animating after HeroGPUI was told to
/// stop. `ActiveTheme::reduce_motion` therefore reads GPUI's flag and
/// [`set_reduce_motion`] writes it, so the two cannot disagree.
pub struct ThemeProvider {
    active: SharedString,
    themes: HashMap<SharedString, Theme>,
    /// Set only by [`follow_system_appearance`]; nothing else may turn it on.
    /// Following the OS is opt-in because an app that pins `Appearance::Light`
    /// through `init_with`/`use_theme` means it, and must keep it.
    follow_system_appearance: bool,
    /// One retained appearance observer per followed window, keyed so a root
    /// view that re-registers on rebuild replaces its observer instead of
    /// stacking a second one. Retention is the point: `Subscription` is
    /// unsubscribe-on-drop, so a discarded handle syncs once and then goes
    /// quiet forever — the classic failure here.
    appearance_observers: HashMap<AnyWindowHandle, Subscription>,
}

impl Global for ThemeProvider {}

impl ThemeProvider {
    /// Registers the provider with the default light theme.
    ///
    /// Call this once before opening the first window. Rendering a themed
    /// component before initialization panics.
    pub fn init(cx: &mut App) {
        Self::init_with(Theme::light(), cx);
    }

    /// Registers the provider starting from an explicit theme.
    ///
    /// Call this once before opening the first window. Rendering a themed
    /// component before initialization panics.
    ///
    /// The reduced-motion preference is left as it is: GPUI does not surface
    /// the OS `prefers-reduced-motion` setting and this crate reads no
    /// environment variable, so an application that offers the preference
    /// sets it explicitly with [`set_reduce_motion`] (before or after this
    /// call) from its own settings.
    pub fn init_with(theme: Theme, cx: &mut App) {
        let mut themes = HashMap::new();
        themes.insert("light".into(), Theme::light());
        themes.insert("dark".into(), Theme::dark());
        let id = theme.id.clone();
        themes.insert(id.clone(), theme);
        cx.set_global(Self {
            active: id,
            themes,
            follow_system_appearance: false,
            appearance_observers: HashMap::new(),
        });
    }

    /// Returns the global provider. Panics if `init` has not registered it.
    pub fn get(cx: &App) -> &Self {
        cx.global::<ThemeProvider>()
    }

    /// The active theme.
    pub fn theme(&self) -> &Theme {
        // `set_active` refuses ids that are not registered and `register`
        // inserts before it activates, so the active id always resolves.
        // Should that invariant ever break, fall back to the built-in light
        // theme rather than panicking on every frame.
        self.themes
            .get(&self.active)
            .or_else(|| self.themes.get("light"))
            .expect("the built-in light theme is registered by `init_with` and never removed")
    }

    /// Whether a theme with this id has been registered.
    pub fn contains(&self, id: &str) -> bool {
        self.themes.contains_key(id)
    }

    /// The id of the active theme.
    pub fn active_id(&self) -> &SharedString {
        &self.active
    }

    /// Registers and activates a custom theme.
    pub fn register(&mut self, theme: Theme) {
        self.active = theme.id.clone();
        self.themes.insert(theme.id.clone(), theme);
    }

    /// Registers a theme without activating it, replacing any theme with
    /// the same id. The active theme is unchanged (re-inserting the active
    /// id swaps its tokens in place).
    pub fn insert(&mut self, theme: Theme) {
        self.themes.insert(theme.id.clone(), theme);
    }

    /// The ids of every registered theme, sorted.
    pub fn theme_ids(&self) -> Vec<SharedString> {
        let mut ids: Vec<_> = self.themes.keys().cloned().collect();
        ids.sort();
        ids
    }

    /// Activates a previously registered theme by id.
    ///
    /// An id that was never registered is refused: the active theme stays as
    /// it was and the error carries the rejected id.
    pub fn set_active(&mut self, id: impl Into<SharedString>) -> Result<(), UnknownThemeError> {
        let id = id.into();
        if !self.themes.contains_key(&id) {
            return Err(UnknownThemeError { id });
        }
        self.active = id;
        Ok(())
    }

    /// Whether the OS light/dark appearance is being followed — the state a
    /// three-way "Light / Dark / System" setting needs to render itself.
    pub fn follows_system_appearance(&self) -> bool {
        self.follow_system_appearance
    }
}

/// Convenience extension trait giving every GPUI context access to the theme.
///
/// Works with `&App`, `&mut App`, `Context<T>` (they deref to `App`).
pub trait ActiveTheme {
    /// The active theme.
    fn theme(&self) -> &Theme;
    /// The active theme's semantic colors.
    fn colors(&self) -> &ThemeColors;
    /// The active theme's layout tokens.
    fn layout(&self) -> &LayoutTheme;
    /// The active theme's typed component defaults and recipes.
    fn components(&self) -> &crate::ComponentThemes;
    /// The active theme's [`RoleColor`] for a semantic [`Color`].
    fn role(&self, color: Color) -> &RoleColor;
    /// Whether the active theme's appearance is dark.
    fn is_dark_theme(&self) -> bool;
    /// Whether animations should be suppressed. Components must check this
    /// before animating; v3 requires no opt-in from the caller.
    fn reduce_motion(&self) -> bool;
}

impl ActiveTheme for App {
    fn theme(&self) -> &Theme {
        ThemeProvider::get(self).theme()
    }

    fn colors(&self) -> &ThemeColors {
        &self.theme().colors
    }

    fn layout(&self) -> &LayoutTheme {
        &self.theme().layout
    }

    fn components(&self) -> &crate::ComponentThemes {
        &self.theme().components
    }

    fn role(&self, color: Color) -> &RoleColor {
        match color {
            Color::Default => &self.colors().default,
            Color::Accent => &self.colors().accent,
            Color::Success => &self.colors().success,
            Color::Warning => &self.colors().warning,
            Color::Danger => &self.colors().danger,
        }
    }

    fn is_dark_theme(&self) -> bool {
        self.theme().is_dark()
    }

    fn reduce_motion(&self) -> bool {
        // GPUI's flag is the single source of truth (see `ThemeProvider`), and
        // `App::reduce_motion` is spelled out rather than called as
        // `self.reduce_motion()` because that would resolve to this very trait
        // method through the inherent-first rule only by luck — inherent wins,
        // so the shorthand happens to work and reads like infinite recursion.
        App::reduce_motion(self)
    }
}

/// Sets the global theme and schedules every open window to repaint.
pub fn set_theme(theme: Theme, cx: &mut App) {
    let provider = cx.global_mut::<ThemeProvider>();
    provider.register(theme);
    cx.refresh_windows();
}

/// The error [`use_theme`] and [`ThemeProvider::set_active`] return for an id
/// that no registered theme carries (ids are case-sensitive: `"Dark"` is not
/// `"dark"`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnknownThemeError {
    /// The id that was asked for.
    pub id: SharedString,
}

impl std::fmt::Display for UnknownThemeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "no theme is registered under the id {:?}",
            self.id.as_ref()
        )
    }
}

impl std::error::Error for UnknownThemeError {}

/// Activates one of the registered themes by id (`"light"`, `"dark"`, custom)
/// and schedules every open window to repaint.
///
/// An unknown id is an error, not a panic: the active theme is left unchanged,
/// no window repaints, and the rejected id comes back in
/// [`UnknownThemeError`].
pub fn use_theme(id: impl Into<SharedString>, cx: &mut App) -> Result<(), UnknownThemeError> {
    let provider = cx.global_mut::<ThemeProvider>();
    provider.set_active(id)?;
    cx.refresh_windows();
    Ok(())
}

/// Sets the app-level reduced-motion preference — the equivalent of putting
/// `data-reduce-motion="true"` on the document element — and schedules every
/// open window to repaint. Every animated component honours it without opt-in,
/// and so does every plain `gpui::Animation`: the value is stored in GPUI's own
/// global, which its animation elements consult themselves.
///
/// This is the only way the preference is set: the library reads no
/// environment variable or OS setting for it (GPUI exposes none), so an app
/// wires it to its own settings, a command-line flag, or an environment
/// variable of its choosing.
///
/// ```
/// # fn startup(cx: &mut gpui::App) {
/// herogpui_theme::ThemeProvider::init(cx);
/// // e.g. the gallery's `HEROGPUI_REDUCE_MOTION=1`:
/// if std::env::var("MY_APP_REDUCE_MOTION").is_ok_and(|v| v == "1") {
///     herogpui_theme::set_reduce_motion(true, cx);
/// }
/// # }
/// ```
pub fn set_reduce_motion(v: bool, cx: &mut App) {
    cx.set_reduce_motion(v);
    // GPUI's setter already refreshes on a *change*; this repaints on a
    // no-change write too, which is the contract `theme_repaint.rs` pins for
    // every provider mutation.
    cx.refresh_windows();
}

/// Flips the reduced-motion preference.
pub fn toggle_reduce_motion(cx: &mut App) {
    let next = !App::reduce_motion(cx);
    set_reduce_motion(next, cx);
}

/// Follows the OS light/dark appearance for as long as `window` stays open,
/// activating the registered `"light"` or `"dark"` theme to match.
///
/// Opt-in on purpose: without this call the app keeps whatever theme it
/// activated, so pinning a single appearance stays possible. Call it once per
/// window, from the window's root-view constructor. A custom theme pair can
/// join in by registering under the ids `"light"` and `"dark"`, which is what
/// the switch reads.
///
/// The current appearance is applied immediately, so a window opened on a dark
/// desktop does not paint one light frame first.
///
/// `App::should_auto_hide_scrollbars` is read by the painted `Scrollbar`
/// overlay: thumbs hide after idle when the OS preference is on, and stay
/// painted when it is off.
pub fn follow_system_appearance(window: &mut Window, cx: &mut App) {
    // The per-window `appearance()` over `App::window_appearance()`: it is the
    // value the observer below reports, so the immediate sync and every later
    // sync agree, and the app-level platform query is the one that misbehaves
    // on Linux (longbridge/gpui-kit#104).
    let subscription = window.observe_window_appearance(|window, cx| {
        apply_system_appearance(window.appearance(), cx);
    });
    let handle = window.window_handle();
    let appearance = window.appearance();

    let provider = cx.global_mut::<ThemeProvider>();
    provider.follow_system_appearance = true;
    // Moving the `Subscription` into the global is what keeps the observer
    // alive; binding it to a local here would unsubscribe at the end of this
    // function and the OS switch would silently stop arriving.
    provider.appearance_observers.insert(handle, subscription);

    apply_system_appearance(appearance, cx);
}

/// Stops following the OS appearance, leaving the active theme as it is.
pub fn stop_following_system_appearance(cx: &mut App) {
    let provider = cx.global_mut::<ThemeProvider>();
    provider.follow_system_appearance = false;
    // Dropping the subscriptions is the unsubscribe. Clearing only the flag
    // would leave live observers calling back into a mode that is off.
    provider.appearance_observers.clear();
}

/// Activates the theme matching one OS appearance, if the mode is still on.
fn apply_system_appearance(appearance: WindowAppearance, cx: &mut App) {
    let provider = ThemeProvider::get(cx);
    if !provider.follows_system_appearance() {
        return;
    }
    let id = match Appearance::from(appearance) {
        Appearance::Light => "light",
        Appearance::Dark => "dark",
    };
    // A window reports its appearance on unrelated occasions too (and every
    // followed window reports the same OS switch), so skip the no-op rather
    // than repainting every window once per redundant notification.
    if provider.active_id().as_ref() == id {
        return;
    }
    // Both ids are registered by `init_with`; a custom pair replaces them
    // under the same ids, so this cannot miss.
    let _ = use_theme(id, cx);
}

/// Switches between the light and dark defaults.
pub fn toggle_light_dark(cx: &mut App) {
    let dark = cx.theme().is_dark();
    let next = if dark { "light" } else { "dark" };
    // Registered by `init_with`, so this cannot miss.
    let _ = use_theme(next, cx);
}

#[cfg(test)]
mod tests {
    use super::*;
    use gpui::TestAppContext;

    /// `init` must not own the preference: a value the app set first (from
    /// its settings, before the provider existed) survives initialization,
    /// and a default app stays `false`.
    #[gpui::test]
    fn init_leaves_the_reduce_motion_preference_alone(cx: &mut TestAppContext) {
        cx.update(|cx| {
            ThemeProvider::init(cx);
            assert!(!cx.reduce_motion());
        });
        cx.update(|cx| {
            cx.set_reduce_motion(true);
            ThemeProvider::init_with(Theme::dark(), cx);
            assert!(cx.reduce_motion());
            set_reduce_motion(false, cx);
            assert!(!ActiveTheme::reduce_motion(cx));
        });
    }
}