add windows theme source and platform icon resolvers
This commit is contained in:
+13
-1
@@ -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"
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user