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}