add windows theme source and platform icon resolvers

This commit is contained in:
2026-10-31 08:56:57 +07:00
parent c45f939211
commit d53f4e290d
13 changed files with 895 additions and 36 deletions
+13 -1
View File
@@ -13,11 +13,23 @@ toml = "0.8"
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }
[target.'cfg(target_os = "linux")'.dependencies]
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
async-channel = "2"
[target.'cfg(target_os = "linux")'.dependencies]
notify = "8"
zbus = "5"
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62", features = [
"Foundation",
"UI",
"UI_ViewManagement",
"Win32_Foundation",
"Win32_System_Com",
] }
windows-registry = "0.6"
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6"
objc2 = "0.6"
+12 -1
View File
@@ -22,7 +22,12 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
linux::platform_source()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
#[cfg(target_os = "windows")]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
windows::platform_source()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
Box::new(fallback::FallbackSource)
}
@@ -33,6 +38,12 @@ pub(crate) mod macos;
#[cfg(target_os = "linux")]
pub(crate) mod linux;
#[cfg(target_os = "windows")]
pub(crate) mod windows;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub(crate) mod signal;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) mod kde;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+15 -20
View File
@@ -12,6 +12,7 @@ use super::ThemeSource;
use super::kde;
use super::omarchy;
use super::portal;
use super::signal::ReapplySignal;
use crate::ThemeVariant;
use crate::system::{PartialPalette, SourceKind, SystemTheme};
@@ -20,7 +21,8 @@ pub(crate) struct LinuxSource {
portal: Option<portal::io::PortalClient>,
gsettings: Option<GSettingsSnapshot>,
file_watcher: Option<notify::RecommendedWatcher>,
reapply_task: Option<gpui::Task<()>>,
/// Keeps the main-thread channel loop alive.
_signal: Option<ReapplySignal>,
}
/// Which payload the source reads.
@@ -106,11 +108,13 @@ fn read_gsettings() -> Option<GSettingsSnapshot> {
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
let portal = portal::io::PortalClient::connect();
let gsettings = if portal.is_none() {
read_gsettings()
} else {
None
};
let backend = home_dir()
.map(|home| detect(&home, portal.is_some(), gsettings.is_some()))
.unwrap_or(Backend::Fallback);
@@ -120,7 +124,7 @@ pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
portal,
gsettings,
file_watcher: None,
reapply_task: None,
_signal: None,
})
}
@@ -185,18 +189,10 @@ impl ThemeSource for LinuxSource {
}
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
let (reapply_signal, reapply_events) = async_channel::unbounded::<()>();
async_cx.update(|cx| {
self.reapply_task = Some(cx.spawn(async move |cx| {
while reapply_events.recv().await.is_ok() {
cx.update(crate::reapply);
}
}));
});
let signal = ReapplySignal::install(&async_cx);
if let Some(portal) = &self.portal {
portal.spawn_watch(reapply_signal.clone());
portal.spawn_watch(signal.sender());
}
match self.backend {
@@ -204,14 +200,11 @@ impl ThemeSource for LinuxSource {
let Some(current) = omarchy::io::current_dir() else {
return;
};
let reapply_signal = signal.sender();
let watcher = notify::recommended_watcher(
move |event: std::result::Result<notify::Event, notify::Error>| match event {
Ok(_) => {
if let Err(error) = reapply_signal.try_send(()) {
eprintln!("theme: omarchy reapply signal failed: {error}");
}
}
Ok(_) => ReapplySignal::poke(&reapply_signal),
Err(error) => eprintln!("theme: omarchy watch error: {error}"),
},
);
@@ -231,15 +224,15 @@ impl ThemeSource for LinuxSource {
let Some(config) = home_dir().map(|home| home.join(".config")) else {
return;
};
let reapply_signal = signal.sender();
let watcher = notify::recommended_watcher(
move |event: std::result::Result<notify::Event, notify::Error>| match event {
Ok(event) => {
if event.paths.iter().any(|path| {
path.file_name().is_some_and(|name| name == "kdeglobals")
}) && let Err(error) = reapply_signal.try_send(())
{
eprintln!("theme: kde reapply signal failed: {error}");
}) {
ReapplySignal::poke(&reapply_signal);
}
}
Err(error) => eprintln!("theme: kde watch error: {error}"),
@@ -258,6 +251,8 @@ impl ThemeSource for LinuxSource {
}
Backend::Portal | Backend::Fallback => {}
}
self._signal = Some(signal);
}
fn label(&self) -> &'static str {
+3 -3
View File
@@ -127,15 +127,15 @@ pub(crate) mod io {
else {
continue;
};
if namespace != NAMESPACE {
continue;
}
match read_appearance(&proxy) {
Some(latest) => {
*appearance.lock().expect("portal cache poisoned") = latest;
if let Err(error) = reapply_signal.try_send(()) {
eprintln!("theme: portal reapply signal failed: {error}");
}
super::super::signal::ReapplySignal::poke(&reapply_signal);
}
None => eprintln!("theme: portal read failed on SettingChanged"),
}
+38
View File
@@ -0,0 +1,38 @@
//! Wakes the main-thread resolve pipeline from watcher threads, which hold
//! no gpui context (the pinned gpui's `AsyncApp` is not `Send`).
use gpui::{AsyncApp, Task};
pub(crate) struct ReapplySignal {
sender: async_channel::Sender<()>,
_task: Task<()>,
}
impl ReapplySignal {
pub(crate) fn install(async_cx: &AsyncApp) -> Self {
let (sender, events) = async_channel::unbounded::<()>();
let task = async_cx.update(|cx| {
cx.spawn(async move |cx| {
while events.recv().await.is_ok() {
cx.update(crate::reapply);
}
})
});
Self {
sender,
_task: task,
}
}
/// A clone for a watcher thread to poke the pipeline with.
pub(crate) fn sender(&self) -> async_channel::Sender<()> {
self.sender.clone()
}
/// Pokes the pipeline from a thread holding a [`Self::sender`] clone.
pub(crate) fn poke(sender: &async_channel::Sender<()>) {
if let Err(error) = sender.try_send(()) {
eprintln!("theme: reapply signal failed: {error}");
}
}
}
+199
View File
@@ -0,0 +1,199 @@
//! Windows 11 adapter: WinRT `UISettings` semantic colors with a
//! `Personalize` registry fallback, watched through `ColorValuesChanged`.
use windows::Foundation::TypedEventHandler;
use windows::UI::ViewManagement::{AccessibilitySettings, UIColorType, UISettings};
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
use windows::Win32::System::Com::{COINIT_APARTMENTTHREADED, CoInitializeEx};
use windows::core::IInspectable;
use super::ThemeSource;
use super::signal::ReapplySignal;
use crate::ThemeVariant;
use crate::system::{AccessibilityPreferences, PartialPalette, SourceKind, SystemTheme};
/// WinUI 11's `ControlCornerRadius`, in pixels at 100% scaling.
const CONTROL_RADIUS_PX: f32 = 4.0;
/// WinUI 11's default text-box height, in pixels at 100% scaling.
const INPUT_HEIGHT_PX: f32 = 32.0;
/// WinUI 11's default body text size, in pixels at 100% scaling.
const ROOT_FONT_SIZE_PX: f32 = 14.0;
/// The `Personalize` registry values, read once at startup as the fallback
/// when the WinRT surface is unavailable.
#[derive(Clone, Copy, Debug, Default)]
struct RegistrySettings {
apps_use_light_theme: Option<bool>,
enable_transparency: Option<bool>,
}
impl RegistrySettings {
fn read() -> Self {
let personalize = || {
windows_registry::CURRENT_USER
.open(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize")
};
Self {
apps_use_light_theme: personalize()
.and_then(|key| key.get_u32("AppsUseLightTheme"))
.ok()
.map(|value| value != 0),
enable_transparency: personalize()
.and_then(|key| key.get_u32("EnableTransparency"))
.ok()
.map(|value| value != 0),
}
}
}
pub(crate) struct WindowsSource {
ui_settings: UISettings,
accessibility_settings: AccessibilitySettings,
registry: RegistrySettings,
/// Keeps the main-thread channel loop alive.
_signal: Option<ReapplySignal>,
}
pub(crate) fn platform_source() -> Box<dyn ThemeSource> {
let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if initialized.is_err() && initialized != RPC_E_CHANGED_MODE {
eprintln!("theme: COM initialization failed: {initialized:?}");
}
match WindowsSource::new() {
Some(source) => Box::new(source),
None => {
eprintln!("theme: Windows system theme unavailable; using fallback");
Box::new(super::fallback::FallbackSource)
}
}
}
impl WindowsSource {
fn new() -> Option<Self> {
let ui_settings = match UISettings::new() {
Ok(ui_settings) => ui_settings,
Err(error) => {
eprintln!("theme: UISettings unavailable: {error}");
return None;
}
};
let accessibility_settings = match AccessibilitySettings::new() {
Ok(accessibility_settings) => accessibility_settings,
Err(error) => {
eprintln!("theme: AccessibilitySettings unavailable: {error}");
return None;
}
};
Some(Self {
ui_settings,
accessibility_settings,
registry: RegistrySettings::read(),
_signal: None,
})
}
fn color(&self, kind: UIColorType) -> Option<gpui::Hsla> {
let color = self.ui_settings.GetColorValue(kind).ok()?;
Some(gpui::Hsla::from(gpui::Rgba {
r: color.R as f32 / 255.0,
g: color.G as f32 / 255.0,
b: color.B as f32 / 255.0,
a: color.A as f32 / 255.0,
}))
}
fn accessibility(&self) -> AccessibilityPreferences {
AccessibilityPreferences {
high_contrast: self.accessibility_settings.HighContrast().unwrap_or(false),
// Windows states animations separately in Win32; not read yet.
reduce_motion: false,
reduce_transparency: self
.registry
.enable_transparency
.map(|enabled| !enabled)
.unwrap_or(false),
}
}
}
impl ThemeSource for WindowsSource {
fn snapshot(&self, variant: ThemeVariant) -> SystemTheme {
let accent = self.color(UIColorType::Accent);
let background = self.color(UIColorType::Background);
let foreground = self.color(UIColorType::Foreground);
let variant = background
.map(|background| {
if background.l >= 0.5 {
ThemeVariant::Light
} else {
ThemeVariant::Dark
}
})
.or(self.registry.apps_use_light_theme.map(|light| {
if light {
ThemeVariant::Light
} else {
ThemeVariant::Dark
}
}))
.unwrap_or(variant);
let text_scale = self.ui_settings.TextScaleFactor().unwrap_or(1.0);
SystemTheme {
variant,
accent,
// Windows states its ramp through UIColorType's accent variants.
palette: PartialPalette {
background,
foreground,
// Windows paints keyboard focus with the accent itself.
ring: accent,
..PartialPalette::default()
},
control_radius: Some(gpui::px(CONTROL_RADIUS_PX)),
input_height: Some(gpui::px(INPUT_HEIGHT_PX)),
root_font_size: Some(gpui::px(ROOT_FONT_SIZE_PX * text_scale as f32)),
accessibility: self.accessibility(),
source: SourceKind::Windows,
}
}
fn install_watchers(&mut self, async_cx: gpui::AsyncApp) {
let signal = ReapplySignal::install(&async_cx);
let color_signal = signal.sender();
let color_handler = TypedEventHandler::<UISettings, IInspectable>::new(move |_, _| {
ReapplySignal::poke(&color_signal);
Ok(())
});
if let Err(error) = self.ui_settings.ColorValuesChanged(&color_handler) {
eprintln!("theme: color change watch failed: {error}");
}
let contrast_signal = signal.sender();
let contrast_handler =
TypedEventHandler::<AccessibilitySettings, IInspectable>::new(move |_, _| {
ReapplySignal::poke(&contrast_signal);
Ok(())
});
if let Err(error) = self
.accessibility_settings
.HighContrastChanged(&contrast_handler)
{
eprintln!("theme: high contrast watch failed: {error}");
}
self._signal = Some(signal);
}
fn label(&self) -> &'static str {
SourceKind::Windows.label()
}
}
+3 -1
View File
@@ -14,7 +14,7 @@ pub struct AccessibilityPreferences {
/// Which adapter produced a [`SystemTheme`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
#[cfg_attr(not(any(target_os = "linux", target_os = "windows")), allow(dead_code))]
pub enum SourceKind {
#[default]
Fallback,
@@ -22,6 +22,7 @@ pub enum SourceKind {
Omarchy,
Kde,
Gnome,
Windows,
}
impl SourceKind {
@@ -32,6 +33,7 @@ impl SourceKind {
Self::Omarchy => "Omarchy (colors.toml)",
Self::Kde => "KDE (kdeglobals)",
Self::Gnome => "GNOME (portal)",
Self::Windows => "Windows 11 (UISettings)",
}
}
}