| 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 |
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 |
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 |
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 |
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 |
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!(
|