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}