From c0cb5600f5e6e9f60bfc9e32312076390a3788af Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 9 Jun 2026 15:43:19 +0800 Subject: [PATCH 1/4] gpui: Add App::set_window_appearance to override the macOS app appearance Add `App::set_window_appearance(Option)`, the setter paired with the existing `App::window_appearance()` getter. `Some(_)` forces a light/dark appearance; `None` clears the override and follows the system again. On macOS this sets `NSApplication.appearance`, which controls the native window chrome (the window border and titlebar) of every window, so a dark-themed app no longer shows a light, washed-out window border when the system is in light mode. Setting it on the application (rather than per window) matches AppKit's app-wide appearance model, so every window inherits it. No-op on other platforms. Demo: `cargo run -p gpui --example window_appearance`. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/gpui/examples/window_appearance.rs | 202 ++++++++++++++++++++++ crates/gpui/src/app.rs | 14 ++ crates/gpui/src/platform.rs | 10 ++ crates/gpui_macos/src/platform.rs | 28 ++- 4 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 crates/gpui/examples/window_appearance.rs diff --git a/crates/gpui/examples/window_appearance.rs b/crates/gpui/examples/window_appearance.rs new file mode 100644 index 00000000000000..4e3ce00f013d3c --- /dev/null +++ b/crates/gpui/examples/window_appearance.rs @@ -0,0 +1,202 @@ +//! Window appearance demo. +//! +//! Run with: `cargo run -p gpui --example window_appearance` +//! +//! This app demonstrates [`App::set_window_appearance`], which overrides the native +//! window chrome (the window border and the titlebar) of every window to be light +//! or dark independent of the OS-wide setting. +//! +//! To see the effect on macOS: set the system to Light mode, then click "Dark". +//! The window's border and titlebar should switch to dark to match a dark theme, +//! instead of staying light. Click "Auto" to follow the system again. + +#![cfg_attr(target_family = "wasm", no_main)] + +use gpui::{ + App, Bounds, Context, Rgba, Window, WindowAppearance, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +/// A palette whose colors switch together so the whole UI re-themes when the +/// appearance changes. +struct Palette { + bg: Rgba, + fg: Rgba, + muted: Rgba, + accent: Rgba, + accent_fg: Rgba, + control: Rgba, +} + +impl Palette { + fn new(is_dark: bool) -> Self { + if is_dark { + Self { + bg: rgb(0x1e1e1e), + fg: rgb(0xf4f4f5), + muted: rgb(0x9a9a9a), + accent: rgb(0x0059d1), + accent_fg: rgb(0xffffff), + control: rgb(0x2f2f2f), + } + } else { + Self { + bg: rgb(0xffffff), + fg: rgb(0x18181b), + muted: rgb(0x6a6a72), + accent: rgb(0x0076f7), + accent_fg: rgb(0xffffff), + control: rgb(0xe4e4e4), + } + } + } +} + +struct AppearanceExample { + /// The forced appearance, or `None` to follow the system. + selected: Option, +} + +impl AppearanceExample { + fn button( + &self, + label: &'static str, + value: Option, + palette: &Palette, + cx: &mut Context, + ) -> impl IntoElement { + let selected = self.selected == value; + let (bg, fg) = if selected { + (palette.accent, palette.accent_fg) + } else { + (palette.control, palette.fg) + }; + + div() + .id(label) + .flex() + .items_center() + .justify_center() + .px_4() + .py_1() + .text_sm() + .rounded_md() + .cursor_pointer() + .bg(bg) + .text_color(fg) + .child(label) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.selected = value; + cx.set_window_appearance(value); + cx.notify(); + })) + } +} + +impl Render for AppearanceExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let appearance = window.appearance(); + // Theme the UI from the selection so the content re-themes immediately on click; + // when following the system (`None`), use the effective appearance. + let is_dark = match self.selected { + Some(WindowAppearance::Dark | WindowAppearance::VibrantDark) => true, + Some(_) => false, + None => matches!( + appearance, + WindowAppearance::Dark | WindowAppearance::VibrantDark + ), + }; + let palette = Palette::new(is_dark); + let selected_label = match self.selected { + None => "Auto", + Some(WindowAppearance::Light | WindowAppearance::VibrantLight) => "Light", + Some(WindowAppearance::Dark | WindowAppearance::VibrantDark) => "Dark", + }; + + div() + .flex() + .size_full() + .justify_center() + .items_center() + .bg(palette.bg) + .text_color(palette.fg) + .child( + div() + .flex() + .flex_col() + .gap_4() + .w(px(340.)) + .p_6() + .child(div().text_xl().child("Window Appearance")) + .child( + div() + .flex() + .flex_col() + .gap_1() + .text_sm() + .text_color(palette.muted) + .child(format!("Selected: {selected_label}")) + .child(format!("Effective appearance: {appearance:?}")), + ) + .child( + div() + .flex() + .gap_2() + .child(self.button("Auto", None, &palette, cx)) + .child( + self.button("Light", Some(WindowAppearance::Light), &palette, cx), + ) + .child(self.button("Dark", Some(WindowAppearance::Dark), &palette, cx)), + ) + .child( + div() + .text_xs() + .text_color(palette.muted) + .child( + "Set the system to Light mode, then choose Dark: the native \ + window border and titlebar switch to dark to match.", + ), + ), + ) + } +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(440.), px(380.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| { + cx.new(|cx| { + // Re-render when the effective appearance changes, so the labels + // stay accurate while in `Auto` mode and the system theme toggles. + cx.observe_window_appearance(window, |_, _, cx| { + cx.notify(); + }) + .detach(); + AppearanceExample { + selected: None, + } + }) + }, + ) + .unwrap(); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 3631499fa98ffc..aab5092074ef37 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1244,6 +1244,20 @@ impl App { self.platform.window_appearance() } + /// Overrides the appearance (light/dark) applied to the app's windows, independent of + /// the OS-wide setting. Pass `None` to clear the override and follow the system again. + /// The current value is reported by [`App::window_appearance`]. + /// + /// On macOS this sets the underlying `NSApplication.appearance`, which controls the + /// native window chrome (the window border and titlebar) of every window. Use this + /// when the app uses a dark theme while the system is in light mode (or vice versa) + /// so the window edges render to match the theme. While an appearance is forced, + /// windows stop tracking system light/dark changes; pass `None` to resume following + /// the system. On other platforms this is a no-op. + pub fn set_window_appearance(&self, appearance: Option) { + self.platform.set_window_appearance(appearance); + } + /// Returns the window button layout configuration when supported. pub fn button_layout(&self) -> Option { self.platform.button_layout() diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 06072b0d5e61e6..17847e0c33e874 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -158,6 +158,16 @@ pub trait Platform: 'static { /// Returns the appearance of the application's windows. fn window_appearance(&self) -> WindowAppearance; + /// Overrides the appearance (light/dark) applied to the app's windows, independent + /// of the OS-wide setting. Pass `None` to clear the override and follow the system + /// again. The override is reflected by [`Platform::window_appearance`]. + /// + /// Currently only implemented on macOS, where it sets `NSApplication.appearance` so + /// the native window chrome (the window border and titlebar) of every window matches + /// a dark app theme even when the system is in light mode (or vice versa). A no-op on + /// other platforms. + fn set_window_appearance(&self, _appearance: Option) {} + /// Returns the window button layout configuration when supported. fn button_layout(&self) -> Option { None diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 87346991d64750..6ec28344faab1d 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -7,9 +7,10 @@ use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ - NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSControl as _, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, - NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, + NSAppearanceNameVibrantDark, NSAppearanceNameVibrantLight, NSApplication, + NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, NSControl as _, + NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, + NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ @@ -655,6 +656,27 @@ impl Platform for MacPlatform { } } + fn set_window_appearance(&self, appearance: Option) { + unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + // `None` clears the override by setting a nil appearance, so the app + // falls back to tracking the system-wide light/dark setting. + let ns_appearance: id = match appearance { + None => nil, + Some(appearance) => { + let name: id = match appearance { + WindowAppearance::Light => crate::window_appearance::NSAppearanceNameAqua, + WindowAppearance::Dark => crate::window_appearance::NSAppearanceNameDarkAqua, + WindowAppearance::VibrantLight => NSAppearanceNameVibrantLight, + WindowAppearance::VibrantDark => NSAppearanceNameVibrantDark, + }; + msg_send![class!(NSAppearance), appearanceNamed: name] + } + }; + let _: () = msg_send![app, setAppearance: ns_appearance]; + } + } + fn open_url(&self, url: &str) { unsafe { let ns_url = NSURL::alloc(nil).initWithString_(ns_string(url)); From 5f5684240f851fd1ab16147308470976bf7be709 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 9 Jun 2026 15:59:51 +0800 Subject: [PATCH 2/4] Match the macOS app appearance to the selected theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive App::set_window_appearance from the theme at startup so the native window chrome (the window border and titlebar) matches the selected theme. An explicit light/dark theme (or a static theme) forces the matching appearance; System follows the OS. Wired via a global settings observer, so every window — including the settings window — stays in sync. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/zed/src/zed.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1b5e00c68d526b..b6fa9f23b8f998 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -400,6 +400,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { .detach(); init_cursor_hide_mode(cx); + init_app_appearance(cx); cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| { let Some(window) = window else { @@ -1959,6 +1960,24 @@ fn init_cursor_hide_mode(cx: &mut App) { cx.observe_global::(apply).detach(); } +fn init_app_appearance(cx: &mut App) { + // Force the native window chrome (border + titlebar) to match the selected theme. + // `System` follows the OS (no override); any other theme forces its appearance, so a + // dark theme doesn't render a light window border when the system is in light mode. + let apply = |cx: &mut App| { + let appearance = match ThemeSettings::get_global(cx).theme.mode() { + Some(theme_settings::ThemeAppearanceMode::System) => None, + _ => Some(match cx.theme().appearance() { + theme::Appearance::Light => gpui::WindowAppearance::Light, + theme::Appearance::Dark => gpui::WindowAppearance::Dark, + }), + }; + cx.set_window_appearance(appearance); + }; + apply(cx); + cx.observe_global::(apply).detach(); +} + /// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and /// surfaces any read errors using the same notification UI as settings errors. /// From fb1e2191290571c3de389fcc9ef1ea5ec668ef99 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 9 Jun 2026 21:48:16 +0800 Subject: [PATCH 3/4] Fix lint --- crates/gpui/examples/window_appearance.rs | 24 ++++++++++------------- crates/gpui_macos/src/platform.rs | 4 +++- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/crates/gpui/examples/window_appearance.rs b/crates/gpui/examples/window_appearance.rs index 4e3ce00f013d3c..619b26ba21f7af 100644 --- a/crates/gpui/examples/window_appearance.rs +++ b/crates/gpui/examples/window_appearance.rs @@ -144,20 +144,18 @@ impl Render for AppearanceExample { .flex() .gap_2() .child(self.button("Auto", None, &palette, cx)) - .child( - self.button("Light", Some(WindowAppearance::Light), &palette, cx), - ) + .child(self.button( + "Light", + Some(WindowAppearance::Light), + &palette, + cx, + )) .child(self.button("Dark", Some(WindowAppearance::Dark), &palette, cx)), ) - .child( - div() - .text_xs() - .text_color(palette.muted) - .child( - "Set the system to Light mode, then choose Dark: the native \ + .child(div().text_xs().text_color(palette.muted).child( + "Set the system to Light mode, then choose Dark: the native \ window border and titlebar switch to dark to match.", - ), - ), + )), ) } } @@ -178,9 +176,7 @@ fn run_example() { cx.notify(); }) .detach(); - AppearanceExample { - selected: None, - } + AppearanceExample { selected: None } }) }, ) diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 6ec28344faab1d..de5c2f5f6534f8 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -666,7 +666,9 @@ impl Platform for MacPlatform { Some(appearance) => { let name: id = match appearance { WindowAppearance::Light => crate::window_appearance::NSAppearanceNameAqua, - WindowAppearance::Dark => crate::window_appearance::NSAppearanceNameDarkAqua, + WindowAppearance::Dark => { + crate::window_appearance::NSAppearanceNameDarkAqua + } WindowAppearance::VibrantLight => NSAppearanceNameVibrantLight, WindowAppearance::VibrantDark => NSAppearanceNameVibrantDark, }; From 66d95fb1945b7a7be671427f11ebfb42c339bdb4 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 28 Jul 2026 16:15:58 +0800 Subject: [PATCH 4/4] Remove crates/gpui/examples/window_appearance.rs --- crates/gpui/examples/window_appearance.rs | 198 ---------------------- 1 file changed, 198 deletions(-) delete mode 100644 crates/gpui/examples/window_appearance.rs diff --git a/crates/gpui/examples/window_appearance.rs b/crates/gpui/examples/window_appearance.rs deleted file mode 100644 index 619b26ba21f7af..00000000000000 --- a/crates/gpui/examples/window_appearance.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Window appearance demo. -//! -//! Run with: `cargo run -p gpui --example window_appearance` -//! -//! This app demonstrates [`App::set_window_appearance`], which overrides the native -//! window chrome (the window border and the titlebar) of every window to be light -//! or dark independent of the OS-wide setting. -//! -//! To see the effect on macOS: set the system to Light mode, then click "Dark". -//! The window's border and titlebar should switch to dark to match a dark theme, -//! instead of staying light. Click "Auto" to follow the system again. - -#![cfg_attr(target_family = "wasm", no_main)] - -use gpui::{ - App, Bounds, Context, Rgba, Window, WindowAppearance, WindowBounds, WindowOptions, div, - prelude::*, px, rgb, size, -}; -use gpui_platform::application; - -/// A palette whose colors switch together so the whole UI re-themes when the -/// appearance changes. -struct Palette { - bg: Rgba, - fg: Rgba, - muted: Rgba, - accent: Rgba, - accent_fg: Rgba, - control: Rgba, -} - -impl Palette { - fn new(is_dark: bool) -> Self { - if is_dark { - Self { - bg: rgb(0x1e1e1e), - fg: rgb(0xf4f4f5), - muted: rgb(0x9a9a9a), - accent: rgb(0x0059d1), - accent_fg: rgb(0xffffff), - control: rgb(0x2f2f2f), - } - } else { - Self { - bg: rgb(0xffffff), - fg: rgb(0x18181b), - muted: rgb(0x6a6a72), - accent: rgb(0x0076f7), - accent_fg: rgb(0xffffff), - control: rgb(0xe4e4e4), - } - } - } -} - -struct AppearanceExample { - /// The forced appearance, or `None` to follow the system. - selected: Option, -} - -impl AppearanceExample { - fn button( - &self, - label: &'static str, - value: Option, - palette: &Palette, - cx: &mut Context, - ) -> impl IntoElement { - let selected = self.selected == value; - let (bg, fg) = if selected { - (palette.accent, palette.accent_fg) - } else { - (palette.control, palette.fg) - }; - - div() - .id(label) - .flex() - .items_center() - .justify_center() - .px_4() - .py_1() - .text_sm() - .rounded_md() - .cursor_pointer() - .bg(bg) - .text_color(fg) - .child(label) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.selected = value; - cx.set_window_appearance(value); - cx.notify(); - })) - } -} - -impl Render for AppearanceExample { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let appearance = window.appearance(); - // Theme the UI from the selection so the content re-themes immediately on click; - // when following the system (`None`), use the effective appearance. - let is_dark = match self.selected { - Some(WindowAppearance::Dark | WindowAppearance::VibrantDark) => true, - Some(_) => false, - None => matches!( - appearance, - WindowAppearance::Dark | WindowAppearance::VibrantDark - ), - }; - let palette = Palette::new(is_dark); - let selected_label = match self.selected { - None => "Auto", - Some(WindowAppearance::Light | WindowAppearance::VibrantLight) => "Light", - Some(WindowAppearance::Dark | WindowAppearance::VibrantDark) => "Dark", - }; - - div() - .flex() - .size_full() - .justify_center() - .items_center() - .bg(palette.bg) - .text_color(palette.fg) - .child( - div() - .flex() - .flex_col() - .gap_4() - .w(px(340.)) - .p_6() - .child(div().text_xl().child("Window Appearance")) - .child( - div() - .flex() - .flex_col() - .gap_1() - .text_sm() - .text_color(palette.muted) - .child(format!("Selected: {selected_label}")) - .child(format!("Effective appearance: {appearance:?}")), - ) - .child( - div() - .flex() - .gap_2() - .child(self.button("Auto", None, &palette, cx)) - .child(self.button( - "Light", - Some(WindowAppearance::Light), - &palette, - cx, - )) - .child(self.button("Dark", Some(WindowAppearance::Dark), &palette, cx)), - ) - .child(div().text_xs().text_color(palette.muted).child( - "Set the system to Light mode, then choose Dark: the native \ - window border and titlebar switch to dark to match.", - )), - ) - } -} - -fn run_example() { - application().run(|cx: &mut App| { - let bounds = Bounds::centered(None, size(px(440.), px(380.0)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |window, cx| { - cx.new(|cx| { - // Re-render when the effective appearance changes, so the labels - // stay accurate while in `Auto` mode and the system theme toggles. - cx.observe_window_appearance(window, |_, _, cx| { - cx.notify(); - }) - .detach(); - AppearanceExample { selected: None } - }) - }, - ) - .unwrap(); - cx.activate(true); - }); -} - -#[cfg(not(target_family = "wasm"))] -fn main() { - run_example(); -} - -#[cfg(target_family = "wasm")] -#[wasm_bindgen::prelude::wasm_bindgen(start)] -pub fn start() { - gpui_platform::web_init(); - run_example(); -}