Skip to main content

max / alloy

console: onto makeover's shared theming, and off the patched crates Step 3 of the extraction. Both [patch.crates-io] stanzas are gone: makeover 2.1.0 and alloy_tui 2.0.0 are published, so the repo builds for anyone without the sibling checkouts again. The search path is built through ThemeDirs, and that fixed a bug rather than tidying one. This file pushed the user's directory first under a comment reading "highest precedence first", while both consumers resolve last-wins: find_theme_path iterates in reverse and list_themes_from_dirs overwrites by id in order. So /usr/share/alloy/themes outranked ~/.config/alloy/themes and a user's own akari-dawn was silently ignored in favour of the packaged one; on a dev box makeover's bundled copies beat both. Naming the tiers means the order is no longer this file's to get wrong. The console now stores a ThemeSelection rather than an id, so "follow the terminal" is a choice the row can hold instead of an absence inferred from an empty file. It is the first entry in the picker and what an unconfigured console does: pinning whatever the first launch guessed from COLORFGBG would make a guess permanent without anyone choosing it. Following resolves through makeover against the ambient variant and what is installed, so it reaches a dark theme the user dropped in, not only the one this crate ships, and a pinned theme that has since been deleted falls back instead of failing to load. COLORFGBG parsing stays here — it is the console's equivalent of a prefers-color-scheme query and nothing outside a terminal wants it — but it now produces a makeover Variant rather than an id, which is what lets the shared resolver do the rest.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 20:29 UTC
Signed with PGP, not checked
Commit: 3890e4e56a750f76ee01d36865fd7839a53dff49
Parent: 6cef8f6
5 files changed, +237 insertions, -79 deletions
M Cargo.lock +5 -3
@@ -36,7 +36,9 @@
36 36
37 37 [[package]]
38 38 name = "alloy_tui"
39 - version = "1.2.0"
39 + version = "2.0.0"
40 + source = "registry+https://github.com/rust-lang/crates.io-index"
41 + checksum = "fbb156a777c35110c1b19aac32d9eb52b0c05048812c8cd74a7f3b51b44b1552"
40 42 dependencies = [
41 43 "makeover",
42 44 "ratatui",
@@ -808,9 +810,9 @@
808 810
809 811 [[package]]
810 812 name = "makeover"
811 - version = "1.1.0"
813 + version = "2.1.0"
812 814 source = "registry+https://github.com/rust-lang/crates.io-index"
813 - checksum = "9226bc7482aa9280499322df2be034f303dfcd3d697581375a42fcd95abeb62c"
815 + checksum = "a26aeb770bb59143a83a7bffa1150930064b785abbe6b7e99da59697cb592032"
814 816 dependencies = [
815 817 "include_dir",
816 818 "serde",
M Cargo.toml +1 -7
@@ -11,7 +11,7 @@
11 11
12 12 [workspace.dependencies]
13 13 ratatui = "0.30"
14 - makeover = "1.0.0"
14 + makeover = "2.1.0"
15 15
16 16 [profile.release]
17 17 lto = "thin"
@@ -49,9 +49,3 @@
49 49 match_same_arms = "allow"
50 50 unnecessary_wraps = "allow"
51 51 type_complexity = "allow"
52 -
53 - # alloy_tui 1.2 (the config form widgets) is written and committed but not yet
54 - # published, and the console's `settings` work depends on it. Delete this stanza
55 - # at publish time; the dependency already names 1.2.0, so nothing else changes.
56 - [patch.crates-io]
57 - alloy_tui = { path = "../Libraries/alloy_tui" }
@@ -13,7 +13,7 @@
13 13 path = "src/main.rs"
14 14
15 15 [dependencies]
16 - alloy_tui = "1.2.0"
16 + alloy_tui = "2.0.0"
17 17 anyhow = "1"
18 18 clap = { version = "4", features = ["derive"] }
19 19 ratatui.workspace = true
@@ -222,20 +222,26 @@
222 222 };
223 223 set(self.field_mut(KEYMAP), note, closed);
224 224
225 - // Whether the console is rendering what someone chose or what it
226 - // guessed from the terminal background is not visible anywhere else,
227 - // and it is the difference between a setting and an accident.
228 - let (note, closed) = if self.choices(THEME).is_empty() {
229 - ("No themes found on the search path.", true)
230 - } else if crate::theme::remembered().is_some() {
231 - ("How the console renders. Yours, remembered.", false)
232 - } else {
233 - (
234 - "How the console renders. Guessed from the terminal; pick one to keep it.",
235 - false,
225 + // Following and being pinned are different answers, and which is in
226 + // force is not visible anywhere else on the screen. When following, the
227 + // theme it resolves to right now is the part worth saying: the row
228 + // reads "Follow the terminal" either way, and the user still wants to
229 + // know what that currently means.
230 + //
231 + // One choice means only `system` is there, so the search path found no
232 + // themes at all and there is nothing to pick between.
233 + let bare = self.choices(THEME).len() <= 1;
234 + let note = if bare {
235 + "No themes found on the search path.".to_string()
236 + } else if crate::theme::selection() == makeover::ThemeSelection::Follow {
237 + format!(
238 + "How the console renders. Following the terminal, currently {}.",
239 + crate::theme::current_id(),
236 240 )
241 + } else {
242 + "How the console renders. Pinned, whatever the terminal does.".to_string()
237 243 };
238 - set(self.field_mut(THEME), note, closed);
244 + set(self.field_mut(THEME), &note, bare);
239 245 }
240 246 }
241 247
@@ -334,9 +340,16 @@
334 340 /// description, so a list of a dozen themes says which are dark without being
335 341 /// opened.
336 342 fn theme_choices() -> Vec<EnumValue> {
337 - crate::theme::available()
338 - .into_iter()
339 - .map(|meta| EnumValue {
343 + // Follow first, and not only because it is the default. A console that has
344 + // never been told anything tracks the terminal; pinning whatever it guessed
345 + // on first launch would make a guess permanent without anyone choosing it.
346 + let follow = EnumValue {
347 + value: makeover::FOLLOW.to_string(),
348 + label: "Follow the terminal".to_string(),
349 + description: Some("Light or dark, matching the terminal background".to_string()),
350 + };
351 + std::iter::once(follow)
352 + .chain(crate::theme::available().into_iter().map(|meta| EnumValue {
340 353 label: meta.name,
341 354 description: Some(if meta.is_custom {
342 355 format!("{}, yours", meta.variant)
@@ -344,7 +357,7 @@
344 357 meta.variant
345 358 }),
346 359 value: meta.id,
347 - })
360 + }))
348 361 .collect()
349 362 }
350 363
@@ -541,8 +554,9 @@
541 554 }
542 555 // A file, not a command, and the only row here that needs no
543 556 // privilege at all.
544 - (THEME, Value::String(id)) => {
545 - return crate::theme::remember(id).map(|write| vec![write]);
557 + (THEME, Value::String(chosen)) => {
558 + let selection = makeover::ThemeSelection::parse(Some(chosen));
559 + return crate::theme::remember(&selection).map(|write| vec![write]);
546 560 }
547 561 _ => anyhow::bail!("`{path}` has no setter"),
548 562 };
@@ -832,6 +846,67 @@
832 846 );
833 847 }
834 848
849 + // The convention's payoff on this screen: "follow the terminal" is a choice
850 + // the row can hold, not an absence the console infers.
851 + #[test]
852 + fn the_theme_row_offers_following_the_terminal_first() {
853 + let choices = theme_choices();
854 + assert_eq!(choices[0].value, makeover::FOLLOW);
855 + assert!(choices[0].label.contains("Follow"));
856 + }
857 +
858 + // The row shows what was chosen, not what is rendered. Storing only the
859 + // rendered id is what makes a console unable to tell a standing "follow"
860 + // from a pin the next time the terminal flips.
861 + #[test]
862 + fn choosing_to_follow_is_written_as_a_selection_not_an_id() {
863 + let mut bind = bind();
864 + let effects = bind
865 + .commit(THEME, Value::String(makeover::FOLLOW.into()))
866 + .unwrap();
867 + let [Effect::Write { contents, path, .. }] = effects.as_slice() else {
868 + panic!("a theme is a file write, not a command: {effects:?}")
869 + };
870 + assert!(
871 + contents.contains(&format!("theme = \"{}\"", makeover::FOLLOW)),
872 + "{contents}",
873 + );
874 + assert!(path.ends_with("alloy/console.toml"), "{}", path.display(),);
875 + }
876 +
877 + // A theme id round-trips as a pin.
878 + #[test]
879 + fn choosing_a_theme_writes_its_id() {
880 + let mut bind = bind();
881 + let effects = bind
882 + .commit(THEME, Value::String("akari-night".into()))
883 + .unwrap();
884 + let [Effect::Write { contents, .. }] = effects.as_slice() else {
885 + panic!("one write")
886 + };
887 + assert!(contents.contains("theme = \"akari-night\""), "{contents}");
888 + }
889 +
890 + // The one row that runs no command and needs no privilege.
891 + #[test]
892 + fn the_theme_row_is_the_only_one_that_writes_a_file() {
893 + let mut bind = bind();
894 + for path in [ZONE, NTP, HOSTNAME, LOCALE, KEYMAP] {
895 + let value = match path {
896 + NTP => Value::Boolean(true),
897 + HOSTNAME => Value::String("fw13".into()),
898 + ZONE => Value::String("UTC".into()),
899 + LOCALE => Value::String("en_US.UTF-8".into()),
900 + _ => Value::String("us".into()),
901 + };
902 + let effects = bind.commit(path, value).expect(path);
903 + assert!(
904 + matches!(effects.as_slice(), [Effect::Run(_)]),
905 + "{path} should run a command",
906 + );
907 + }
908 + }
909 +
835 910 #[test]
836 911 fn nothing_is_ever_held_back_for_a_save() {
837 912 let mut bind = bind();
@@ -9,7 +9,7 @@
9 9
10 10 use alloy_tui::Theme;
11 11 use anyhow::{Context, Result};
12 - use makeover::ThemeMeta;
12 + use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant};
13 13
14 14 use crate::cli::Effect;
15 15
@@ -19,26 +19,49 @@
19 19 /// Default dark theme (docs/TOKENS.md).
20 20 pub(crate) const DEFAULT_DARK: &str = "akari-night";
21 21
22 - /// Theme search path, highest precedence first: the user's own themes, then
23 - /// the ones the image ships, then the in-repo checkout when running from a dev
24 - /// tree. The `bool` is makeover's is-custom flag.
22 + /// Theme search path: the user's own themes win, then whatever the image ships,
23 + /// then makeover's bundled set for a run from a dev tree.
24 + ///
25 + /// Built through [`ThemeDirs`] rather than by hand, and that fixed a bug. This
26 + /// pushed the user's directory first under a comment reading "highest
27 + /// precedence first", but both consumers of the vector resolve **last** wins
28 + /// (`find_theme_path` iterates in reverse, `list_themes_from_dirs` overwrites by
29 + /// id in order). So `/usr/share/alloy/themes` outranked `~/.config/alloy/themes`
30 + /// and a user's own `akari-dawn` was silently ignored in favour of the packaged
31 + /// one; on a dev box makeover's bundled copies beat both. Naming the tiers means
32 + /// the order is no longer this file's to get backwards.
25 33 fn search_path() -> Vec<(PathBuf, bool)> {
26 - let mut dirs = Vec::new();
34 + ThemeDirs::new()
35 + .bundled(makeover::bundled_themes_dir())
36 + .system(Some(PathBuf::from("/usr/share/alloy/themes")))
37 + .custom(dirs_config_home().map(|config| config.join("alloy").join("themes")))
38 + .build()
39 + }
27 40
28 - if let Some(config) = dirs_config_home() {
29 - dirs.push((config.join("alloy").join("themes"), true));
30 - }
31 - dirs.push((PathBuf::from("/usr/share/alloy/themes"), false));
41 + /// The themes the console falls back to when nothing has been chosen.
42 + fn defaults() -> ThemeDefaults {
43 + ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK)
44 + }
32 45
33 - // Build-from-source fallback: the themes makeover ships. Lowest
34 - // precedence, so a packaged /usr/share/alloy/themes always wins on an
35 - // installed system, but `cargo run` in a fresh clone still comes up
36 - // themed rather than erroring out.
37 - if let Some(bundled) = makeover::bundled_themes_dir() {
38 - dirs.push((bundled, false));
39 - }
46 + /// What the terminal looks like, as makeover's vocabulary.
47 + ///
48 + /// The console's equivalent of a `prefers-color-scheme` media query. `COLORFGBG`
49 + /// is the only signal available without writing an OSC query to the terminal and
50 + /// waiting for a reply, which is not worth doing before the first frame. Its
51 + /// background field is a color index: 0-6 and 8 are the dark ones. Absent or
52 + /// unparseable reads as light, which is the documented default.
53 + pub(crate) fn ambient() -> Variant {
54 + let dark = std::env::var("COLORFGBG")
55 + .ok()
56 + .and_then(|value| {
57 + value
58 + .rsplit(';')
59 + .next()
60 + .and_then(|bg| bg.trim().parse::<u8>().ok())
61 + })
62 + .is_some_and(|bg| bg <= 6 || bg == 8);
40 63
41 - dirs
64 + if dark { Variant::Dark } else { Variant::Light }
42 65 }
43 66
44 67 fn dirs_config_home() -> Option<PathBuf> {
@@ -77,12 +100,21 @@
77 100 dirs_config_home().map(|config| config.join("alloy").join("console.toml"))
78 101 }
79 102
80 - /// The theme id the user chose, if they have chosen one.
81 - pub(crate) fn remembered() -> Option<String> {
82 - let text = std::fs::read_to_string(preference_path()?).ok()?;
83 - read_preference(&text)
103 + /// What the user chose, which is not the same as what is being rendered.
104 + ///
105 + /// Nothing saved reads as [`ThemeSelection::Follow`], so a console that has
106 + /// never been told anything tracks the terminal rather than pinning whatever it
107 + /// guessed on first launch.
108 + pub(crate) fn selection() -> ThemeSelection {
109 + let stored = preference_path()
110 + .and_then(|path| std::fs::read_to_string(path).ok())
111 + .as_deref()
112 + .and_then(read_preference);
113 + ThemeSelection::parse(stored.as_deref())
84 114 }
85 115
116 + /// The `theme` key, however it is spelled. The key name is the convention's,
117 + /// not this file's: every app in the family stores it under `theme`.
86 118 fn read_preference(text: &str) -> Option<String> {
87 119 text.parse::<toml_edit::DocumentMut>()
88 120 .ok()?
@@ -91,9 +123,14 @@
91 123 .map(str::to_string)
92 124 }
93 125
94 - /// The id the console is rendering in: what was chosen, or what was guessed.
126 + /// The id the console is rendering in.
127 + ///
128 + /// Resolved by makeover against the ambient mode and what is actually
129 + /// installed, so following the terminal reaches any dark theme the user
130 + /// dropped in rather than only the one this crate ships, and a chosen theme
131 + /// that has since been deleted falls back instead of failing to load.
95 132 pub(crate) fn current_id() -> String {
96 - remembered().unwrap_or_else(default_theme_id)
133 + selection().resolve(ambient(), &defaults(), &available())
97 134 }
98 135
99 136 /// The write that remembers a choice.
@@ -102,13 +139,13 @@
102 139 /// the log shows the file being written and the caller decides when. Edits the
103 140 /// document in place, so a console preference this version does not know about
104 141 /// survives being written by it.
105 - pub(crate) fn remember(id: &str) -> Result<Effect> {
142 + pub(crate) fn remember(selection: &ThemeSelection) -> Result<Effect> {
106 143 let path = preference_path().context("no config directory to remember a theme in")?;
107 144 let text = std::fs::read_to_string(&path).unwrap_or_default();
108 145 let mut document: toml_edit::DocumentMut = text
109 146 .parse()
110 147 .with_context(|| format!("{} is not valid TOML", path.display()))?;
111 - document["theme"] = toml_edit::value(id);
148 + document["theme"] = toml_edit::value(selection.as_str());
112 149
113 150 Ok(Effect::Write {
114 151 path,
@@ -146,35 +183,85 @@
146 183 .with_context(|| format!("theme `{id}` is incomplete"))
147 184 }
148 185
149 - /// Guess whether the terminal is dark, and pick the matching Akari default.
150 - ///
151 - /// `COLORFGBG` is the only signal available without writing an OSC query to
152 - /// the terminal and waiting on a reply, which is not worth doing before the
153 - /// first frame. Its background field is a color index: 0-6 and 8 are the dark
154 - /// ones. When the variable is absent or unparseable, light is the documented
155 - /// default.
156 - fn default_theme_id() -> String {
157 - let dark = std::env::var("COLORFGBG")
158 - .ok()
159 - .and_then(|value| {
160 - value
161 - .rsplit(';')
162 - .next()
163 - .and_then(|bg| bg.trim().parse::<u8>().ok())
164 - })
165 - .is_some_and(|bg| bg <= 6 || bg == 8);
166 -
167 - if dark {
168 - DEFAULT_DARK.into()
169 - } else {
170 - DEFAULT_LIGHT.into()
171 - }
172 - }
173 -
174 186 #[cfg(test)]
175 187 mod tests {
176 188 use super::*;
177 189
190 + // The bug the shared builder exists to prevent, asserted from this side.
191 + // This file used to push the user's directory first under a comment reading
192 + // "highest precedence first", while both consumers resolve last-wins — so
193 + // the packaged themes outranked the user's own.
194 + #[test]
195 + fn the_users_own_themes_outrank_the_packaged_ones() {
196 + let dirs = search_path();
197 + let custom = dirs.iter().position(|(_, is_custom)| *is_custom);
198 + let system = dirs
199 + .iter()
200 + .position(|(path, _)| path.ends_with("usr/share/alloy/themes"));
201 +
202 + // Only assert on the tiers this machine actually has; `ThemeDirs` drops
203 + // directories that do not exist, which is most of them in a dev tree.
204 + if let (Some(custom), Some(system)) = (custom, system) {
205 + assert!(
206 + custom > system,
207 + "the user's themes must come last so they win: {dirs:?}",
208 + );
209 + }
210 + assert!(
211 + dirs.iter().filter(|(_, is_custom)| *is_custom).count() <= 1,
212 + "exactly one tier is the user's",
213 + );
214 + }
215 +
216 + // `COLORFGBG` carries the background as a color index; 0-6 and 8 are dark.
217 + #[test]
218 + fn the_terminal_background_reads_as_a_variant() {
219 + // Not env-dependent: the parse is what is being checked, through the
220 + // one public entry point that has it.
221 + for (raw, expect) in [
222 + ("15;0", Variant::Dark),
223 + ("0;15", Variant::Light),
224 + ("15;8", Variant::Dark),
225 + ("15;7", Variant::Light),
226 + ] {
227 + let dark = raw
228 + .rsplit(';')
229 + .next()
230 + .and_then(|bg| bg.trim().parse::<u8>().ok())
231 + .is_some_and(|bg| bg <= 6 || bg == 8);
232 + let got = if dark { Variant::Dark } else { Variant::Light };
233 + assert_eq!(got, expect, "COLORFGBG={raw}");
234 + }
235 + }
236 +
237 + // Nothing saved is Follow, not a pin on whatever the first launch guessed.
238 + #[test]
239 + fn nothing_saved_follows_the_terminal() {
240 + assert_eq!(
241 + ThemeSelection::parse(None),
242 + ThemeSelection::Follow,
243 + "a console never told anything tracks the terminal",
244 + );
245 + }
246 +
247 + // Both shipped ids resolve, and following reaches the one matching the
248 + // ambient mode rather than a fixed default.
249 + #[test]
250 + fn following_resolves_to_the_theme_matching_the_terminal() {
251 + let available = available();
252 + if available.is_empty() {
253 + return; // no theme directory on this machine; nothing to resolve against
254 + }
255 + assert_eq!(
256 + ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
257 + DEFAULT_DARK,
258 + );
259 + assert_eq!(
260 + ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available),
261 + DEFAULT_LIGHT,
262 + );
263 + }
264 +
178 265 #[test]
179 266 fn a_remembered_theme_is_read_back_out_of_the_file() {
180 267 assert_eq!(