Skip to main content

theme/
appearance.rs

1//! Light/dark switching: what the user asked for, what the OS reports, and the
2//! plumbing that turns a change in either into a repaint.
3//!
4//! Three pieces, following the pattern zed uses (`crates/theme/src/theme.rs`
5//! `SystemAppearance` + `reload_theme` + `cx.refresh_windows`):
6//!
7//! 1. [`AppearanceMode`] — the persisted user choice: follow the OS, or pin one.
8//! 2. [`AppearanceState`] — a gpui global holding that choice alongside the last
9//!    appearance the OS reported, so [`resolve`] can combine them.
10//! 3. [`observe_window`] — subscribes to the platform's appearance notification
11//!    (macOS `viewDidChangeEffectiveAppearance`) and re-applies.
12//!
13//! # Why `refresh_windows` and not `notify`
14//!
15//! Colors are read *imperatively* (`Theme::of(cx).text`) at paint time, not
16//! through a reactive binding, so no view knows its colors went stale — a
17//! `notify()` on some entity would repaint that entity and nothing else.
18//! [`App::refresh_windows`] marks every window dirty *and* disables gpui's
19//! per-view prepaint cache for the frame, which is the only thing that forces
20//! already-laid-out elements to re-run their paint with the new palette.
21
22use crate::{Appearance, Theme};
23use gpui::{App, Global, Subscription, Window};
24use serde::{Deserialize, Serialize};
25
26/// The user's appearance preference. Serde-serializable so callers can persist
27/// it wherever their settings live; this crate never touches disk.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub enum AppearanceMode {
31    /// Follow the OS. The default — matches every other native app on the
32    /// machine, including when the user has macOS set to switch at sunset.
33    #[default]
34    System,
35    Light,
36    Dark,
37}
38
39impl AppearanceMode {
40    /// Menu/label text.
41    pub fn label(self) -> &'static str {
42        match self {
43            Self::System => "System",
44            Self::Light => "Light",
45            Self::Dark => "Dark",
46        }
47    }
48
49    pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
50}
51
52/// Global state behind the current theme: what the user chose, and what the OS
53/// last said. Kept separate from [`Theme`] itself so that flipping the OS
54/// appearance while the user has pinned Light still records the new system value
55/// (and takes effect the moment they switch back to `System`).
56pub struct AppearanceState {
57    pub mode: AppearanceMode,
58    pub system: Appearance,
59}
60
61impl Global for AppearanceState {}
62
63/// Combine the user's choice with the OS state.
64pub fn resolve(mode: AppearanceMode, system: Appearance) -> Appearance {
65    match mode {
66        AppearanceMode::System => system,
67        AppearanceMode::Light => Appearance::Light,
68        AppearanceMode::Dark => Appearance::Dark,
69    }
70}
71
72/// Install the appearance globals and the matching theme. Call once at boot,
73/// before any window opens, so the first frame is already the right palette
74/// (installing later produces a visible dark-to-light flash).
75pub fn init(mode: AppearanceMode, cx: &mut App) {
76    let system = Appearance::from_window(cx.window_appearance());
77    tracing::debug!(?mode, ?system, "appearance: initial");
78    cx.set_global(AppearanceState { mode, system });
79    sync_ns_appearance(mode);
80    Theme::install(resolve(mode, system), cx);
81}
82
83/// The mode currently in effect (defaults to `System` before [`init`]).
84pub fn mode(cx: &App) -> AppearanceMode {
85    cx.try_global::<AppearanceState>()
86        .map(|s| s.mode)
87        .unwrap_or_default()
88}
89
90/// Change the user's preference and repaint if that changed the palette.
91/// Persisting the choice is the caller's job.
92pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
93    if !cx.has_global::<AppearanceState>() {
94        return;
95    }
96    let state = cx.global_mut::<AppearanceState>();
97    if state.mode == mode {
98        return;
99    }
100    state.mode = mode;
101    apply(cx);
102}
103
104/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
105/// must outlive the window — callers typically `.detach()` it.
106///
107/// The notification is *per window*, but the appearance it reports is a system
108/// setting, so any one window is enough to learn about the change; re-applying
109/// is idempotent when several fire.
110pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
111    // Reconcile against the *window's* appearance before subscribing.
112    //
113    // [`init`] runs before any window exists and can only ask the platform
114    // (`App::window_appearance`), which on macOS reads `NSApp.effectiveAppearance`
115    // — and that is not reliably populated that early in launch. When it guesses
116    // wrong the app paints the wrong palette until some unrelated event happens to
117    // fire the appearance notification, which reads as "it booted dark and fixed
118    // itself when I clicked something". The window knows for certain, so ask it.
119    sync(Appearance::from_window(window.appearance()), cx);
120    window.observe_window_appearance(|window, cx| {
121        sync(Appearance::from_window(window.appearance()), cx);
122    })
123}
124
125/// Record the OS appearance and re-apply if it moved.
126fn sync(system: Appearance, cx: &mut App) {
127    if !cx.has_global::<AppearanceState>() {
128        return;
129    }
130    let state = cx.global_mut::<AppearanceState>();
131    if state.system == system {
132        return;
133    }
134    tracing::debug!(?system, "appearance: system changed");
135    state.system = system;
136    apply(cx);
137}
138
139/// Re-resolve the palette and, if it moved, swap the theme and force a full
140/// repaint. A no-op when the resolved appearance is unchanged — the OS fires the
141/// notification for vibrancy and accent-color changes too, and repainting every
142/// window for those would be a visible hitch for nothing.
143pub fn apply(cx: &mut App) {
144    let Some(state) = cx.try_global::<AppearanceState>() else {
145        return;
146    };
147    sync_ns_appearance(state.mode);
148    let wanted = resolve(state.mode, state.system);
149    let changed = !cx
150        .try_global::<Theme>()
151        .is_some_and(|t| t.appearance == wanted);
152    if changed {
153        tracing::debug!(?wanted, "appearance: installing palette");
154        Theme::install(wanted, cx);
155        cx.refresh_windows();
156    }
157    // Unconditional, even when the palette did not move: this is the only thing
158    // that keeps macOS vibrancy alive. gpui's macOS backend removes the
159    // `NSVisualEffectView` from the window the moment the background appearance
160    // is anything but `Blurred`, and nothing puts it back on its own — so a
161    // single missed re-apply leaves the sidebar and tab strip permanently
162    // opaque, which is exactly how the frost died. zed runs the same loop on
163    // every settings change (`crates/zed/src/main.rs`).
164    reapply_window_background(cx);
165}
166
167/// Tell AppKit which appearance the app's windows use, so the chrome *it*
168/// draws — the traffic lights above all — matches the palette *we* paint.
169/// gpui never sets `NSAppearance`, so before this a pinned in-app theme left
170/// the window chrome following the OS setting: a light window rendered
171/// dark-appearance inactive traffic lights when the system was dark (user
172/// report). Pinned modes name the appearance explicitly; `System` clears the
173/// override (`setAppearance: nil`) so AppKit follows the OS again — resolving
174/// to a name there too would freeze the chrome across OS sunset switches
175/// until our own notification round-trip repainted it.
176#[cfg(target_os = "macos")]
177fn sync_ns_appearance(mode: AppearanceMode) {
178    use objc::{class, msg_send, runtime::Object, sel, sel_impl};
179    // NSAppearanceName constants are NSStrings whose value equals the
180    // constant's own name (AppKit documents them as stable identifiers), so
181    // building them from literals avoids linking the extern statics.
182    let name = match mode {
183        AppearanceMode::System => None,
184        AppearanceMode::Light => Some(c"NSAppearanceNameAqua"),
185        AppearanceMode::Dark => Some(c"NSAppearanceNameDarkAqua"),
186    };
187    unsafe {
188        let appearance: *mut Object = match name {
189            None => std::ptr::null_mut(),
190            Some(name) => {
191                let name: *mut Object =
192                    msg_send![class!(NSString), stringWithUTF8String: name.as_ptr()];
193                msg_send![class!(NSAppearance), appearanceNamed: name]
194            }
195        };
196        let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
197        let _: () = msg_send![app, setAppearance: appearance];
198    }
199}
200
201#[cfg(not(target_os = "macos"))]
202fn sync_ns_appearance(_mode: AppearanceMode) {}
203
204/// Push the theme's window background appearance onto every open window.
205pub fn reapply_window_background(cx: &mut App) {
206    let Some(wanted) = cx
207        .try_global::<Theme>()
208        .map(|theme| theme.window_background_appearance())
209    else {
210        return;
211    };
212    for window in cx.windows() {
213        window
214            .update(cx, |_, window, _| {
215                window.set_background_appearance(wanted);
216            })
217            .ok();
218    }
219}