From d223879a2e445c230d7e45de40440047851a6f95 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 21 Oct 2026 14:38:40 +0700 Subject: [PATCH] Watch for OS theme changes at runtime --- Cargo.lock | 3 ++ Cargo.toml | 1 + desktop/Cargo.toml | 2 + desktop/src/main.rs | 97 ++++++++++++++++++++++++++-------- docs/NATIVE-THEME-MIGRATION.md | 14 ++--- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a790525..423cc86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5699,6 +5699,7 @@ dependencies = [ "fontdb", "inventory", "native-theme-derive", + "notify", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", @@ -8337,11 +8338,13 @@ version = "0.2.0-alpha" dependencies = [ "assets", "dock", + "flume 0.11.1", "gpui-component", "gpui-pre", "gpui-pre-platform", "gpui-pre-reqwest-client", "metadata", + "native-theme", "native-theme-gpui", "paths", "settings", diff --git a/Cargo.toml b/Cargo.toml index 0050ba5..ad8bd41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.8" } gpui-component = { version = "=0.7.1", features = ["tree-sitter-languages"] } gpui-base = "=0.7.1" +native-theme = { version = "0.6.1", features = ["watch"] } native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] } dock = { path = "crates/dock" } diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 045883d..0175475 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -38,7 +38,9 @@ gpui.workspace = true gpui_platform.workspace = true dock = { workspace = true } gpui-component.workspace = true +native-theme.workspace = true native-theme-gpui.workspace = true +flume.workspace = true reqwest_client.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 93c2c95..5606dec 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -5,8 +5,9 @@ use dock::TAB_BAR_HEIGHT; use gpui::*; use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme}; use gpui_platform::application; +use native_theme::watch::ThemeSubscription; use native_theme_gpui::{SystemTheme, apply_system_theme}; -use settings::{AppearanceMode, SettingsStore}; +use settings::{AppearanceMode, Settings, SettingsStore}; actions!(signed, [Quit]); @@ -28,26 +29,21 @@ fn main() { SettingsStore::set_global(store.clone(), cx); let settings = store.read(cx).settings().clone(); - let registry = ThemeRegistry::global_mut(cx); - - for (name, content) in Assets.themes() { - if let Err(err) = registry.load_themes_from_str(&content) { - tracing::error!("Failed to load theme {name}: {err}"); + { + let registry = ThemeRegistry::global_mut(cx); + for (name, content) in Assets.themes() { + if let Err(err) = registry.load_themes_from_str(&content) { + tracing::error!("Failed to load theme {name}: {err}"); + } } } - match SystemTheme::from_system() { - Ok(mut system) => { - let native = system.light.defaults.font.size; - if native > 0.0 { - let scaling = settings.theme.font_size / native; - system.accessibility.text_scaling_factor *= scaling; - } - apply_system_theme(&system, cx); - } + match apply_native_theme(&settings, cx) { + Ok(()) => start_theme_watcher(cx), Err(error) => { tracing::warn!("native theme unavailable ({error})"); + let registry = ThemeRegistry::global_mut(cx); let light_theme = registry .themes() .get(settings.theme.light_theme.as_str()) @@ -77,13 +73,13 @@ fn main() { } else { tracing::warn!("{} theme is missing", settings.theme.dark_theme); } - } - } - match settings.appearance { - AppearanceMode::System => Theme::sync_system_appearance(None, cx), - AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), - AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + match settings.appearance { + AppearanceMode::System => Theme::sync_system_appearance(None, cx), + AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), + AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + } + } } std::fs::create_dir_all(paths::nostr_dir()).ok(); @@ -128,3 +124,62 @@ fn main() { cx.activate(true); }); } + +/// Apply the OS theme to the app, scaling the font size to match the system font size. +fn apply_native_theme(settings: &Settings, cx: &mut App) -> native_theme::Result<()> { + let mut system = SystemTheme::from_system()?; + let native = system.light.defaults.font.size; + if native > 0.0 { + let scaling = settings.theme.font_size / native; + system.accessibility.text_scaling_factor *= scaling; + } + + apply_system_theme(&system, cx); + + match settings.appearance { + AppearanceMode::System => Theme::sync_system_appearance(None, cx), + AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), + AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + } + + Ok(()) +} + +/// Holds the watcher subscription so it lives as long as the app. +struct ThemeWatcher { + _subscription: ThemeSubscription, +} + +impl Global for ThemeWatcher {} + +/// Re-apply the OS theme whenever it changes at runtime. +fn start_theme_watcher(cx: &mut App) { + let (sender, receiver) = flume::unbounded(); + match native_theme::watch::on_theme_change(move |event| { + if let Err(error) = sender.send(event) { + tracing::warn!("theme change dropped ({error})"); + } + }) { + Ok(subscription) => { + cx.set_global(ThemeWatcher { + _subscription: subscription, + }); + cx.spawn(async move |cx| { + while receiver.recv_async().await.is_ok() { + tracing::info!("OS theme changed; re-applying"); + cx.update(|cx| { + let store = SettingsStore::global(cx); + let settings = store.read(cx).settings().clone(); + if let Err(error) = apply_native_theme(&settings, cx) { + tracing::warn!("failed to apply updated OS theme ({error})"); + } + }); + } + }) + .detach(); + } + Err(error) => { + tracing::warn!("OS theme watching unavailable ({error})") + } + } +} diff --git a/docs/NATIVE-THEME-MIGRATION.md b/docs/NATIVE-THEME-MIGRATION.md index 73a7684..5d2e1e6 100644 --- a/docs/NATIVE-THEME-MIGRATION.md +++ b/docs/NATIVE-THEME-MIGRATION.md @@ -169,11 +169,14 @@ and overwriting them fights its observer. - `Theme::change(mode, None, cx)` reproduces the native palette for the mode because the connector installed a `ThemeConfig` per variant. -Known gap (not addressed here): gpui-component's system-appearance follow is -driven by window-activation events. A live OS theme change while the window -is focused may not propagate on Linux until the window is re-focused; -native-theme's `watch` feature (a theme-change watcher) is a possible -follow-up, out of scope. +Known gap (addressed since): gpui-component's system-appearance follow is +driven by window-activation events. The app now enables native-theme's +`watch` feature and re-applies the theme on its signal (`start_theme_watcher` +in `desktop/src/main.rs`), so live OS changes apply without re-focusing or +restarting. macOS reports appearance changes only (`AppleInterfaceTheme- +ChangedNotification`); an accent-only change posts a different notification +the watcher does not observe yet — Windows (`UISettings::ColorValuesChanged`), +KDE (inotify on `kdeglobals`) and GNOME (portal `SettingChanged`) cover accent. ### 4.4 Settings dialog (`settings_dialog.rs`) @@ -278,6 +281,5 @@ Unchanged: SVG assets. - theme-drawn widgets (`widgets` feature). - a preset-picker UI (`from_preset`) in settings. -- runtime OS-theme watching (`watch` feature). - per-field user overrides layered on the native theme. - migrating `signed.json` into a native-theme `Theme` definition.