Skip to main content

max / alloy

settings: remember a theme, through makeover The last System row. Choices come from makeover's own scan rather than a second one, so a theme the list offers is one load can find; the id is what is written and the name is what is shown, since akari-night is the file and "Akari Night" is what a person picked. The row is the exception on this tab: it writes a file rather than running a command, and needs no privilege at all. Which theme a terminal renders in is the person's business, not the machine's, so it goes in the user's config rather than anywhere a polkit action could reach. Preference lands in $XDG_CONFIG_HOME/alloy/console.toml, the directory the console already owns for custom themes and user schemas. Edited in place, so a key a later version adds survives being written by this one, and returned as an effect like every other write. Resolution order is --theme, then what was chosen, then the guess from the terminal background, so a flag stays a one-off and never becomes the remembered answer. The row says which of the two it is currently on, since that is the difference between a setting and an accident and is visible nowhere else. Applies on next launch. Re-theming a running console needs the shell to swap a Theme mid-loop, and this file's persistence half is about to be extracted into makeover as a family convention, so both wait for that.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:41 UTC
Signed with PGP, not checked
Commit: 6cef8f6b43ed607ed120600f72f9b62b75da9b6e
Parent: ebdd3c7
3 files changed, +162 insertions, -6 deletions
@@ -1978,8 +1978,10 @@
1978 1978 "[locale]",
1979 1979 "locale.lang",
1980 1980 "locale.keymap",
1981 + "[appearance]",
1982 + "appearance.theme",
1981 1983 ],
1982 - "three fronts, one form, sections in declared order",
1984 + "three fronts and the console's own preference, one form",
1983 1985 );
1984 1986 assert_eq!(
1985 1987 cell(form.bind.as_ref(), form.bind.field("time.zone").unwrap()).text,
@@ -26,6 +26,12 @@
26 26 //! | lang | `localectl set-locale` | `locale1.set-locale` |
27 27 //! | keymap | `localectl set-keymap` | `locale1.set-keyboard` |
28 28 //!
29 + //! The theme row is the exception that proves the shape: it writes
30 + //! `$XDG_CONFIG_HOME/alloy/console.toml` rather than running anything, because
31 + //! which theme a terminal renders in is the person's business and not the
32 + //! machine's. It needs no privilege and grants none, and the console re-themes
33 + //! the moment it is written.
34 + //!
29 35 //! Every one is in the five `usr/share/polkit-1/rules.d/50-alloy-settings.rules`
30 36 //! grants, so none prompts, and nothing here can reach an action deliberately
31 37 //! left prompting. Two consequences that look like quirks and are not:
@@ -69,6 +75,8 @@
69 75 const HOSTNAME: &str = "hostname.static";
70 76 const LOCALE: &str = "locale.lang";
71 77 const KEYMAP: &str = "locale.keymap";
78 + /// The one row that writes a file rather than running a command.
79 + pub(crate) const THEME: &str = "appearance.theme";
72 80
73 81 /// Live system settings, fronted by `timedatectl`, `hostnamectl`, `localectl`.
74 82 pub(crate) struct SystemBind {
@@ -124,7 +132,7 @@
124 132
125 133 let mut bind = Self {
126 134 sections: sections(),
127 - fields: rows(zones, locales, keymaps),
135 + fields: rows(zones, locales, keymaps, theme_choices()),
128 136 time,
129 137 host: read_host(log),
130 138 locale: read_locale(log),
@@ -213,6 +221,21 @@
213 221 )
214 222 };
215 223 set(self.field_mut(KEYMAP), note, closed);
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,
236 + )
237 + };
238 + set(self.field_mut(THEME), note, closed);
216 239 }
217 240 }
218 241
@@ -250,11 +273,20 @@
250 273 path: "locale".to_string(),
251 274 description: Some("Language and keyboard.".to_string()),
252 275 },
276 + Section {
277 + path: "appearance".to_string(),
278 + description: Some("How the console renders.".to_string()),
279 + },
253 280 ]
254 281 }
255 282
256 283 /// Every row. Help lines and read-only flags are [`SystemBind::annotate`]'s.
257 - fn rows(zones: Vec<EnumValue>, locales: Vec<EnumValue>, keymaps: Vec<EnumValue>) -> Vec<Field> {
284 + fn rows(
285 + zones: Vec<EnumValue>,
286 + locales: Vec<EnumValue>,
287 + keymaps: Vec<EnumValue>,
288 + themes: Vec<EnumValue>,
289 + ) -> Vec<Field> {
258 290 let enumerated = |path: &str, values: Vec<EnumValue>| Field {
259 291 path: path.to_string(),
260 292 description: None,
@@ -290,9 +322,32 @@
290 322 text(HOSTNAME, false),
291 323 enumerated(LOCALE, locales),
292 324 enumerated(KEYMAP, keymaps),
325 + enumerated(THEME, themes),
293 326 ]
294 327 }
295 328
329 + /// The themes makeover can find, as choices.
330 + ///
331 + /// The id is what is written and the name is what is shown, which is the one
332 + /// place on this screen where those differ: `akari-night` is the file and
333 + /// "Akari Night" is the thing a person picked. The variant rides along as the
334 + /// description, so a list of a dozen themes says which are dark without being
335 + /// opened.
336 + fn theme_choices() -> Vec<EnumValue> {
337 + crate::theme::available()
338 + .into_iter()
339 + .map(|meta| EnumValue {
340 + label: meta.name,
341 + description: Some(if meta.is_custom {
342 + format!("{}, yours", meta.variant)
343 + } else {
344 + meta.variant
345 + }),
346 + value: meta.id,
347 + })
348 + .collect()
349 + }
350 +
296 351 /// Parse `timedatectl show`, which is `Key=value` a line at a time.
297 352 ///
298 353 /// Values are taken verbatim after the first `=`. `TimeUSec` arrives already
@@ -437,6 +492,7 @@
437 492 HOSTNAME => text(self.host.fixed.as_ref().or(self.host.effective.as_ref())),
438 493 LOCALE => text(self.locale.lang.as_ref()),
439 494 KEYMAP => text(self.locale.keymap.as_ref()),
495 + THEME => Some(Value::String(crate::theme::current_id())),
440 496 _ => None,
441 497 }
442 498 }
@@ -483,6 +539,11 @@
483 539 (KEYMAP, Value::String(keymap)) => {
484 540 Invocation::new("localectl").args(["set-keymap", keymap])
485 541 }
542 + // A file, not a command, and the only row here that needs no
543 + // privilege at all.
544 + (THEME, Value::String(id)) => {
545 + return crate::theme::remember(id).map(|write| vec![write]);
546 + }
486 547 _ => anyhow::bail!("`{path}` has no setter"),
487 548 };
488 549 Ok(vec![Effect::Run(invocation)])
@@ -523,6 +584,7 @@
523 584 parse_list("America/Los_Angeles\nAmerica/Denver\nUTC\n"),
524 585 parse_list("en_US.UTF-8\nen_GB.UTF-8\n"),
525 586 parse_list("us\nuk\n"),
587 + theme_choices(),
526 588 ),
527 589 time: parse_show(tests::SHOW),
528 590 host: parse_host(tests::HOST),
@@ -723,7 +785,12 @@
723 785 fn a_machine_that_lists_no_keymaps_does_not_offer_the_row() {
724 786 let mut bind = SystemBind {
725 787 sections: sections(),
726 - fields: rows(Vec::new(), parse_list("en_US.UTF-8\n"), Vec::new()),
788 + fields: rows(
789 + Vec::new(),
790 + parse_list("en_US.UTF-8\n"),
791 + Vec::new(),
792 + theme_choices(),
793 + ),
727 794 time: parse_show(SHOW),
728 795 host: parse_host(HOST),
729 796 locale: parse_locale(LOCALE_STATUS),
@@ -9,6 +9,9 @@
9 9
10 10 use alloy_tui::Theme;
11 11 use anyhow::{Context, Result};
12 + use makeover::ThemeMeta;
13 +
14 + use crate::cli::Effect;
12 15
13 16 /// Default light theme (docs/TOKENS.md).
14 17 pub(crate) const DEFAULT_LIGHT: &str = "akari-dawn";
@@ -50,9 +53,78 @@
50 53 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
51 54 }
52 55
53 - /// Load a theme by id, or the mode-appropriate default when `id` is `None`.
56 + /// Every theme the console can render, from the same search path it loads from.
57 + ///
58 + /// makeover's own scan, not a second one: the crate already knows what a theme
59 + /// file is and which directory outranks which, and a list here that disagreed
60 + /// with [`load`] would offer a choice that then failed to load.
61 + pub(crate) fn available() -> Vec<ThemeMeta> {
62 + makeover::list_themes_from_dirs(&search_path())
63 + }
64 +
65 + /// Where the console remembers a chosen theme.
66 + ///
67 + /// `$XDG_CONFIG_HOME/alloy/console.toml`. The console already owns that
68 + /// directory — custom themes live in `alloy/themes/`, user schemas in
69 + /// `alloy/schemas/` — so this needed no new place, only a file. TOML because
70 + /// everything else in the ecosystem is, and because a console preference that
71 + /// is not a theme will want a key of its own eventually.
72 + ///
73 + /// Deliberately not a system-wide file: which theme a terminal renders in is
74 + /// the person's, not the machine's, and two accounts on one machine should not
75 + /// fight over it.
76 + pub(crate) fn preference_path() -> Option<PathBuf> {
77 + dirs_config_home().map(|config| config.join("alloy").join("console.toml"))
78 + }
79 +
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)
84 + }
85 +
86 + fn read_preference(text: &str) -> Option<String> {
87 + text.parse::<toml_edit::DocumentMut>()
88 + .ok()?
89 + .get("theme")?
90 + .as_str()
91 + .map(str::to_string)
92 + }
93 +
94 + /// The id the console is rendering in: what was chosen, or what was guessed.
95 + pub(crate) fn current_id() -> String {
96 + remembered().unwrap_or_else(default_theme_id)
97 + }
98 +
99 + /// The write that remembers a choice.
100 + ///
101 + /// Returned rather than performed, like every other effect in the console, so
102 + /// the log shows the file being written and the caller decides when. Edits the
103 + /// document in place, so a console preference this version does not know about
104 + /// survives being written by it.
105 + pub(crate) fn remember(id: &str) -> Result<Effect> {
106 + let path = preference_path().context("no config directory to remember a theme in")?;
107 + let text = std::fs::read_to_string(&path).unwrap_or_default();
108 + let mut document: toml_edit::DocumentMut = text
109 + .parse()
110 + .with_context(|| format!("{} is not valid TOML", path.display()))?;
111 + document["theme"] = toml_edit::value(id);
112 +
113 + Ok(Effect::Write {
114 + path,
115 + contents: document.to_string(),
116 + // A preference, not a secret and not executable.
117 + mode: 0o644,
118 + })
119 + }
120 +
121 + /// Load a theme by id, or the console's current one when `id` is `None`.
122 + ///
123 + /// `--theme` wins, then what the user chose, then the guess from the terminal
124 + /// background. So a flag is a one-off override and does not quietly become the
125 + /// remembered answer.
54 126 pub(crate) fn load(id: Option<&str>) -> Result<Theme> {
55 - let id = id.map_or_else(default_theme_id, str::to_string);
127 + let id = id.map_or_else(current_id, str::to_string);
56 128 let dirs = search_path();
57 129
58 130 let colors = makeover::load_theme(&dirs, &id)
@@ -103,6 +175,21 @@
103 175 mod tests {
104 176 use super::*;
105 177
178 + #[test]
179 + fn a_remembered_theme_is_read_back_out_of_the_file() {
180 + assert_eq!(
181 + read_preference("theme = \"akari-night\"\n").as_deref(),
182 + Some("akari-night"),
183 + );
184 + assert_eq!(
185 + read_preference("# nothing chosen yet\n"),
186 + None,
187 + "an empty file is not a choice",
188 + );
189 + assert_eq!(read_preference("theme = 3\n"), None, "nor is a non-string");
190 + assert_eq!(read_preference("theme = ["), None, "nor is broken TOML");
191 + }
192 +
106 193 // The console ships against these two ids; a rename in makeover's themes
107 194 // that misses this crate should fail here rather than at first launch.
108 195 #[test]