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, WindowId};
24use serde::{Deserialize, Serialize};
25use std::collections::HashSet;
26
27/// The user's appearance preference. Serde-serializable so callers can persist
28/// it wherever their settings live; this crate never touches disk.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub enum AppearanceMode {
32    /// Follow the OS. The default — matches every other native app on the
33    /// machine, including when the user has macOS set to switch at sunset.
34    #[default]
35    System,
36    Light,
37    Dark,
38}
39
40impl AppearanceMode {
41    /// Menu/label text.
42    pub fn label(self) -> &'static str {
43        match self {
44            Self::System => "System",
45            Self::Light => "Light",
46            Self::Dark => "Dark",
47        }
48    }
49
50    pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
51}
52
53/// Global state behind the current theme: what the user chose, and what the OS
54/// last said. Kept separate from [`Theme`] itself so that flipping the OS
55/// appearance while the user has pinned Light still records the new system value
56/// (and takes effect the moment they switch back to `System`).
57pub struct AppearanceState {
58    pub mode: AppearanceMode,
59    pub system: Appearance,
60}
61
62impl Global for AppearanceState {}
63
64/// Combine the user's choice with the OS state.
65pub fn resolve(mode: AppearanceMode, system: Appearance) -> Appearance {
66    match mode {
67        AppearanceMode::System => system,
68        AppearanceMode::Light => Appearance::Light,
69        AppearanceMode::Dark => Appearance::Dark,
70    }
71}
72
73/// Install the appearance globals and the matching theme. Call once at boot,
74/// before any window opens, so the first frame is already the right palette
75/// (installing later produces a visible dark-to-light flash).
76pub fn init(mode: AppearanceMode, cx: &mut App) {
77    let system = Appearance::from_window(cx.window_appearance());
78    tracing::debug!(?mode, ?system, "appearance: initial");
79    cx.set_global(AppearanceState { mode, system });
80    sync_ns_appearance(mode);
81    Theme::install(resolve(mode, system), cx);
82}
83
84/// The mode currently in effect (defaults to `System` before [`init`]).
85pub fn mode(cx: &App) -> AppearanceMode {
86    cx.try_global::<AppearanceState>()
87        .map(|s| s.mode)
88        .unwrap_or_default()
89}
90
91/// Change the user's preference and repaint if that changed the palette.
92/// Persisting the choice is the caller's job.
93pub fn set_mode(mode: AppearanceMode, cx: &mut App) {
94    if !cx.has_global::<AppearanceState>() {
95        return;
96    }
97    let state = cx.global_mut::<AppearanceState>();
98    if state.mode == mode {
99        return;
100    }
101    state.mode = mode;
102    // Coming back to `System`, ask the OS what it actually is before resolving.
103    // A pinned mode holds an `NSAppearance` over the app, and everything the
104    // platform reports while one is up is that override read back — so
105    // [`sync`] has been declining to record it and `system` is as stale as the
106    // moment the mode was pinned. Clearing it here is what makes the read
107    // honest; `apply` sets the same (cleared) override again a line later,
108    // which keeps `sync_ns_appearance` the one place that owns it.
109    if mode == AppearanceMode::System {
110        sync_ns_appearance(mode);
111        let system = Appearance::from_window(cx.window_appearance());
112        cx.global_mut::<AppearanceState>().system = system;
113    }
114    apply(cx);
115}
116
117/// Subscribe a window to OS appearance changes. The returned [`Subscription`]
118/// must outlive the window — callers typically `.detach()` it.
119///
120/// The notification is *per window*, but the appearance it reports is a system
121/// setting, so any one window is enough to learn about the change; re-applying
122/// is idempotent when several fire.
123pub fn observe_window(window: &mut Window, cx: &mut App) -> Subscription {
124    // Reconcile against the *window's* appearance before subscribing.
125    //
126    // [`init`] runs before any window exists and can only ask the platform
127    // (`App::window_appearance`), which on macOS reads `NSApp.effectiveAppearance`
128    // — and that is not reliably populated that early in launch. When it guesses
129    // wrong the app paints the wrong palette until some unrelated event happens to
130    // fire the appearance notification, which reads as "it booted dark and fixed
131    // itself when I clicked something". The window knows for certain, so ask it.
132    sync(Appearance::from_window(window.appearance()), cx);
133    window.observe_window_appearance(|window, cx| {
134        sync(Appearance::from_window(window.appearance()), cx);
135    })
136}
137
138/// Whether what a window reports is the OS's own answer.
139///
140/// A pinned mode holds an `NSAppearance` over the app — see
141/// [`sync_ns_appearance`] — and from then on every window reports that
142/// override back. Only under `System` is there none in the way.
143pub fn reports_the_os(mode: AppearanceMode) -> bool {
144    matches!(mode, AppearanceMode::System)
145}
146
147/// Record the OS appearance and re-apply if it moved.
148///
149/// Only what [`reports_the_os`] will vouch for: recording an override read
150/// back would overwrite what the OS said with what we asked for, and the first
151/// switch to `System` would resolve to the mode just left. Nothing is lost by
152/// skipping — a pinned mode ignores the OS anyway, and [`set_mode`] re-reads it
153/// on the way back.
154fn sync(system: Appearance, cx: &mut App) {
155    if !cx.has_global::<AppearanceState>() {
156        return;
157    }
158    let state = cx.global_mut::<AppearanceState>();
159    if !reports_the_os(state.mode) || state.system == system {
160        return;
161    }
162    tracing::debug!(?system, "appearance: system changed");
163    state.system = system;
164    apply(cx);
165}
166
167/// Re-resolve the palette and, if it moved, swap the theme and force a full
168/// repaint. A no-op when the resolved appearance is unchanged — the OS fires the
169/// notification for vibrancy and accent-color changes too, and repainting every
170/// window for those would be a visible hitch for nothing.
171pub fn apply(cx: &mut App) {
172    let Some(state) = cx.try_global::<AppearanceState>() else {
173        return;
174    };
175    sync_ns_appearance(state.mode);
176    let wanted = resolve(state.mode, state.system);
177    let changed = !cx
178        .try_global::<Theme>()
179        .is_some_and(|t| t.appearance == wanted);
180    if changed {
181        tracing::debug!(?wanted, "appearance: installing palette");
182        Theme::install(wanted, cx);
183        cx.refresh_windows();
184    }
185    // Unconditional, even when the palette did not move: this is the only thing
186    // that keeps macOS vibrancy alive. gpui's macOS backend removes the
187    // `NSVisualEffectView` from the window the moment the background appearance
188    // is anything but `Blurred`, and nothing puts it back on its own — so a
189    // single missed re-apply leaves the sidebar and tab strip permanently
190    // opaque, which is exactly how the frost died. zed runs the same loop on
191    // every settings change (`crates/zed/src/main.rs`).
192    reapply_window_background(cx);
193}
194
195/// Tell AppKit which appearance the app's windows use, so the chrome *it*
196/// draws — the traffic lights above all — matches the palette *we* paint.
197/// gpui never sets `NSAppearance`, so before this a pinned in-app theme left
198/// the window chrome following the OS setting: a light window rendered
199/// dark-appearance inactive traffic lights when the system was dark (user
200/// report). Pinned modes name the appearance explicitly; `System` clears the
201/// override (`setAppearance: nil`) so AppKit follows the OS again — resolving
202/// to a name there too would freeze the chrome across OS sunset switches
203/// until our own notification round-trip repainted it.
204#[cfg(target_os = "macos")]
205fn sync_ns_appearance(mode: AppearanceMode) {
206    use objc::{class, msg_send, runtime::Object, sel, sel_impl};
207    // NSAppearanceName constants are NSStrings whose value equals the
208    // constant's own name (AppKit documents them as stable identifiers), so
209    // building them from literals avoids linking the extern statics.
210    let name = match mode {
211        AppearanceMode::System => None,
212        AppearanceMode::Light => Some(c"NSAppearanceNameAqua"),
213        AppearanceMode::Dark => Some(c"NSAppearanceNameDarkAqua"),
214    };
215    unsafe {
216        let appearance: *mut Object = match name {
217            None => std::ptr::null_mut(),
218            Some(name) => {
219                let name: *mut Object =
220                    msg_send![class!(NSString), stringWithUTF8String: name.as_ptr()];
221                msg_send![class!(NSAppearance), appearanceNamed: name]
222            }
223        };
224        let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
225        let _: () = msg_send![app, setAppearance: appearance];
226    }
227}
228
229#[cfg(not(target_os = "macos"))]
230fn sync_ns_appearance(_mode: AppearanceMode) {}
231
232/// Windows that keep the background they opened with. See [`keep_background`].
233#[derive(Default)]
234struct KeepBackground(HashSet<WindowId>);
235
236impl Global for KeepBackground {}
237
238/// Leave this window's background where it is, whatever the palette says.
239///
240/// [`reapply_window_background`] reaches every open window, which is what keeps
241/// vibrancy alive across an appearance switch. A window that is opaque *on
242/// purpose* — a settings form the app behind it must not show through — says so
243/// here rather than being frosted by the next switch.
244pub fn keep_background(window: &Window, cx: &mut App) {
245    let id = window.window_handle().window_id();
246    cx.default_global::<KeepBackground>().0.insert(id);
247}
248
249/// Push the theme's window background appearance onto every open window, bar
250/// the ones that asked to keep their own.
251pub fn reapply_window_background(cx: &mut App) {
252    let Some(wanted) = cx
253        .try_global::<Theme>()
254        .map(|theme| theme.window_background_appearance())
255    else {
256        return;
257    };
258    let windows = cx.windows();
259    let keep = if cx.has_global::<KeepBackground>() {
260        let keep = cx.global_mut::<KeepBackground>();
261        // The only place a closed window's id is dropped, which is enough:
262        // `windows` is a handful, and the set is read here and nowhere else.
263        keep.0
264            .retain(|id| windows.iter().any(|window| window.window_id() == *id));
265        keep.0.clone()
266    } else {
267        HashSet::new()
268    };
269    for window in windows {
270        if keep.contains(&window.window_id()) {
271            continue;
272        }
273        // A window cannot be updated from inside its own update — gpui takes
274        // it out of its slot for the duration — and the OS appearance
275        // notification arrives exactly that way, inside the observing window's
276        // update. Pushing straight through would skip the one window that just
277        // changed and leave it on the old background until something else set
278        // one. Deferring runs it as the update unwinds, still before the frame.
279        if window
280            .update(cx, |_, window, _| {
281                window.set_background_appearance(wanted);
282            })
283            .is_err()
284        {
285            cx.defer(move |cx| {
286                window
287                    .update(cx, |_, window, _| {
288                        window.set_background_appearance(wanted);
289                    })
290                    .ok();
291            });
292        }
293    }
294}