Skip to main content

max / alloy

Let the console see a night session the terminal never mentions rio sets no COLORFGBG at all, so `ambient()` read light in a night session and a console left on Follow rendered light on a dark terminal. The desktop knew: the mode file is right there. Ask it when the terminal declines to speak. COLORFGBG still wins when it parses, being the more specific claim. The fallthrough is why absent and unparseable can no longer both read as light, since there has to be something left to fall back to. So the mode file's stated `day` and a malformed one are now different answers, which `read_mode` may keep collapsing onto day because its own caller has nothing further to ask.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 03:00 UTC
Signed with PGP, not checked
Commit: 7b17509bc9d71ca362b75f9ed132eadb6da51b2f
Parent: 612412b
2 files changed, +100 insertions, -20 deletions
@@ -44,25 +44,53 @@
44 44 ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK)
45 45 }
46 46
47 - /// What the terminal looks like, as makeover's vocabulary.
47 + /// What the console is being drawn on, as makeover's vocabulary.
48 48 ///
49 - /// The console's equivalent of a `prefers-color-scheme` media query. `COLORFGBG`
50 - /// is the only signal available without writing an OSC query to the terminal and
51 - /// waiting for a reply, which is not worth doing before the first frame. Its
52 - /// background field is a color index: 0-6 and 8 are the dark ones. Absent or
53 - /// unparseable reads as light, which is the documented default.
49 + /// The console's equivalent of a `prefers-color-scheme` media query, and it asks
50 + /// two sources in order of how specific they are:
51 + ///
52 + /// 1. **`COLORFGBG`**, what this terminal says its own background is. The most
53 + /// specific answer there is, and the only one available without writing an
54 + /// OSC query and waiting for a reply, which is not worth doing before the
55 + /// first frame.
56 + /// 2. **The session's mode file**, the polarity the desktop was switched to.
57 + /// Less specific — it is right about sway and mako and says nothing about the
58 + /// terminal the console happens to be running in — but it is the answer
59 + /// whenever the terminal declines to speak.
60 + ///
61 + /// Neither, and it is light, which is the documented default.
62 + ///
63 + /// The second tier is not decoration. rio sets no `COLORFGBG` at all, so a night
64 + /// session left on [`ThemeSelection::Follow`] used to render a light console on a
65 + /// dark terminal: the desktop knew, and this function was not asking it. That is
66 + /// also why absent and unparseable fall through here rather than both reading
67 + /// light — no signal has to be distinguishable from a light one to have anything
68 + /// left to fall back to.
54 69 pub(crate) fn ambient() -> Variant {
55 - let dark = std::env::var("COLORFGBG")
56 - .ok()
57 - .and_then(|value| {
58 - value
59 - .rsplit(';')
60 - .next()
61 - .and_then(|bg| bg.trim().parse::<u8>().ok())
62 - })
63 - .is_some_and(|bg| bg <= 6 || bg == 8);
70 + terminal_background()
71 + .or_else(session_mode)
72 + .unwrap_or(Variant::Light)
73 + }
64 74
65 - if dark { Variant::Dark } else { Variant::Light }
75 + /// The variant `COLORFGBG` names, when the terminal sets it to something legible.
76 + ///
77 + /// The background field is a color index: 0-6 and 8 are the dark ones.
78 + fn terminal_background() -> Option<Variant> {
79 + let value = std::env::var("COLORFGBG").ok()?;
80 + let bg = value.rsplit(';').next()?.trim().parse::<u8>().ok()?;
81 + Some(if bg <= 6 || bg == 8 {
82 + Variant::Dark
83 + } else {
84 + Variant::Light
85 + })
86 + }
87 +
88 + /// The variant the desktop was switched to, read off the mode file.
89 + fn session_mode() -> Option<Variant> {
90 + match crate::theme_apply::saved_mode()? {
91 + crate::theme_apply::NIGHT => Some(Variant::Dark),
92 + _ => Some(Variant::Light),
93 + }
66 94 }
67 95
68 96 /// Every theme the console can render, from the same search path it loads from.
@@ -125,6 +153,11 @@
125 153 /// skeleton cannot follow a terminal background that sway, mako and Firefox
126 154 /// will never see.
127 155 ///
156 + /// Since [`ambient`] falls back to the mode file, choosing Follow inside a
157 + /// terminal that sets no `COLORFGBG` now writes back the mode already in place
158 + /// rather than reverting the desktop to day. That is the wanted answer: "follow
159 + /// the terminal" is not a request to re-skin sway.
160 + ///
128 161 /// An id that is not in [`available`], a theme deleted between the list being
129 162 /// drawn and the choice being made, reads as day, because `/etc/skel` is the
130 163 /// day render and day is therefore the answer that changes the least.
@@ -213,17 +213,18 @@
213 213 return DAY.to_string();
214 214 };
215 215
216 + if let Some(mode) = parse_mode(&raw) {
217 + return mode.to_string();
218 + }
219 +
220 + // Malformed, so the only thing left to decide is which way to say so.
216 221 let lines: Vec<&str> = raw
217 222 .lines()
218 223 .map(str::trim)
219 224 .filter(|line| !line.is_empty())
220 225 .collect();
221 226
222 - // One word and a trailing newline is the whole format. A second line means
223 - // somebody is treating this as a config file, and guessing which line they
224 - // meant is worse than saying so.
225 227 match lines.as_slice() {
226 - [only] if *only == DAY || *only == NIGHT => (*only).to_string(),
227 228 [only] => {
228 229 let _ = writeln!(
229 230 err,
@@ -251,6 +252,36 @@
251 252 }
252 253 }
253 254
255 + /// One word, `day` or `night`, and a trailing newline: the whole format.
256 + ///
257 + /// Anything else is `None` rather than a guess. A second line means somebody is
258 + /// treating this as a config file, and picking which line they meant is worse
259 + /// than saying so.
260 + fn parse_mode(raw: &str) -> Option<&'static str> {
261 + let lines: Vec<&str> = raw
262 + .lines()
263 + .map(str::trim)
264 + .filter(|line| !line.is_empty())
265 + .collect();
266 +
267 + match lines.as_slice() {
268 + [only] if *only == DAY => Some(DAY),
269 + [only] if *only == NIGHT => Some(NIGHT),
270 + _ => None,
271 + }
272 + }
273 +
274 + /// The mode this user's desktop was switched to, if it was switched at all.
275 + ///
276 + /// The quiet read, for callers that have a fallback of their own;
277 + /// [`read_mode`] is the loud one, because a session about to be re-skinned off
278 + /// a malformed file should say so. Absent, unreadable and malformed are one
279 + /// answer here: no signal.
280 + pub(crate) fn saved_mode() -> Option<&'static str> {
281 + let path = crate::theme::mode_path()?;
282 + parse_mode(&std::fs::read_to_string(path).ok()?)
283 + }
284 +
254 285 /// Apply `mode` across `trees`, reporting to `out` and complaining to `err`.
255 286 fn run(trees: &Trees, mode: &str, options: &Options, out: &mut impl Write, err: &mut impl Write) {
256 287 // A dev box, or an image somebody has taken files out of. Nothing to switch
@@ -583,6 +614,22 @@
583 614 }
584 615 }
585 616
617 + // The quiet read is why the fallback and the stated answer have to be
618 + // distinguishable: `theme::ambient` falls through to a lower-precedence
619 + // source on `None`, and it must not fall through on a file that genuinely
620 + // says `day`. `read_mode` collapses both onto DAY, correctly, for its own
621 + // caller, which has nothing further to ask.
622 + #[test]
623 + fn a_stated_day_and_a_malformed_file_are_different_answers() {
624 + assert_eq!(parse_mode("day\n"), Some(DAY));
625 + assert_eq!(parse_mode("night\n"), Some(NIGHT));
626 + assert_eq!(parse_mode(" night \n"), Some(NIGHT));
627 +
628 + for malformed in ["", " \n\n", "dusk\n", "night\ntheme = akari-night\n"] {
629 + assert_eq!(parse_mode(malformed), None, "{malformed:?}");
630 + }
631 + }
632 +
586 633 // Never rewritten. A malformed file may be a user mid-edit, and correcting
587 634 // it silently hides the mistake that produced it.
588 635 #[test]