Skip to main content

max / alloy

10.5 KB · 266 lines History Blame Raw
1 //! The templates themselves, rendered against the themes the image ships.
2 //!
3 //! The unit tests cover the renderer; this covers the tree it renders. Without
4 //! it a mistyped token in a config file is caught by `podman build`, which is
5 //! the slowest feedback loop in the project and the one least likely to be run
6 //! before a commit.
7
8 use std::path::{Path, PathBuf};
9
10 use makeover::Rgb;
11 use skelgen::{Palette, render, theme_directive};
12
13 fn repo() -> PathBuf {
14 // crates/skelgen -> the repo root.
15 Path::new(env!("CARGO_MANIFEST_DIR"))
16 .ancestors()
17 .nth(2)
18 .expect("skelgen lives two levels under the repo root")
19 .to_path_buf()
20 }
21
22 fn palette(id: &str) -> Palette {
23 let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes");
24 let theme = makeover::load_theme(&[(dir, false)], id).unwrap_or_else(|e| panic!("{id}: {e}"));
25 Palette::new(id, &theme).unwrap_or_else(|e| panic!("{id}: {e}"))
26 }
27
28 fn templates() -> Vec<PathBuf> {
29 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
30 for entry in std::fs::read_dir(dir).expect("template tree is readable") {
31 let path = entry.expect("readable entry").path();
32 if path.is_dir() {
33 walk(&path, out);
34 } else if path.extension().is_some_and(|e| e == "in") {
35 out.push(path);
36 }
37 }
38 }
39 let mut out = Vec::new();
40 walk(&repo().join("templates"), &mut out);
41 out.sort();
42 assert!(!out.is_empty(), "found no templates under templates/");
43 out
44 }
45
46 /// Every template renders, against every theme its directive names.
47 ///
48 /// A `variants` template is rendered once per name here, the same fan-out the
49 /// build does, because a token that resolves on one polarity and not the other
50 /// is a file that only breaks after dark.
51 #[test]
52 fn the_whole_skeleton_renders() {
53 let default = palette("akari-dawn");
54 let night = palette("akari-night");
55
56 for path in templates() {
57 let text = std::fs::read_to_string(&path).expect("template is readable");
58 let (directive, body) =
59 theme_directive(&text).unwrap_or_else(|e| panic!("{}: {e:#}", path.display()));
60 let target = path.to_string_lossy().trim_end_matches(".in").to_string();
61 let renders = directive
62 .renders(&target)
63 .unwrap_or_else(|e| panic!("{}: {e:#}", path.display()));
64
65 for (theme, out_path) in renders {
66 let palette = match theme.as_str() {
67 "default" => &default,
68 "night" => &night,
69 other => panic!("{}: unknown theme `{other}`", path.display()),
70 };
71 let out = render(&body, palette).unwrap_or_else(|e| panic!("{out_path}: {e:#}"));
72 assert!(
73 !out.contains("@{"),
74 "{out_path}: an expression survived rendering"
75 );
76 }
77 }
78 }
79
80 /// No template smuggles a hex literal past the generator.
81 ///
82 /// The whole point of the tree is that colors come from the theme. A literal is
83 /// not always wrong — a comment can quote one, and the Helix header does — so
84 /// this checks the lines that assign rather than every line.
85 ///
86 /// Both spellings, because one of them was a blind spot the size of the
87 /// lockscreen: swaylock and imv take their colors *bare*, with no `#`, so this
88 /// test would have passed their literals even once the files became templates.
89 /// `#`-prefixed
90 /// is checked against the whole line and bare against the code before any
91 /// comment, which is not an inconsistency: `#` is both the hex prefix and the
92 /// comment character, so a `#rrggbb` value *is* everything after a `#` and
93 /// cutting there would throw the value away.
94 #[test]
95 fn no_template_assigns_a_hex_literal() {
96 for path in templates() {
97 let text = std::fs::read_to_string(&path).expect("template is readable");
98 for (n, line) in text.lines().enumerate() {
99 let code = line.split('#').next().unwrap_or("");
100 let is_comment =
101 line.trim_start().starts_with('#') || line.trim_start().starts_with("/*");
102 if is_comment || !code.contains('=') {
103 continue;
104 }
105 let word = |w: &str| {
106 w.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '#')
107 .to_string()
108 };
109 let prefixed = line
110 .split_whitespace()
111 .find(|w| word(w).len() == 7 && w.contains('#'));
112 // Six or eight digits, `rrggbb` and swaylock's `rrggbbaa`. At least
113 // one a-f, so a plain decimal that happens to be six digits long is
114 // not a color: `indicator-radius=90` is fine, and so would be
115 // `font-size=100000`.
116 let bare = code.split_whitespace().map(word).find(|w| {
117 matches!(w.len(), 6 | 8)
118 && w.bytes().all(|b| b.is_ascii_hexdigit())
119 && w.bytes().any(|b| b.is_ascii_alphabetic())
120 });
121 assert!(
122 prefixed.is_none() && bare.is_none(),
123 "{}:{}: hex literal in an assignment: {line}",
124 path.display(),
125 n + 1
126 );
127 }
128 }
129 }
130
131 /// The Helix palettes stay legible on both polarities.
132 ///
133 /// This is the check the hand-authored pair had no way to run, and the one that
134 /// caught the two real regressions in deriving them: `comment` resolving to a
135 /// near-foreground tone on the dark theme, and `bright-white` landing lighter
136 /// than the page on the light one.
137 #[test]
138 fn every_helix_foreground_reads_against_its_background() {
139 for (theme, file) in [
140 ("akari-dawn", "akari-dawn.toml.in"),
141 ("akari-night", "akari-night.toml.in"),
142 ] {
143 let path = repo()
144 .join("templates/etc/skel/.config/helix/themes")
145 .join(file);
146 let text = std::fs::read_to_string(&path).expect("helix template is readable");
147 let (_, body) = theme_directive(&text).expect("helix template has a valid directive");
148 let out = render(&body, &palette(theme)).expect("helix template renders");
149
150 let entries = palette_entries(&out);
151 let background = entries
152 .iter()
153 .find(|(k, _)| k == "background")
154 .map(|(_, v)| *v)
155 .expect("the palette declares a background");
156
157 // Only the tones the highlight rules actually put in a foreground. A
158 // palette entry nothing references cannot be illegible, and several are
159 // unreferenced — `bright-white` is declared for completeness and used
160 // nowhere. Reading the rules rather than listing the fills by hand also
161 // means this keeps working when a rule changes which tone it uses.
162 let used = foreground_names(&out);
163 assert!(
164 used.len() > 10,
165 "{theme}: only found {} foreground names; the parser is wrong",
166 used.len()
167 );
168
169 // Three exemptions, and the reason differs.
170 //
171 // `border` is a divider rule, and docs/TOKENS.md names `border-subtle`
172 // a decorative divider on purpose — a separator that met AA-UI would be
173 // a line, not a hairline.
174 //
175 // `yellow` and `amber` are one value, the theme's authored
176 // `status.warning`, spent on the diff gutter. TOKENS.md's
177 // "accent-on-glyph, not on body text" rule covers exactly this. It is
178 // 2.80:1 on Akari Dawn's page, which is short even of AA-UI: a property
179 // of the authored theme rather than of the derivation, and the same
180 // value the hand-written file shipped. Worth fixing in the theme, not
181 // here.
182 //
183 // Everything else clears 3.0 on both polarities.
184 let decorative = ["border", "yellow", "amber"];
185
186 for (key, value) in &entries {
187 if !used.contains(key) || decorative.contains(&key.as_str()) {
188 continue;
189 }
190 let contrast = makeover::wcag_contrast(*value, background);
191 assert!(
192 contrast >= 3.0,
193 "{theme}: `{key}` = {} is only {contrast:.2}:1 on the background",
194 value.to_hex()
195 );
196 }
197 }
198 }
199
200 /// Palette names the highlight rules use as a foreground.
201 ///
202 /// Helix writes a foreground three ways: `fg = "name"`, a bare `"name"` as the
203 /// whole value, and inside an inline table alongside a `bg`. All three land in
204 /// the same place, so this reads the rule half of the file and collects every
205 /// quoted name that is not sitting behind a `bg =`.
206 fn foreground_names(rendered: &str) -> Vec<String> {
207 let rules = rendered
208 .split_once("[palette]")
209 .map_or(rendered, |(rules, _)| rules);
210 let mut names = Vec::new();
211 for line in rules.lines() {
212 let line = line.trim();
213 if line.starts_with('#') || !line.contains('=') {
214 continue;
215 }
216 let Some((_, value)) = line.split_once('=') else {
217 continue;
218 };
219 let value = value.trim();
220 if let Some(inner) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) {
221 // A rule that sets its own background is contrasted against *that*,
222 // not against the page — `ui.statusline.insert` puts the page color
223 // on a lantern chip, which is correct and would fail a check
224 // against the page. Only rules that let the page show through are
225 // this test's business.
226 if inner.contains("bg") {
227 continue;
228 }
229 for field in inner.split(',') {
230 let Some((key, name)) = field.split_once('=') else {
231 continue;
232 };
233 if key.trim() == "fg"
234 && let Some(name) = name.split('"').nth(1)
235 {
236 names.push(name.to_string());
237 }
238 }
239 } else if let Some(name) = value.split('"').nth(1) {
240 // A bare value is a foreground.
241 names.push(name.to_string());
242 }
243 }
244 names.sort();
245 names.dedup();
246 names
247 }
248
249 fn palette_entries(rendered: &str) -> Vec<(String, Rgb)> {
250 let (_, palette) = rendered
251 .split_once("[palette]")
252 .expect("a helix theme ends with its palette");
253 palette
254 .lines()
255 .filter_map(|line| {
256 let line = line.trim();
257 if line.starts_with('#') {
258 return None;
259 }
260 let (key, value) = line.split_once('=')?;
261 let value = value.trim().trim_matches('"');
262 Some((key.trim().to_string(), Rgb::from_hex(value)?))
263 })
264 .collect()
265 }
266