|
1 |
+ |
//! The palette, resolved from a makeover theme rather than written here.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! A terminal owns sixteen colours that every program it hosts asks for by
|
|
4 |
+ |
//! index, plus the default foreground and background it paints when a program
|
|
5 |
+ |
//! asks for neither. Those used to be a hex table in `main.rs` with a comment
|
|
6 |
+ |
//! saying it was "not the final theme, just enough that colored output is
|
|
7 |
+ |
//! legible". This is the final theme: the same TOML files the rest of the
|
|
8 |
+ |
//! make-family reads, resolved through the same intent-to-slot mapping the bare
|
|
9 |
+ |
//! Linux console and Alloy's generated configs use
|
|
10 |
+ |
//! (`makeover::ansi_intent`).
|
|
11 |
+ |
//!
|
|
12 |
+ |
//! That mapping living in one place is the point. It was three hand-maintained
|
|
13 |
+ |
//! copies once, and no two of them agreed about what bright cyan was, so a
|
|
14 |
+ |
//! program's output changed colour depending on whether it ran on the VT or in
|
|
15 |
+ |
//! the terminal.
|
|
16 |
+ |
//!
|
|
17 |
+ |
//! # No hardcoded fallback
|
|
18 |
+ |
//!
|
|
19 |
+ |
//! There is no hex in this file, not even for the case where everything fails.
|
|
20 |
+ |
//! makeover embeds its own theme set at compile time, so [`Palette::load`] can
|
|
21 |
+ |
//! always reach `akari-night` without a file existing anywhere on the machine,
|
|
22 |
+ |
//! and a shop built from a registry checkout with no theme directory still
|
|
23 |
+ |
//! comes up themed rather than coming up in colours that exist nowhere.
|
|
24 |
+ |
|
|
25 |
+ |
use std::path::{Path, PathBuf};
|
|
26 |
+ |
|
|
27 |
+ |
use makeover::{Rgb, ThemeColors, ThemeDirs};
|
|
28 |
+ |
use tracing::warn;
|
|
29 |
+ |
|
|
30 |
+ |
/// The theme a config with no `theme` key gets.
|
|
31 |
+ |
///
|
|
32 |
+ |
/// Dark, because shop's own default was and a terminal that flips polarity on
|
|
33 |
+ |
/// upgrade is a worse surprise than one that ignores the desktop.
|
|
34 |
+ |
const DEFAULT_THEME: &str = "akari-night";
|
|
35 |
+ |
|
|
36 |
+ |
/// Every colour shop paints, resolved from one theme.
|
|
37 |
+ |
#[derive(Debug, Clone, Copy)]
|
|
38 |
+ |
pub struct Palette {
|
|
39 |
+ |
/// ANSI 0-15, as the terminal addresses them.
|
|
40 |
+ |
ansi: [[f32; 4]; 16],
|
|
41 |
+ |
/// What a cell asking for neither colour gets.
|
|
42 |
+ |
pub fg: [f32; 4],
|
|
43 |
+ |
pub bg: [f32; 4],
|
|
44 |
+ |
/// The cursor, at full strength. The dim phase is derived from it.
|
|
45 |
+ |
pub cursor: [f32; 4],
|
|
46 |
+ |
}
|
|
47 |
+ |
|
|
48 |
+ |
impl Palette {
|
|
49 |
+ |
/// Resolve the palette shop should run with.
|
|
50 |
+ |
///
|
|
51 |
+ |
/// Never fails. A theme that cannot be found or is missing an intent is
|
|
52 |
+ |
/// reported through `tracing` and falls back to the embedded default,
|
|
53 |
+ |
/// because a terminal that refuses to start is worse than one running in a
|
|
54 |
+ |
/// theme the user did not pick, and the user is often looking at this
|
|
55 |
+ |
/// terminal *because* something else broke.
|
|
56 |
+ |
pub fn load(config: &Config) -> Self {
|
|
57 |
+ |
let id = config.theme.as_deref().unwrap_or(DEFAULT_THEME);
|
|
58 |
+ |
match resolve(id, config.themes.as_deref()) {
|
|
59 |
+ |
Ok(palette) => palette,
|
|
60 |
+ |
Err(err) => {
|
|
61 |
+ |
warn!(theme = id, error = %err, "falling back to the embedded theme");
|
|
62 |
+ |
embedded(DEFAULT_THEME).expect("makeover embeds its own default")
|
|
63 |
+ |
}
|
|
64 |
+ |
}
|
|
65 |
+ |
}
|
|
66 |
+ |
|
|
67 |
+ |
/// ANSI slot `index`, which is every colour a program can name by number.
|
|
68 |
+ |
pub fn ansi(&self, index: u8) -> [f32; 4] {
|
|
69 |
+ |
self.ansi[(index & 0x0f) as usize]
|
|
70 |
+ |
}
|
|
71 |
+ |
|
|
72 |
+ |
/// The 256-colour cube and grey ramp, with the low sixteen taken from this
|
|
73 |
+ |
/// palette.
|
|
74 |
+ |
///
|
|
75 |
+ |
/// 16-255 are fixed by the protocol and are not the theme's to move: a
|
|
76 |
+ |
/// program asking for 208 has picked a specific orange out of a table it
|
|
77 |
+ |
/// expects every terminal to share. Only the low sixteen are repaintable,
|
|
78 |
+ |
/// which is why they are the only ones a theme touches.
|
|
79 |
+ |
pub fn indexed(&self, index: u8) -> [f32; 4] {
|
|
80 |
+ |
if index < 16 {
|
|
81 |
+ |
return self.ansi(index);
|
|
82 |
+ |
}
|
|
83 |
+ |
rgb_to_linear_unit(makeover::ANSI_256[index as usize])
|
|
84 |
+ |
}
|
|
85 |
+ |
}
|
|
86 |
+ |
|
|
87 |
+ |
fn resolve(id: &str, extra: Option<&Path>) -> Result<Palette, String> {
|
|
88 |
+ |
let theme = load_theme(id, extra)?;
|
|
89 |
+ |
from_theme(&theme)
|
|
90 |
+ |
}
|
|
91 |
+ |
|
|
92 |
+ |
/// Find `id` on disk, or fall back to makeover's embedded copy of it.
|
|
93 |
+ |
///
|
|
94 |
+ |
/// The embedded tier is not a nicety. shop installed from a package or built
|
|
95 |
+ |
/// from a registry checkout has no `themes/` directory of its own anywhere, so
|
|
96 |
+ |
/// without it the default theme would be unreachable on exactly the machines
|
|
97 |
+ |
/// that are not a dev tree.
|
|
98 |
+ |
fn load_theme(id: &str, extra: Option<&Path>) -> Result<ThemeColors, String> {
|
|
99 |
+ |
let dirs = ThemeDirs::new()
|
|
100 |
+ |
.bundled(makeover::bundled_themes_dir())
|
|
101 |
+ |
.system(Some(PathBuf::from("/usr/share/shop/themes")))
|
|
102 |
+ |
.custom(extra.map(Path::to_path_buf).or_else(user_themes_dir))
|
|
103 |
+ |
.build();
|
|
104 |
+ |
|
|
105 |
+ |
match makeover::load_theme(&dirs, id) {
|
|
106 |
+ |
Ok(theme) => Ok(theme),
|
|
107 |
+ |
Err(from_disk) => embedded_theme(id).ok_or(from_disk),
|
|
108 |
+ |
}
|
|
109 |
+ |
}
|
|
110 |
+ |
|
|
111 |
+ |
fn embedded_theme(id: &str) -> Option<ThemeColors> {
|
|
112 |
+ |
let (_, source) = makeover::embedded_themes().find(|(name, _)| *name == id)?;
|
|
113 |
+ |
makeover::parse_theme_str(id, source, false).ok()
|
|
114 |
+ |
}
|
|
115 |
+ |
|
|
116 |
+ |
fn embedded(id: &str) -> Option<Palette> {
|
|
117 |
+ |
from_theme(&embedded_theme(id)?).ok()
|
|
118 |
+ |
}
|
|
119 |
+ |
|
|
120 |
+ |
/// Fill every slot from the theme's own intents.
|
|
121 |
+ |
///
|
|
122 |
+ |
/// A missing intent is an error rather than a hole. Sixteen slots that mostly
|
|
123 |
+ |
/// come from the theme and occasionally keep whatever the emulator started with
|
|
124 |
+ |
/// is harder to notice, and harder to explain, than sixteen that do not.
|
|
125 |
+ |
fn from_theme(theme: &ThemeColors) -> Result<Palette, String> {
|
|
126 |
+ |
let variant = &theme.meta.variant;
|
|
127 |
+ |
let intent = |key: &str| -> Result<[f32; 4], String> {
|
|
128 |
+ |
let hex = theme
|
|
129 |
+ |
.colors
|
|
130 |
+ |
.get(key)
|
|
131 |
+ |
.ok_or_else(|| format!("theme `{}` has no `{key}`", theme.meta.id))?;
|
|
132 |
+ |
let rgb = Rgb::from_hex(hex)
|
|
133 |
+ |
.ok_or_else(|| format!("theme `{}` gave `{key}` as `{hex}`", theme.meta.id))?;
|
|
134 |
+ |
Ok(rgb_to_linear_unit(rgb))
|
|
135 |
+ |
};
|
|
136 |
+ |
|
|
137 |
+ |
let mut ansi = [[0.0; 4]; 16];
|
|
138 |
+ |
for (index, slot) in ansi.iter_mut().enumerate() {
|
|
139 |
+ |
let key = makeover::ansi_intent(index, variant)
|
|
140 |
+ |
.ok_or_else(|| format!("ANSI {index} is out of range"))?;
|
|
141 |
+ |
*slot = intent(key)?;
|
|
142 |
+ |
}
|
|
143 |
+ |
|
|
144 |
+ |
Ok(Palette {
|
|
145 |
+ |
ansi,
|
|
146 |
+ |
// Text weights, not ANSI slots. Slot 15 would be wrong here: on a light
|
|
147 |
+ |
// theme it is the overlay surface, so default text would be invisible.
|
|
148 |
+ |
fg: intent("content.primary")?,
|
|
149 |
+ |
bg: intent("surface.page")?,
|
|
150 |
+ |
cursor: intent("action.primary")?,
|
|
151 |
+ |
})
|
|
152 |
+ |
}
|
|
153 |
+ |
|
|
154 |
+ |
/// A theme colour as the renderer wants it: unit floats, alpha 1.
|
|
155 |
+ |
fn rgb_to_linear_unit(c: Rgb) -> [f32; 4] {
|
|
156 |
+ |
[
|
|
157 |
+ |
f32::from(c.r) / 255.0,
|
|
158 |
+ |
f32::from(c.g) / 255.0,
|
|
159 |
+ |
f32::from(c.b) / 255.0,
|
|
160 |
+ |
1.0,
|
|
161 |
+ |
]
|
|
162 |
+ |
}
|
|
163 |
+ |
|
|
164 |
+ |
/// What shop reads out of `~/.config/shop/config.toml`.
|
|
165 |
+ |
///
|
|
166 |
+ |
/// Deliberately two keys. A terminal's config file grows without limit if you
|
|
167 |
+ |
/// let it, and everything else shop needs so far is either a compile-time
|
|
168 |
+ |
/// constant or a command-line flag for one run.
|
|
169 |
+ |
#[derive(Debug, Clone, Default)]
|
|
170 |
+ |
pub struct Config {
|
|
171 |
+ |
/// The theme id to load, without `.toml`.
|
|
172 |
+ |
pub theme: Option<String>,
|
|
173 |
+ |
/// An extra directory to look in, ahead of the packaged and bundled tiers.
|
|
174 |
+ |
///
|
|
175 |
+ |
/// This is how shop stays useful outside the image it was written for: an
|
|
176 |
+ |
/// Alloy machine points it at `/usr/share/alloy/themes` and gets the
|
|
177 |
+ |
/// desktop's own set, and shop does not have to know that Alloy exists.
|
|
178 |
+ |
pub themes: Option<PathBuf>,
|
|
179 |
+ |
}
|
|
180 |
+ |
|
|
181 |
+ |
impl Config {
|
|
182 |
+ |
/// Read the config file, or take the defaults.
|
|
183 |
+ |
///
|
|
184 |
+ |
/// An absent file is the normal case and says nothing. A file that is
|
|
185 |
+ |
/// present and malformed is reported and then ignored, on the same grounds
|
|
186 |
+ |
/// as a bad theme: this is the program somebody opens to fix the mistake.
|
|
187 |
+ |
pub fn load() -> Self {
|
|
188 |
+ |
let Some(path) = config_path() else {
|
|
189 |
+ |
return Self::default();
|
|
190 |
+ |
};
|
|
191 |
+ |
let source = match std::fs::read_to_string(&path) {
|
|
192 |
+ |
Ok(source) => source,
|
|
193 |
+ |
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Self::default(),
|
|
194 |
+ |
Err(err) => {
|
|
195 |
+ |
warn!(path = %path.display(), error = %err, "cannot read the config");
|
|
196 |
+ |
return Self::default();
|
|
197 |
+ |
}
|
|
198 |
+ |
};
|
|
199 |
+ |
match source.parse::<toml::Table>() {
|
|
200 |
+ |
Ok(table) => Self::from_table(&table),
|
|
201 |
+ |
Err(err) => {
|
|
202 |
+ |
warn!(path = %path.display(), error = %err, "ignoring a malformed config");
|
|
203 |
+ |
Self::default()
|
|
204 |
+ |
}
|
|
205 |
+ |
}
|
|
206 |
+ |
}
|
|
207 |
+ |
|
|
208 |
+ |
fn from_table(table: &toml::Table) -> Self {
|
|
209 |
+ |
Self {
|
|
210 |
+ |
theme: table
|
|
211 |
+ |
.get("theme")
|
|
212 |
+ |
.and_then(toml::Value::as_str)
|
|
213 |
+ |
.map(str::to_owned),
|
|
214 |
+ |
themes: table
|
|
215 |
+ |
.get("themes")
|
|
216 |
+ |
.and_then(toml::Value::as_str)
|
|
217 |
+ |
.map(PathBuf::from),
|
|
218 |
+ |
}
|
|
219 |
+ |
}
|
|
220 |
+ |
|
|
221 |
+ |
/// Override the theme for one run, from `--theme ID`.
|
|
222 |
+ |
pub fn with_theme(mut self, id: Option<String>) -> Self {
|
|
223 |
+ |
if id.is_some() {
|
|
224 |
+ |
self.theme = id;
|
|
225 |
+ |
}
|
|
226 |
+ |
self
|
|
227 |
+ |
}
|
|
228 |
+ |
}
|
|
229 |
+ |
|
|
230 |
+ |
fn config_home() -> Option<PathBuf> {
|
|
231 |
+ |
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) {
|
|
232 |
+ |
return Some(PathBuf::from(dir));
|
|
233 |
+ |
}
|
|
234 |
+ |
std::env::var_os("HOME")
|
|
235 |
+ |
.filter(|h| !h.is_empty())
|
|
236 |
+ |
.map(|home| PathBuf::from(home).join(".config"))
|
|
237 |
+ |
}
|
|
238 |
+ |
|
|
239 |
+ |
fn config_path() -> Option<PathBuf> {
|
|
240 |
+ |
Some(config_home()?.join("shop").join("config.toml"))
|
|
241 |
+ |
}
|
|
242 |
+ |
|
|
243 |
+ |
fn user_themes_dir() -> Option<PathBuf> {
|
|
244 |
+ |
Some(config_home()?.join("shop").join("themes"))
|
|
245 |
+ |
}
|
|
246 |
+ |
|
|
247 |
+ |
#[cfg(test)]
|
|
248 |
+ |
mod tests {
|
|
249 |
+ |
use super::*;
|
|
250 |
+ |
|
|
251 |
+ |
/// Compare colours by bit pattern.
|
|
252 |
+ |
///
|
|
253 |
+ |
/// These are exact-by-construction: both sides divide the same byte by the
|
|
254 |
+ |
/// same constant, so there is no tolerance to choose and an approximate
|
|
255 |
+ |
/// comparison would only hide a slot resolving from the wrong intent.
|
|
256 |
+ |
fn bits(c: [f32; 4]) -> [u32; 4] {
|
|
257 |
+ |
c.map(f32::to_bits)
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
// The property that makes this worth doing at all: shop's slots are the
|
|
261 |
+ |
// same slots the console and the generated configs get, because they all
|
|
262 |
+ |
// ask makeover the same question.
|
|
263 |
+ |
#[test]
|
|
264 |
+ |
fn every_slot_comes_from_the_theme() {
|
|
265 |
+ |
let theme = embedded_theme("akari-night").expect("makeover embeds akari-night");
|
|
266 |
+ |
let palette = from_theme(&theme).expect("akari-night resolves");
|
|
267 |
+ |
for index in 0..16u8 {
|
|
268 |
+ |
let key = makeover::ansi_intent(index as usize, &theme.meta.variant).unwrap();
|
|
269 |
+ |
let hex = theme.colors.get(key).unwrap();
|
|
270 |
+ |
let want = rgb_to_linear_unit(Rgb::from_hex(hex).unwrap());
|
|
271 |
+ |
assert_eq!(bits(palette.ansi(index)), bits(want), "slot {index}");
|
|
272 |
+ |
}
|
|
273 |
+ |
}
|
|
274 |
+ |
|
|
275 |
+ |
// 16-255 belong to the protocol. A program asking for 208 wants that
|
|
276 |
+ |
// orange, not the theme's opinion of it.
|
|
277 |
+ |
#[test]
|
|
278 |
+ |
fn the_fixed_region_is_not_the_themes_to_move() {
|
|
279 |
+ |
let palette = embedded(DEFAULT_THEME).expect("the default resolves");
|
|
280 |
+ |
assert_eq!(
|
|
281 |
+ |
bits(palette.indexed(208)),
|
|
282 |
+ |
bits(rgb_to_linear_unit(makeover::ANSI_256[208]))
|
|
283 |
+ |
);
|
|
284 |
+ |
assert_eq!(
|
|
285 |
+ |
bits(palette.indexed(3)),
|
|
286 |
+ |
bits(palette.ansi(3)),
|
|
287 |
+ |
"the low sixteen are"
|
|
288 |
+ |
);
|
|
289 |
+ |
}
|
|
290 |
+ |
|
|
291 |
+ |
// Both polarities resolve. The four achromatic slots come from different
|
|
292 |
+ |
// intents per variant, so a theme that only ever loaded one polarity would
|
|
293 |
+ |
// hide a missing intent in the other.
|
|
294 |
+ |
#[test]
|
|
295 |
+ |
fn both_shipped_polarities_resolve() {
|
|
296 |
+ |
for id in ["akari-dawn", "akari-night"] {
|
|
297 |
+ |
assert!(embedded(id).is_some(), "{id} did not resolve");
|
|
298 |
+ |
}
|
|
299 |
+ |
}
|
|
300 |
+ |
|
|
301 |
+ |
// A named theme that does not exist must not take the terminal down with
|
|
302 |
+ |
// it, and must not leave it half-themed either.
|
|
303 |
+ |
#[test]
|
|
304 |
+ |
fn an_unknown_theme_falls_back_whole() {
|
|
305 |
+ |
let config = Config {
|
|
306 |
+ |
theme: Some("no-such-theme".into()),
|
|
307 |
+ |
themes: None,
|
|
308 |
+ |
};
|
|
309 |
+ |
let palette = Palette::load(&config);
|
|
310 |
+ |
let want = embedded(DEFAULT_THEME).unwrap();
|
|
311 |
+ |
assert_eq!(palette.ansi.map(bits), want.ansi.map(bits));
|
|
312 |
+ |
assert_eq!(bits(palette.bg), bits(want.bg));
|
|
313 |
+ |
}
|
|
314 |
+ |
|
|
315 |
+ |
#[test]
|
|
316 |
+ |
fn a_config_names_a_theme_and_a_directory() {
|
|
317 |
+ |
let table: toml::Table = "theme = \"akari-dawn\"\nthemes = \"/usr/share/alloy/themes\"\n"
|
|
318 |
+ |
.parse()
|
|
319 |
+ |
.unwrap();
|
|
320 |
+ |
let config = Config::from_table(&table);
|
|
321 |
+ |
assert_eq!(config.theme.as_deref(), Some("akari-dawn"));
|
|
322 |
+ |
assert_eq!(
|
|
323 |
+ |
config.themes,
|
|
324 |
+ |
Some(PathBuf::from("/usr/share/alloy/themes"))
|
|
325 |
+ |
);
|
|
326 |
+ |
}
|
|
327 |
+ |
|
|
328 |
+ |
// An empty config is a valid config, and the flag beats the file.
|
|
329 |
+ |
#[test]
|
|
330 |
+ |
fn the_command_line_overrides_the_file() {
|
|
331 |
+ |
let config = Config::default().with_theme(Some("akari-dawn".into()));
|
|
332 |
+ |
assert_eq!(config.theme.as_deref(), Some("akari-dawn"));
|
|
333 |
+ |
let unchanged = Config {
|
|
334 |
+ |
theme: Some("akari-night".into()),
|
|
335 |
+ |
themes: None,
|
|
336 |
+ |
}
|
|
337 |
+ |
.with_theme(None);
|
|
338 |
+ |
assert_eq!(unchanged.theme.as_deref(), Some("akari-night"));
|
|
339 |
+ |
}
|
|
340 |
+ |
}
|