Watch for OS theme changes at runtime
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s

This commit is contained in:
2026-10-21 14:38:40 +07:00
parent 36e5a18982
commit d223879a2e
5 changed files with 90 additions and 27 deletions
Generated
+3
View File
@@ -5699,6 +5699,7 @@ dependencies = [
"fontdb", "fontdb",
"inventory", "inventory",
"native-theme-derive", "native-theme-derive",
"notify",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -8337,11 +8338,13 @@ version = "0.2.0-alpha"
dependencies = [ dependencies = [
"assets", "assets",
"dock", "dock",
"flume 0.11.1",
"gpui-component", "gpui-component",
"gpui-pre", "gpui-pre",
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-reqwest-client", "gpui-pre-reqwest-client",
"metadata", "metadata",
"native-theme",
"native-theme-gpui", "native-theme-gpui",
"paths", "paths",
"settings", "settings",
+1
View File
@@ -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-component = { version = "=0.7.1", features = ["tree-sitter-languages"] }
gpui-base = "=0.7.1" 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"] } native-theme-gpui = { version = "0.6.1", default-features = false, features = ["system-fonts"] }
dock = { path = "crates/dock" } dock = { path = "crates/dock" }
+2
View File
@@ -38,7 +38,9 @@ gpui.workspace = true
gpui_platform.workspace = true gpui_platform.workspace = true
dock = { workspace = true } dock = { workspace = true }
gpui-component.workspace = true gpui-component.workspace = true
native-theme.workspace = true
native-theme-gpui.workspace = true native-theme-gpui.workspace = true
flume.workspace = true
reqwest_client.workspace = true reqwest_client.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+68 -13
View File
@@ -5,8 +5,9 @@ use dock::TAB_BAR_HEIGHT;
use gpui::*; use gpui::*;
use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme}; use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
use gpui_platform::application; use gpui_platform::application;
use native_theme::watch::ThemeSubscription;
use native_theme_gpui::{SystemTheme, apply_system_theme}; use native_theme_gpui::{SystemTheme, apply_system_theme};
use settings::{AppearanceMode, SettingsStore}; use settings::{AppearanceMode, Settings, SettingsStore};
actions!(signed, [Quit]); actions!(signed, [Quit]);
@@ -28,26 +29,21 @@ fn main() {
SettingsStore::set_global(store.clone(), cx); SettingsStore::set_global(store.clone(), cx);
let settings = store.read(cx).settings().clone(); let settings = store.read(cx).settings().clone();
{
let registry = ThemeRegistry::global_mut(cx); let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { if let Err(err) = registry.load_themes_from_str(&content) {
tracing::error!("Failed to load theme {name}: {err}"); tracing::error!("Failed to load theme {name}: {err}");
} }
} }
}
match SystemTheme::from_system() { match apply_native_theme(&settings, cx) {
Ok(mut system) => { Ok(()) => start_theme_watcher(cx),
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);
}
Err(error) => { Err(error) => {
tracing::warn!("native theme unavailable ({error})"); tracing::warn!("native theme unavailable ({error})");
let registry = ThemeRegistry::global_mut(cx);
let light_theme = registry let light_theme = registry
.themes() .themes()
.get(settings.theme.light_theme.as_str()) .get(settings.theme.light_theme.as_str())
@@ -77,14 +73,14 @@ fn main() {
} else { } else {
tracing::warn!("{} theme is missing", settings.theme.dark_theme); tracing::warn!("{} theme is missing", settings.theme.dark_theme);
} }
}
}
match settings.appearance { match settings.appearance {
AppearanceMode::System => Theme::sync_system_appearance(None, cx), AppearanceMode::System => Theme::sync_system_appearance(None, cx),
AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx),
AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx),
} }
}
}
std::fs::create_dir_all(paths::nostr_dir()).ok(); std::fs::create_dir_all(paths::nostr_dir()).ok();
std::fs::create_dir_all(paths::repos_dir()).ok(); std::fs::create_dir_all(paths::repos_dir()).ok();
@@ -128,3 +124,62 @@ fn main() {
cx.activate(true); 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})")
}
}
}
+8 -6
View File
@@ -169,11 +169,14 @@ and overwriting them fights its observer.
- `Theme::change(mode, None, cx)` reproduces the native palette for the mode - `Theme::change(mode, None, cx)` reproduces the native palette for the mode
because the connector installed a `ThemeConfig` per variant. because the connector installed a `ThemeConfig` per variant.
Known gap (not addressed here): gpui-component's system-appearance follow is Known gap (addressed since): gpui-component's system-appearance follow is
driven by window-activation events. A live OS theme change while the window driven by window-activation events. The app now enables native-theme's
is focused may not propagate on Linux until the window is re-focused; `watch` feature and re-applies the theme on its signal (`start_theme_watcher`
native-theme's `watch` feature (a theme-change watcher) is a possible in `desktop/src/main.rs`), so live OS changes apply without re-focusing or
follow-up, out of scope. 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`) ### 4.4 Settings dialog (`settings_dialog.rs`)
@@ -278,6 +281,5 @@ Unchanged:
SVG assets. SVG assets.
- theme-drawn widgets (`widgets` feature). - theme-drawn widgets (`widgets` feature).
- a preset-picker UI (`from_preset`) in settings. - a preset-picker UI (`from_preset`) in settings.
- runtime OS-theme watching (`watch` feature).
- per-field user overrides layered on the native theme. - per-field user overrides layered on the native theme.
- migrating `signed.json` into a native-theme `Theme` definition. - migrating `signed.json` into a native-theme `Theme` definition.