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