Skip to main content

max / shop

Paint from a makeover theme instead of a hex table
Author: Max Johnson <me@maxj.phd> · 2026-07-31 15:04 UTC
Signed with PGP, not checked
Commit: 461cbc5bc4250291e52bb54d6e5d38047fba1ae3
Parent: 9a1372d
5 files changed, +494 insertions, -68 deletions
M Cargo.lock +84
@@ -487,6 +487,25 @@
487 487 "png",
488 488 ]
489 489
490 + [[package]]
491 + name = "include_dir"
492 + version = "0.7.4"
493 + source = "registry+https://github.com/rust-lang/crates.io-index"
494 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
495 + dependencies = [
496 + "include_dir_macros",
497 + ]
498 +
499 + [[package]]
500 + name = "include_dir_macros"
501 + version = "0.7.4"
502 + source = "registry+https://github.com/rust-lang/crates.io-index"
503 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
504 + dependencies = [
505 + "proc-macro2",
506 + "quote",
507 + ]
508 +
490 509 [[package]]
491 510 name = "indexmap"
492 511 version = "2.14.0"
@@ -572,6 +591,15 @@
572 591 source = "registry+https://github.com/rust-lang/crates.io-index"
573 592 checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
574 593
594 + [[package]]
595 + name = "makeover"
596 + version = "2.4.0"
597 + dependencies = [
598 + "include_dir",
599 + "serde",
600 + "toml",
601 + ]
602 +
575 603 [[package]]
576 604 name = "matchers"
577 605 version = "0.2.0"
@@ -1055,6 +1083,15 @@
1055 1083 "zmij",
1056 1084 ]
1057 1085
1086 + [[package]]
1087 + name = "serde_spanned"
1088 + version = "1.1.1"
1089 + source = "registry+https://github.com/rust-lang/crates.io-index"
1090 + checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
1091 + dependencies = [
1092 + "serde_core",
1093 + ]
1094 +
1058 1095 [[package]]
1059 1096 name = "sharded-slab"
1060 1097 version = "0.1.7"
@@ -1079,6 +1116,7 @@
1079 1116 "calloop-wayland-source",
1080 1117 "image",
1081 1118 "kittygfx",
1119 + "makeover",
1082 1120 "pollster",
1083 1121 "shop-grid",
1084 1122 "shop-pty",
@@ -1086,6 +1124,7 @@
1086 1124 "shop-vt",
1087 1125 "shop-wayland",
1088 1126 "smithay-client-toolkit",
1127 + "toml",
1089 1128 "tracing",
1090 1129 "tracing-subscriber",
1091 1130 "wayland-client",
@@ -1285,6 +1324,45 @@
1285 1324 "cfg-if",
1286 1325 ]
1287 1326
1327 + [[package]]
1328 + name = "toml"
1329 + version = "1.1.4+spec-1.1.0"
1330 + source = "registry+https://github.com/rust-lang/crates.io-index"
1331 + checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
1332 + dependencies = [
1333 + "indexmap",
1334 + "serde_core",
1335 + "serde_spanned",
1336 + "toml_datetime",
1337 + "toml_parser",
1338 + "toml_writer",
1339 + "winnow",
1340 + ]
1341 +
1342 + [[package]]
1343 + name = "toml_datetime"
1344 + version = "1.1.1+spec-1.1.0"
1345 + source = "registry+https://github.com/rust-lang/crates.io-index"
1346 + checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
1347 + dependencies = [
1348 + "serde_core",
1349 + ]
1350 +
1351 + [[package]]
1352 + name = "toml_parser"
1353 + version = "1.1.3+spec-1.1.0"
1354 + source = "registry+https://github.com/rust-lang/crates.io-index"
1355 + checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
1356 + dependencies = [
1357 + "winnow",
1358 + ]
1359 +
1360 + [[package]]
1361 + name = "toml_writer"
1362 + version = "1.1.2+spec-1.1.0"
1363 + source = "registry+https://github.com/rust-lang/crates.io-index"
1364 + checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1365 +
1288 1366 [[package]]
1289 1367 name = "tracing"
1290 1368 version = "0.1.44"
@@ -1753,6 +1831,12 @@
1753 1831 "windows-link",
1754 1832 ]
1755 1833
1834 + [[package]]
1835 + name = "winnow"
1836 + version = "1.0.4"
1837 + source = "registry+https://github.com/rust-lang/crates.io-index"
1838 + checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
1839 +
1756 1840 [[package]]
1757 1841 name = "xcursor"
1758 1842 version = "0.3.10"
M Cargo.toml +12
@@ -28,6 +28,18 @@
28 28 nix = { version = "0.31", features = ["term", "process", "fs", "ioctl", "signal"] }
29 29 base64 = "0.23"
30 30 image = { version = "0.25", default-features = false, features = ["png"] }
31 + # The family's theme loading. shop resolves its sixteen ANSI slots, its default
32 + # text and page colours from a theme file rather than carrying a hex table, and
33 + # `ansi_intent` is the mapping the bare Linux console and Alloy's generated
34 + # configs use, so all three agree on what red means.
35 + makeover = "2.4.0"
36 + # For shop's own config file, and already in the tree underneath makeover.
37 + toml = "1.1"
38 +
39 + # makeover 2.4.0 is not on crates.io yet: it carries `ansi_intent`, which moved
40 + # out of Alloy's skelgen on 2026-07-31. Remove this stanza when 2.4.0 publishes.
41 + [patch.crates-io]
42 + makeover = { path = "../Libraries/makeover" }
31 43
32 44 [workspace.package]
33 45 edition = "2024"
@@ -32,3 +32,5 @@
32 32 anyhow.workspace = true
33 33 tracing.workspace = true
34 34 tracing-subscriber.workspace = true
35 + makeover.workspace = true
36 + toml.workspace = true
@@ -9,6 +9,9 @@
9 9 use std::os::fd::{AsFd, AsRawFd};
10 10 use std::sync::Arc;
11 11
12 + mod theme;
13 + use theme::{Config, Palette};
14 +
12 15 use calloop::EventLoop;
13 16 use calloop::generic::{FdWrapper, Generic};
14 17 use calloop_wayland_source::WaylandSource;
@@ -41,19 +44,15 @@
41 44 const CELL_HEIGHT: f32 = 17.0;
42 45 const PAD_X: f32 = 10.0;
43 46 const PAD_Y: f32 = 6.0;
44 - const CLEAR: wgpu::Color = wgpu::Color {
45 - r: 0.043,
46 - g: 0.055,
47 - b: 0.086,
48 - a: 1.0,
49 - };
50 - const DEFAULT_FG: [f32; 4] = [0.90, 0.92, 0.96, 1.0];
51 - const DEFAULT_BG: [f32; 4] = [0.043, 0.055, 0.086, 1.0];
52 - /// Warm-white cursor. Doubles as the disk-activity light: its "phase"
53 - /// (visible / dim) toggles on every PTY-read chunk, so rapid shell output
54 - /// produces visible flicker while an idle prompt stays steady.
55 - const CURSOR_COLOR_ON: [f32; 4] = [1.0, 0.85, 0.55, 0.85];
56 - const CURSOR_COLOR_DIM: [f32; 4] = [1.0, 0.85, 0.55, 0.28];
47 + /// How solid the cursor is at each end of its phase.
48 + ///
49 + /// The cursor doubles as the disk-activity light: the phase toggles on every
50 + /// PTY-read chunk, so rapid shell output produces visible flicker while an idle
51 + /// prompt stays steady. The colour is the theme's `action.primary`; only the
52 + /// alpha is shop's, because how much of the cell the cursor covers is a
53 + /// property of this terminal and not of the palette.
54 + const CURSOR_ALPHA_ON: f32 = 0.85;
55 + const CURSOR_ALPHA_DIM: f32 = 0.28;
57 56
58 57 fn main() -> anyhow::Result<()> {
59 58 tracing_subscriber::fmt()
@@ -79,6 +78,13 @@
79 78 .iter()
80 79 .position(|a| a == "--record")
81 80 .and_then(|i| args.get(i + 1).cloned());
81 + // `--theme ID` overrides the config file for one run, which is how you look
82 + // at a theme before committing to it.
83 + let theme_arg = args
84 + .iter()
85 + .position(|a| a == "--theme")
86 + .and_then(|i| args.get(i + 1).cloned());
87 + let palette = Palette::load(&Config::load().with_theme(theme_arg));
82 88 let (spawn_cmd, spawn_args): (String, Vec<String>) = match &exec_cmd {
83 89 Some(cmd) => ("/bin/sh".into(), vec!["-c".into(), cmd.clone()]),
84 90 None => (
@@ -192,6 +198,7 @@
192 198 grid,
193 199 parser,
194 200 kitty: kgp::Parser::new(),
201 + palette,
195 202 image_placement: None,
196 203 next_anon_image_id: 1,
197 204 font_data: FONT_BYTES.to_vec(),
@@ -496,6 +503,8 @@
496 503 grid: Grid,
497 504 parser: shop_vt::Parser,
498 505 kitty: kgp::Parser,
506 + /// Every colour shop paints, resolved from a makeover theme at startup.
507 + palette: Palette,
499 508 /// Single-image MVP: track the most recently displayed image + where.
500 509 /// Multi-image / z-order / delete-selectors are follow-ups.
501 510 image_placement: Option<ImagePlacement>,
@@ -558,7 +567,7 @@
558 567 view: &view,
559 568 resolve_target: None,
560 569 ops: wgpu::Operations {
561 - load: wgpu::LoadOp::Clear(CLEAR),
570 + load: wgpu::LoadOp::Clear(clear_color(app.palette.bg)),
562 571 store: wgpu::StoreOp::Store,
563 572 },
564 573 depth_slice: None,
@@ -594,8 +603,8 @@
594 603 if c == ' ' || c == '\0' {
595 604 return None;
596 605 }
597 - let mut fg = resolve_color(cell.fg(), DEFAULT_FG);
598 - let mut bg = resolve_color(cell.bg(), DEFAULT_BG);
606 + let mut fg = resolve_color(cell.fg(), app.palette.fg, &app.palette);
607 + let mut bg = resolve_color(cell.bg(), app.palette.bg, &app.palette);
599 608 if cell.reverse() {
600 609 std::mem::swap(&mut fg, &mut bg);
601 610 }
@@ -620,9 +629,9 @@
620 629 continue;
621 630 }
622 631 let x = pad_x_px + col as f32 * cell_w_px;
623 - let mut fg = resolve_color(cell.fg(), DEFAULT_FG);
632 + let mut fg = resolve_color(cell.fg(), app.palette.fg, &app.palette);
624 633 if has_bg {
625 - let mut bg = resolve_color(cell.bg(), DEFAULT_BG);
634 + let mut bg = resolve_color(cell.bg(), app.palette.bg, &app.palette);
626 635 if cell.reverse() {
627 636 std::mem::swap(&mut fg, &mut bg);
628 637 }
@@ -649,11 +658,13 @@
649 658 if cursor.visible {
650 659 let cx = pad_x_px + cursor.col as f32 * cell_w_px;
651 660 let cy = pad_y_px + cursor.row as f32 * cell_h_px;
652 - let base = if app.cursor_phase {
653 - CURSOR_COLOR_ON
661 + let [r, g, b, _] = app.palette.cursor;
662 + let alpha = if app.cursor_phase {
663 + CURSOR_ALPHA_ON
654 664 } else {
655 - CURSOR_COLOR_DIM
665 + CURSOR_ALPHA_DIM
656 666 };
667 + let base = [r, g, b, alpha];
657 668 // Dim to a hollow-looking ghost when the window doesn't have focus.
658 669 let color = if app.focused {
659 670 base
@@ -974,56 +985,33 @@
974 985 }
975 986 }
976 987
977 - fn resolve_color(c: GridColor, default: [f32; 4]) -> [f32; 4] {
978 - match c {
979 - GridColor::Default => default,
980 - GridColor::Named(i) => palette16(i),
981 - GridColor::Indexed(i) => palette256(i),
982 - GridColor::Rgb(r, g, b) => [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0],
988 + /// The theme's page colour, as wgpu wants the clear value.
989 + ///
990 + /// Every cell's background is painted over this, so it only shows in the
991 + /// padding around the grid. Which is exactly why it has to be the theme's page
992 + /// colour and not black: a light theme with a black frame around it reads as a
993 + /// rendering bug.
994 + fn clear_color([r, g, b, a]: [f32; 4]) -> wgpu::Color {
995 + wgpu::Color {
996 + r: f64::from(r),
997 + g: f64::from(g),
998 + b: f64::from(b),
999 + a: f64::from(a),
983 1000 }
984 1001 }
985 1002
986 - /// 16-color base palette. Values from a mid-neutral scheme close to
987 - /// alacritty's `iterm` — not the final theme, just enough that colored output
988 - /// is legible before we wire the Alloy TOKENS palette.
989 - const NAMED_16: [[u8; 3]; 16] = [
990 - [0x28, 0x2c, 0x34], // 0 black
991 - [0xe0, 0x6c, 0x75], // 1 red
992 - [0x98, 0xc3, 0x79], // 2 green
993 - [0xe5, 0xc0, 0x7b], // 3 yellow
994 - [0x61, 0xaf, 0xef], // 4 blue
995 - [0xc6, 0x78, 0xdd], // 5 magenta
996 - [0x56, 0xb6, 0xc2], // 6 cyan
997 - [0xab, 0xb2, 0xbf], // 7 white
998 - [0x5c, 0x63, 0x70], // 8 bright black (gray)
999 - [0xef, 0x8f, 0x96], // 9 bright red
1000 - [0xb8, 0xd6, 0x93], // 10 bright green
1001 - [0xf1, 0xd4, 0x99], // 11 bright yellow
1002 - [0x8c, 0xc4, 0xf3], // 12 bright blue
1003 - [0xd6, 0x99, 0xeb], // 13 bright magenta
1004 - [0x83, 0xc7, 0xd0], // 14 bright cyan
1005 - [0xc8, 0xcc, 0xd4], // 15 bright white
1006 - ];
1007 -
1008 - fn palette16(i: u8) -> [f32; 4] {
1009 - let [r, g, b] = NAMED_16[i.min(15) as usize];
1010 - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
1011 - }
1012 -
1013 - fn palette256(i: u8) -> [f32; 4] {
1014 - if i < 16 {
1015 - palette16(i)
1016 - } else if i < 232 {
1017 - let v = i - 16;
1018 - let cube = [0u8, 95, 135, 175, 215, 255];
1019 - let r = cube[(v / 36) as usize];
1020 - let g = cube[((v / 6) % 6) as usize];
1021 - let b = cube[(v % 6) as usize];
1022 - [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
1023 - } else {
1024 - let g = 8 + (i - 232) as u16 * 10;
1025 - let g = g.min(255) as f32 / 255.0;
1026 - [g, g, g, 1.0]
1003 + /// One cell's colour, as the grid asked for it.
1004 + ///
1005 + /// `Default` is the only case the palette does not answer directly: it means
1006 + /// the program said nothing, so the terminal's own default text or background
1007 + /// colour applies, and which of the two depends on whether this is a
1008 + /// foreground or a background lookup.
1009 + fn resolve_color(c: GridColor, default: [f32; 4], palette: &Palette) -> [f32; 4] {
1010 + match c {
1011 + GridColor::Default => default,
1012 + GridColor::Named(i) => palette.ansi(i),
1013 + GridColor::Indexed(i) => palette.indexed(i),
1014 + GridColor::Rgb(r, g, b) => [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0],
1027 1015 }
1028 1016 }
1029 1017
@@ -1,0 +1,340 @@
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 + }