odox_ui/system_theme.rs
1//! What the desktop says about light and dark, where the window toolkit will not.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5//
6// **This is slipcase-desktop's module, taken unchanged but for the thread's
7// name.** The reasoning, the measurements and the tests are that repository's,
8// the same arrangement duckling's copy records: three applications carry one
9// answer to this, and a published crate to share it would be a crate whose only
10// contents are forty lines of D-Bus.
11//
12// **This module exists for Linux and does nothing anywhere else.** `winit`
13// answers `system_theme()` on the other two platforms — the Windows arm calls
14// `should_use_dark_mode()` and the macOS arm reads `NSApplication`'s
15// `effectiveAppearance` — and on Linux it returns `None` unconditionally
16// (`winit-0.30.13`, `src/platform_impl/linux/mod.rs:909`, a body that is the
17// word and nothing else). `egui-winit` passes that `Option` through untouched
18// and egui falls back to `Theme::Dark`, so before this module every Linux user
19// saw the dark card and no desktop setting could reach them.
20//
21// Measured 2026-08-28 before it was written, because the comfortable
22// explanations had to be ruled out first. With GNOME set to Light the card drew
23// dark; with `color-scheme` forced to `prefer-light`, so the portal answered
24// `uint32 2` rather than `0`, the card drew dark again *and the window's own
25// titlebar turned light in the same screenshot*. One window, two halves,
26// disagreeing — which is what rules out the desktop having failed to say what
27// it wanted. The run and the pixel samples are in `git log`.
28//
29// The titlebar's answer comes from `sctk-adwaita`, which spawns `dbus-send` and
30// greps its output for `uint32 1` under a 100ms timeout. That is the second
31// implementation this window would have had, and it is why this one asks the
32// portal properly rather than shelling out beside it.
33
34/// Which way a `color-scheme` answer points.
35///
36/// A named type rather than `egui::ThemePreference` so that the mapping below
37/// can be tested without a window, and so that the one case that is a judgement
38/// rather than a reading — zero — is visible at the point it is decided.
39#[cfg(target_os = "linux")]
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Scheme {
42 /// The desktop asked for a dark window.
43 Dark,
44 /// The desktop asked for a light one, or declared no preference, which is
45 /// the same answer for the reason the function below gives.
46 Light,
47}
48
49/// What the desktop portal's `color-scheme` means, including the value that is
50/// a decision rather than a reading.
51///
52/// The XDG specification defines three: 0 no preference, 1 prefer dark, 2
53/// prefer light. **Zero is treated as light, and that is the whole of what makes
54/// this module work on GNOME.** GNOME's Settings offers Light and Dark and
55/// spells them `default` and `prefer-dark`, so choosing Light sets
56/// `color-scheme` to `default` and the portal answers 0 — never 2. Measured on
57/// GNOME 48.7: `prefer-light` has to be set by hand through `gsettings` and no
58/// part of the interface will do it.
59///
60/// So reading 0 as *leave it dark* would mean every GNOME user who chose Light
61/// still got the dark card, which is the defect this module was written for
62/// wearing a different hat. Reading it as light is also what GTK does with
63/// `ADW_COLOR_SCHEME_DEFAULT`, so an application that follows this rule looks
64/// like every other application on that desktop rather than like the one
65/// exception.
66///
67/// The cost is a desktop that is genuinely dark while declaring no preference,
68/// where this now draws light. That desktop's GTK applications are drawing
69/// light too, so the answer is at least consistent with its neighbours.
70#[cfg(target_os = "linux")]
71#[must_use]
72pub fn scheme_of(color_scheme: u32) -> Scheme {
73 match color_scheme {
74 1 => Scheme::Dark,
75 _ => Scheme::Light,
76 }
77}
78
79#[cfg(target_os = "linux")]
80mod linux {
81 use super::{Scheme, scheme_of};
82
83 const PORTAL: &str = "org.freedesktop.portal.Desktop";
84 const PATH: &str = "/org/freedesktop/portal/desktop";
85 const SETTINGS: &str = "org.freedesktop.portal.Settings";
86 const NAMESPACE: &str = "org.freedesktop.appearance";
87 const KEY: &str = "color-scheme";
88
89 /// The portal answers `Read` with a variant inside a variant, and `ReadOne`
90 /// with one. Rather than choose a method by portal version — the older one
91 /// does not have `ReadOne` and the newer one deprecates `Read` — this
92 /// unwraps whatever arrived until a number falls out.
93 ///
94 /// Written as a loop because the nesting is a property of the wire format
95 /// and not of this application, and a `u32` at any depth means the same
96 /// thing.
97 fn number_inside(value: &zbus::zvariant::Value<'_>) -> Option<u32> {
98 let mut here = value;
99 for _ in 0..4 {
100 match here {
101 zbus::zvariant::Value::U32(n) => return Some(*n),
102 zbus::zvariant::Value::Value(inner) => here = inner,
103 _ => return None,
104 }
105 }
106 None
107 }
108
109 fn proxy(
110 connection: &zbus::blocking::Connection,
111 ) -> Result<zbus::blocking::Proxy<'static>, zbus::Error> {
112 zbus::blocking::Proxy::new(connection, PORTAL, PATH, SETTINGS)
113 }
114
115 /// Ask once. `None` where the question could not be put at all — no session
116 /// bus, no portal, or a portal that does not carry the appearance
117 /// namespace.
118 ///
119 /// **`None` is not a third answer and must not be turned into one.** It
120 /// means nothing was learned, and the caller leaves egui's own fallback
121 /// alone rather than choosing a theme on no evidence. A machine with no
122 /// portal therefore behaves exactly as it did before this module existed.
123 pub fn ask() -> Option<Scheme> {
124 let connection = zbus::blocking::Connection::session().ok()?;
125 let proxy = proxy(&connection).ok()?;
126 let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(NAMESPACE, KEY)).ok()?;
127 number_inside(&value).map(scheme_of)
128 }
129
130 /// Watch for the setting changing while the window is open, on a thread of
131 /// its own.
132 ///
133 /// The other two platforms get this for free: `winit` delivers
134 /// `WindowEvent::ThemeChanged` and `egui-winit` turns it into a new
135 /// `system_theme` without anybody here being involved. Following the
136 /// setting only at startup would leave Linux the one platform where the
137 /// answer goes stale the moment a person changes their mind, which is not
138 /// what *respects the setting* means anywhere else.
139 ///
140 /// The thread is detached and never joined. It holds a D-Bus connection and
141 /// a clone of the context for as long as the process runs, and there is
142 /// nothing for it to clean up: the process exiting closes the socket.
143 pub fn watch(ctx: &eframe::egui::Context, mut apply: impl FnMut(Scheme) + Send + 'static) {
144 let ctx = ctx.clone();
145 std::thread::Builder::new()
146 .name("odox-theme".to_owned())
147 .spawn(move || {
148 let Ok(connection) = zbus::blocking::Connection::session() else {
149 return;
150 };
151 let Ok(proxy) = proxy(&connection) else {
152 return;
153 };
154 let Ok(changes) = proxy.receive_signal("SettingChanged") else {
155 return;
156 };
157 for message in changes {
158 // The signal carries every setting the portal has, so the
159 // namespace and key are checked rather than assumed. A
160 // desktop emits these for font sizes and accent colours
161 // too, and repainting the window for an accent change
162 // would be a wakeup for nothing.
163 // Bound before it is read: the body borrows the message,
164 // so deserializing inline drops it at the end of the
165 // statement and leaves the value pointing at nothing.
166 let body = message.body();
167 let Ok((namespace, key, value)) =
168 body.deserialize::<(String, String, zbus::zvariant::Value<'_>)>()
169 else {
170 continue;
171 };
172 if namespace != NAMESPACE || key != KEY {
173 continue;
174 }
175 if let Some(number) = number_inside(&value) {
176 apply(scheme_of(number));
177 // The thread has no frame of its own, so the window is
178 // asked for one. Without this the new theme sits in
179 // egui's options until something else happens to cause
180 // a repaint — a pointer moving over the window, most
181 // likely — which looks exactly like the setting having
182 // been ignored.
183 ctx.request_repaint();
184 }
185 }
186 })
187 .ok();
188 }
189}
190
191/// Follow the desktop's light and dark setting, where the window toolkit does
192/// not do it for us.
193///
194/// Called from the creation closure, where the context exists. On every
195/// platform but Linux this is empty: `winit` reports the system theme there and
196/// egui is already following it, and calling `set_theme` would replace a live
197/// answer with a pinned one.
198#[cfg(target_os = "linux")]
199pub fn follow(ctx: &eframe::egui::Context) {
200 use eframe::egui::ThemePreference;
201
202 fn preference(scheme: Scheme) -> ThemePreference {
203 match scheme {
204 Scheme::Dark => ThemePreference::Dark,
205 Scheme::Light => ThemePreference::Light,
206 }
207 }
208
209 // `set_theme` and not `Options::system_theme`, because the second is what
210 // egui fills in from `winit` and this platform's whole problem is that
211 // nothing fills it in. Pinning the preference is the honest description of
212 // what is happening: this module, and not the toolkit, is deciding.
213 if let Some(scheme) = linux::ask() {
214 ctx.set_theme(preference(scheme));
215 }
216
217 let watcher = ctx.clone();
218 linux::watch(ctx, move |scheme| {
219 watcher.set_theme(preference(scheme));
220 });
221}
222
223/// Nothing to do: `winit` reports the system theme on this platform and egui is
224/// already following it, so pinning a preference here would replace a live
225/// answer with a fixed one.
226#[cfg(not(target_os = "linux"))]
227pub fn follow(_ctx: &eframe::egui::Context) {}
228
229#[cfg(all(test, target_os = "linux"))]
230mod tests {
231 use super::{Scheme, scheme_of};
232
233 /// Would catch the defect this module was written for: reading the
234 /// portal's *no preference* as a reason to leave the window dark.
235 ///
236 /// GNOME's Settings spells Light as `color-scheme=default`, which the
237 /// portal reports as 0 and never as 2, so a mapping that treats 0 as
238 /// anything but light gives every GNOME user who chose Light the dark card
239 /// — which is the state this module replaced.
240 #[test]
241 fn no_preference_is_light_because_that_is_what_gnome_sends_for_light() {
242 assert_eq!(scheme_of(0), Scheme::Light);
243 }
244
245 /// Would catch the mapping being inverted, which is the one way this can
246 /// be wrong while still appearing to work: the theme would change when the
247 /// setting changed, and be wrong both times.
248 #[test]
249 fn one_is_dark_and_two_is_light() {
250 assert_eq!(scheme_of(1), Scheme::Dark);
251 assert_eq!(scheme_of(2), Scheme::Light);
252 }
253
254 /// Would catch a value outside the specification's three landing on dark.
255 ///
256 /// Light is the same answer 0 gets and for the same reason: a value this
257 /// build does not understand is not evidence that a person wants a dark
258 /// window, and the desktops that send one will have their other
259 /// applications drawing light.
260 #[test]
261 fn a_value_the_specification_does_not_define_is_light() {
262 for unknown in [3, 4, 99, u32::MAX] {
263 assert_eq!(
264 scheme_of(unknown),
265 Scheme::Light,
266 "color-scheme {unknown} should not darken the window"
267 );
268 }
269 }
270}