Skip to main content

gpui_macos/
system_theme.rs

1//! Appearance policy and per-window system observation.
2use crate::{ActiveTheme, Theme};
3use gpui_kit::{App, Global, Window};
4use std::collections::HashSet;
5#[derive(Default)]
6struct AppearancePolicy {
7    following: bool,
8    windows: HashSet<gpui_kit::WindowId>,
9    closed: Option<gpui_kit::Subscription>,
10}
11impl Global for AppearancePolicy {}
12
13impl Theme {
14    /// Restore system appearance, preserving accent and material preferences.
15    /// Attach [`crate::observe_window`] once to each newly opened window.
16    pub fn follow_system(cx: &mut App) {
17        cx.set_window_appearance(None);
18        cx.default_global::<AppearancePolicy>().following = true;
19        sync(cx);
20    }
21    pub fn is_following_system(cx: &App) -> bool {
22        cx.try_global::<AppearancePolicy>()
23            .is_some_and(|policy| policy.following)
24    }
25}
26pub(crate) fn stop_following(cx: &mut App) {
27    cx.default_global::<AppearancePolicy>().following = false;
28}
29fn sync(cx: &mut App) {
30    let mut next = Theme::system(cx);
31    if let Some(current) = cx.try_global::<Theme>() {
32        next.glass_tint = current.glass_tint;
33        // An accent explicitly set by an app should survive system appearance changes.
34        let default = if current.is_dark() {
35            Theme::dark()
36        } else {
37            Theme::light()
38        };
39        if current.accent != default.accent {
40            next = next.with_accent(current.accent);
41        }
42    }
43    if cx.try_global::<Theme>() != Some(&next) {
44        next.apply_palette(cx);
45    }
46}
47/// Follow macOS light/dark changes for this window. The observer lives with it.
48/// Repeated calls on the same window do not install duplicate subscriptions.
49pub fn observe_window(window: &mut Window, cx: &mut App) {
50    if cx.default_global::<AppearancePolicy>().closed.is_none() {
51        let closed = cx.on_window_closed(|cx, id| {
52            cx.default_global::<AppearancePolicy>().windows.remove(&id);
53        });
54        cx.default_global::<AppearancePolicy>().closed = Some(closed);
55    }
56    if cx
57        .default_global::<AppearancePolicy>()
58        .windows
59        .insert(window.window_handle().window_id())
60    {
61        window
62            .observe_window_appearance(|_, cx| {
63                if Theme::is_following_system(cx) {
64                    sync(cx);
65                }
66                gpui_kit::base::apply_system_reduce_motion(cx);
67            })
68            .detach();
69    }
70}
71/// Re-read Display accessibility preferences after an application settings change.
72pub fn refresh_accessibility(cx: &mut App) {
73    crate::native::refresh_sidebar_metrics();
74    let mut theme = cx.macos().clone();
75    theme.read_accessibility();
76    theme.apply_palette(cx);
77    gpui_kit::base::apply_system_reduce_motion(cx);
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use gpui_kit::{Context, IntoElement, Render, TestAppContext, div, px, size};
84
85    struct ObservedWindow;
86    impl Render for ObservedWindow {
87        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
88            div()
89        }
90    }
91
92    #[gpui_kit::test]
93    fn appearance_observation_forgets_closed_windows_and_keeps_live_windows(
94        cx: &mut TestAppContext,
95    ) {
96        cx.update(crate::init);
97        let open = |cx: &mut TestAppContext| {
98            cx.open_window(size(px(100.), px(100.)), |window, cx| {
99                observe_window(window, cx);
100                observe_window(window, cx);
101                ObservedWindow
102            })
103        };
104        let persistent = open(cx);
105        for _ in 0..20 {
106            let temporary = open(cx);
107            cx.update(|cx| assert_eq!(cx.global::<AppearancePolicy>().windows.len(), 2));
108            temporary
109                .update(cx, |_, window, _| window.remove_window())
110                .unwrap();
111            cx.run_until_parked();
112            cx.update(|cx| {
113                assert_eq!(cx.global::<AppearancePolicy>().windows.len(), 1);
114                assert!(
115                    cx.global::<AppearancePolicy>()
116                        .windows
117                        .contains(&persistent.window_id())
118                );
119            });
120        }
121        persistent
122            .update(cx, |_, window, _| window.remove_window())
123            .unwrap();
124        cx.run_until_parked();
125        cx.update(|cx| assert!(cx.global::<AppearancePolicy>().windows.is_empty()));
126    }
127}