Skip to main content

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}