Fix system theme matching on Linux Wayland (#189)

* Read the Linux desktop appearance portal so system theme matching works on Wayland.

* system theme: back off to every 5 minutes when there's no appearance portal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 02:49:47 -04:00
1 parent 250cc54335
commit cf573b2d35
4 files changed
+167

No files matched your search

Generated
+1
View File
@@ -294,6 +294,7 @@ dependencies = [
"serde",
"tray-icon",
"winresource",
"zbus",
]
[[package]]
+3
View File
@@ -31,6 +31,9 @@ notify-rust = "4"
ron = "0.12.2"
serde = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
# The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus
# directly, so no GTK or libappindicator is needed.
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
+16
View File
@@ -9,6 +9,8 @@ mod logging;
mod settings_file;
#[cfg(target_os = "macos")]
mod spaces;
#[cfg(target_os = "linux")]
mod system_theme;
mod tray;
use std::path::PathBuf;
@@ -77,6 +79,11 @@ fn main() -> ExitCode {
let backend_waker = waker.clone();
let settings_path = settings_file::path(APP_ID);
let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone());
#[cfg(target_os = "linux")]
{
let theme_waker = waker.clone();
launcher.system_theme = system_theme::Monitor::start(move || theme_waker.wake());
}
// Started hidden at login where a window can't hide: start in the tray without mapping one,
// so no window flashes up. Without a tray to come back from, the window opens after all.
let mut window = true;
@@ -390,6 +397,8 @@ impl Host for DesktopHost {
/// Everything that outlives a window: the UI state and its backend, the tray icon, and the work
/// that goes on with or without a window.
struct Launcher {
#[cfg(target_os = "linux")]
system_theme: system_theme::Monitor,
ui: LauncherUi,
tray: Option<tray::Tray>,
host: DesktopHost,
@@ -417,6 +426,8 @@ impl Launcher {
let _ = asked.send(info.delay);
});
Self {
#[cfg(target_os = "linux")]
system_theme: system_theme::Monitor::default(),
ui: LauncherUi::new(&windowless, backend, Settings::default()),
tray: None,
host: DesktopHost { tray: false, quit: false },
@@ -614,6 +625,11 @@ impl<'a> Shell<'a> {
}
impl eframe::App for Shell<'_> {
#[cfg(target_os = "linux")]
fn raw_input_hook(&mut self, _ctx: &egui::Context, input: &mut egui::RawInput) {
self.launcher.system_theme.apply(input);
}
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let requests = self.launcher.tick(ctx);
#[cfg(target_os = "macos")]
+147
View File
@@ -0,0 +1,147 @@
//! The desktop appearance portal supplies the theme that winit cannot read on Wayland.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::blocking::{Proxy, connection::Builder};
use zbus::zvariant::{OwnedValue, Value};
const APPEARANCE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME: &str = "color-scheme";
#[derive(Default)]
pub struct Monitor(Arc<AtomicU8>);
impl Monitor {
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
let monitor = Self::default();
let state = Arc::downgrade(&monitor.0);
if let Err(e) = std::thread::Builder::new().name("system-theme".into()).spawn(move || {
let publish = |scheme| {
if let Some(state) = state.upgrade()
&& state.swap(scheme, Ordering::Relaxed) != scheme
{
log::debug!("desktop appearance: {:?}", theme(scheme));
wake();
}
};
// Retry after the portal goes away, backing off to every 5 minutes where there's none.
let mut retry = Duration::from_secs(5);
while state.strong_count() > 0 {
match watch(&publish) {
Ok(()) => retry = Duration::from_secs(5),
Err(e) => log::debug!("desktop appearance portal: {e}"),
}
publish(0);
std::thread::sleep(retry);
retry = (retry * 2).min(Duration::from_secs(300));
}
}) {
log::warn!("couldn't monitor the system theme: {e}");
}
monitor
}
pub fn apply(&self, input: &mut egui::RawInput) {
if let Some(theme) = theme(self.0.load(Ordering::Relaxed)) {
input.system_theme = Some(theme);
}
}
}
fn theme(scheme: u8) -> Option<egui::Theme> {
match scheme {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
fn scheme(value: OwnedValue) -> u8 {
// Read returns an extra variant wrapper; ReadOne and SettingChanged do not.
let value = match Value::from(value) {
Value::Value(value) => *value,
value => value,
};
match u32::try_from(value) {
Ok(1) => 1,
Ok(2) => 2,
_ => 0,
}
}
fn watch(publish: &impl Fn(u8)) -> zbus::Result<()> {
let connection = Builder::session()?.method_timeout(Duration::from_secs(3)).build()?;
let proxy = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
// Subscribe before reading so a theme switch during startup is not missed.
let changes = proxy.receive_signal_with_args("SettingChanged", &[(0, APPEARANCE), (1, COLOR_SCHEME)])?;
let initial: OwnedValue = proxy.call("ReadOne", &(APPEARANCE, COLOR_SCHEME)).or_else(|_| proxy.call("Read", &(APPEARANCE, COLOR_SCHEME)))?;
publish(scheme(initial));
for message in changes {
let (namespace, key, value): (String, String, OwnedValue) = message.body().deserialize()?;
if namespace == APPEARANCE && key == COLOR_SCHEME {
publish(scheme(value));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a session bus and a desktop portal with an explicit color preference"]
fn running_desktop_portal_reaches_egui() {
let (send, receive) = std::sync::mpsc::channel();
let monitor = Monitor::start(move || {
let _ = send.send(());
});
assert!(receive.recv_timeout(Duration::from_secs(10)).is_ok());
let mut input = egui::RawInput::default();
monitor.apply(&mut input);
assert!(input.system_theme.is_some());
let ctx = egui::Context::default();
ctx.set_theme(egui::ThemePreference::System);
ctx.begin_pass(input.clone());
assert_eq!(Some(ctx.theme()), input.system_theme);
ctx.end_pass().textures_delta.clear();
}
#[test]
fn portal_values_include_no_preference_and_unknown_values() {
for (value, expected) in [(0, None), (1, Some(egui::Theme::Dark)), (2, Some(egui::Theme::Light)), (3, None), (u32::MAX, None)] {
assert_eq!(theme(scheme(OwnedValue::from(value))), expected);
}
assert_eq!(scheme(OwnedValue::from(true)), 0);
}
#[test]
fn legacy_read_unwraps_the_extra_variant() {
let value = Value::Value(Box::new(Value::U32(2)));
assert_eq!(value.try_to_owned().map(scheme).ok(), Some(2));
}
#[test]
fn portal_changes_preserve_explicit_choices_and_unknown_falls_back_to_winit() {
let monitor = Monitor::default();
let ctx = egui::Context::default();
for preference in [egui::ThemePreference::System, egui::ThemePreference::Light, egui::ThemePreference::Dark] {
ctx.set_theme(preference);
for scheme in [1, 2, 0] {
monitor.0.store(scheme, Ordering::Relaxed);
let mut input = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
monitor.apply(&mut input);
let expected = match preference {
egui::ThemePreference::Light => egui::Theme::Light,
egui::ThemePreference::Dark => egui::Theme::Dark,
egui::ThemePreference::System => theme(scheme).unwrap_or(egui::Theme::Dark),
};
ctx.begin_pass(input);
assert_eq!(ctx.theme(), expected);
ctx.end_pass().textures_delta.clear();
}
}
}
}