Skip to main content

max / alloy

38.4 KB · 955 lines History Blame Raw
1 //! The desktop skeleton, rendered from a makeover theme.
2 //!
3 //! A color anywhere under `etc/skel/` written as a hex literal is a hand
4 //! transcription of `themes/akari-dawn.toml` that nothing would notice going out
5 //! of agreement with the theme. So the tree is templates and this renders them,
6 //! at image-build time, from the same theme file the console loads. Same rule
7 //! docs/TOKENS.md states for the console itself: no hex in the source.
8 //!
9 //! A config that cannot read `#rrggbb`, such as swaylock's or imv's, is rendered
10 //! through [`eval_format`] rather than left with literals. `etc/skel/` holds
11 //! only the files that carry no
12 //! color at all.
13 //!
14 //! # Template syntax
15 //!
16 //! A template is the target file with its color literals replaced by `@{ }`
17 //! expressions. Everything outside the braces is copied through untouched, so a
18 //! template still reads as the config file it produces.
19 //!
20 //! The delimiter is `@{` and not the more usual `{{` because these are real
21 //! config files and `{{` is taken: yazi's theme carries vim fold markers
22 //! (`{{{`) in its section comments, and TOML inline tables put braces next to
23 //! each other freely. `@{` appears nowhere in the tree.
24 //!
25 //! ```text
26 //! background = "@{surface.page}" an authored intent
27 //! border = "@{border.strong}" an Alloy-derived token
28 //! edge = "@{bevel.light}" a makeover-derived token
29 //! raised = "@{mix(surface.page, surface.sunken, 0.5)}"
30 //! label = "@{readable_on(status.danger)}"
31 //! ```
32 //!
33 //! A color renders as `#rrggbb`, which is what all but three of the consumers
34 //! read. The rest take an output format, and those are the outermost call rather
35 //! than something a color function composes over, because their result is text
36 //! and not a color (see [`eval_format`]).
37 //!
38 //! ```text
39 //! background = "@{hex_bare(surface.page)}" e4ded6
40 //! color = "@{hex_alpha(surface.page, 1.0)}" e4ded6ff
41 //! highlight = "@{rgba(status.warning, 0.35)}" rgba(176, 120, 64, 0.35)
42 //! ```
43 //!
44 //! An optional first-line directive picks which theme the file renders against;
45 //! without one it takes the default. The directive line is stripped from the
46 //! output.
47 //!
48 //! ```text
49 //! @{! theme = night } render once, against `night`
50 //! @{! variants = default, night } render once per name
51 //! ```
52 //!
53 //! `variants` is how a file that every program reads by one fixed path ships in
54 //! both polarities: the first name renders to the plain path and each later name
55 //! `N` renders to a `.N` sibling. `~/.config/mako/config` and
56 //! `~/.config/mako/config.night` are the same template, and `alloy theme apply`
57 //! copies whichever the session's mode calls for. The Helix themes are the other
58 //! case and still take `theme`: Helix picks between them by filename, so they are
59 //! two files by name rather than one file in two renders.
60
61 use std::collections::BTreeMap;
62
63 use anyhow::{Context, Result, anyhow, bail};
64 use makeover::{Rgb, ThemeColors};
65
66 /// The intent painting ANSI slot `index` under a theme of `variant`.
67 ///
68 /// The table itself is `makeover::ansi_intent`. It lives there rather than here
69 /// because `shop` paints its palette at runtime from a theme rather than reading
70 /// a generated config, so it needs the mapping as code and not as rendered hex,
71 /// and a copy in shop would be a second copy of the one table every surface
72 /// answers to.
73 ///
74 /// Kept as a re-export rather than deleted: templates reach it through
75 /// `@{ansi.N}` and [`Palette::ansi`], and the callers here read better naming
76 /// the crate that owns the rest of the skeleton.
77 pub use makeover::ansi_intent;
78
79 /// Every color a template can name, resolved from one theme.
80 ///
81 /// Authored intents come from the theme file as-is. The derived ones are asked
82 /// of the crates that own them rather than recomputed here — `bevel.*` from
83 /// makeover, `border.*` from `alloy_tui` — because a second implementation of a
84 /// derivation is a second answer to what the token is, and the whole point of
85 /// generating this tree is that there is one answer.
86 pub struct Palette {
87 name: String,
88 /// The theme's own id (`akari-dawn`), which is not [`Palette::name`]: that
89 /// is the `--theme` key (`default`, `night`) and is a build-time label. A
90 /// file naming a theme to a program has to name the id, because that is
91 /// what the program will look for. Helix handed `theme = "night"` finds no
92 /// such theme and falls back to its own default without saying so.
93 id: String,
94 /// `light`, `dark` or `high-contrast`, straight from the theme's `[meta]`.
95 /// Only the ANSI table consults it; everything else is polarity-agnostic
96 /// because the intents already carry the meaning.
97 variant: String,
98 /// The theme's own display name, for files that print it.
99 display_name: String,
100 tokens: BTreeMap<String, Rgb>,
101 }
102
103 impl Palette {
104 /// Resolve a loaded theme into every token a template may name.
105 pub fn new(name: impl Into<String>, theme: &ThemeColors) -> Result<Self> {
106 let name = name.into();
107 let mut tokens = BTreeMap::new();
108
109 // Authored intents, under the dotted names the theme file uses.
110 for (key, value) in &theme.colors {
111 if let Some(rgb) = Rgb::from_hex(value) {
112 tokens.insert(key.clone(), rgb);
113 }
114 }
115
116 let need = |k: &str| -> Result<Rgb> {
117 tokens
118 .get(k)
119 .copied()
120 .ok_or_else(|| anyhow!("theme `{name}` is missing required intent `{k}`"))
121 };
122 let line_border = need("line.border")?;
123 let surface_page = need("surface.page")?;
124 let content_primary = need("content.primary")?;
125
126 // Alloy's two border tiers, from alloy_tui — the console renders these
127 // same two functions, so the skeleton and the console cannot disagree.
128 tokens.insert(
129 "border.subtle".into(),
130 alloy_tui::border_subtle(line_border, surface_page),
131 );
132 tokens.insert(
133 "border.strong".into(),
134 alloy_tui::border_strong(line_border, content_primary),
135 );
136
137 // The bevel pair, from makeover, for the same reason.
138 let resolved = makeover::resolve(theme);
139 for (token, intent) in [("bevel.light", "bevel-light"), ("bevel.dark", "bevel-dark")] {
140 let hex = resolved
141 .hex(intent)
142 .ok_or_else(|| anyhow!("theme `{name}` yielded no `{intent}`"))?;
143 let rgb = Rgb::from_hex(hex)
144 .ok_or_else(|| anyhow!("theme `{name}` gave `{intent}` as invalid hex `{hex}`"))?;
145 tokens.insert(token.into(), rgb);
146 }
147
148 Ok(Self {
149 name,
150 id: theme.meta.id.clone(),
151 variant: theme.meta.variant.clone(),
152 display_name: theme.meta.name.clone(),
153 tokens,
154 })
155 }
156
157 /// The name this palette was loaded under, for error messages.
158 pub fn name(&self) -> &str {
159 &self.name
160 }
161
162 /// The theme's own `[meta] id` ("akari-dawn"), as another program names it.
163 pub fn id(&self) -> &str {
164 &self.id
165 }
166
167 /// The theme's own `[meta] name`, as a human reads it ("Akari Dawn").
168 pub fn display_name(&self) -> &str {
169 &self.display_name
170 }
171
172 /// `light`, `dark` or `high-contrast`.
173 pub fn variant(&self) -> &str {
174 &self.variant
175 }
176
177 /// Look up an authored or derived token.
178 pub fn get(&self, path: &str) -> Option<Rgb> {
179 self.tokens.get(path).copied()
180 }
181
182 /// The ANSI slot at `index`, per [`ansi_intent`] and this theme's polarity.
183 pub fn ansi(&self, index: usize) -> Result<Rgb> {
184 let path = ansi_intent(index, &self.variant)
185 .ok_or_else(|| anyhow!("ANSI index {index} is out of range (0-15)"))?;
186 self.get(path)
187 .ok_or_else(|| anyhow!("theme `{}` is missing `{path}` (ANSI {index})", self.name))
188 }
189
190 /// One channel of the sixteen-entry `setvtrgb` table, as the kernel's
191 /// `vt.default_*` cmdline argument expects it: sixteen `0xNN` bytes.
192 ///
193 /// The Linux console is the one surface with no emulator under it and no
194 /// 24-bit escape to fall back on, so this table *is* the greeter's palette.
195 pub fn vt_channel(&self, channel: Channel) -> Result<String> {
196 let mut out = Vec::with_capacity(16);
197 for index in 0..16 {
198 let rgb = self.ansi(index)?;
199 let byte = match channel {
200 Channel::Red => rgb.r,
201 Channel::Green => rgb.g,
202 Channel::Blue => rgb.b,
203 };
204 out.push(format!("0x{byte:02x}"));
205 }
206 Ok(out.join(","))
207 }
208
209 /// The whole `setvtrgb` table, in the file format that tool reads: three
210 /// lines of sixteen comma-separated decimal values, all reds then all
211 /// greens then all blues.
212 ///
213 /// **Comma, not whitespace.** kbd's `setvtrgb` parses each line with a
214 /// comma-delimited scan and rejects anything else with "Insufficient number
215 /// of fields", which is a parse failure before it ever opens a console.
216 /// Emitting spaces makes `alloy-vtrgb.service` fail on every boot with the
217 /// console palette never applied. Verified both ways against the shipped
218 /// kbd: the space form dies at parsing, the comma form parses and reaches
219 /// the console.
220 ///
221 /// The kernel cmdline (`vt.default_*`, via [`Palette::vt_channel`]) and
222 /// this file are the same sixteen colors applied twice — once by the kernel
223 /// before userspace, once by `alloy-vtrgb.service` after. They were
224 /// maintained separately and had drifted apart; now neither can move
225 /// without the other.
226 pub fn vtrgb_table(&self) -> Result<String> {
227 let slots: Vec<Rgb> = (0..16).map(|i| self.ansi(i)).collect::<Result<_>>()?;
228 let mut out = String::new();
229 for channel in [Channel::Red, Channel::Green, Channel::Blue] {
230 let row: Vec<String> = slots
231 .iter()
232 .map(|c| {
233 match channel {
234 Channel::Red => c.r,
235 Channel::Green => c.g,
236 Channel::Blue => c.b,
237 }
238 .to_string()
239 })
240 .collect();
241 out.push_str(&row.join(","));
242 out.push('\n');
243 }
244 Ok(out)
245 }
246 }
247
248 /// Which channel of the console palette table to emit.
249 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
250 pub enum Channel {
251 Red,
252 Green,
253 Blue,
254 }
255
256 /// The theme a template with no directive renders against, and the name a
257 /// `variants` list has to start with.
258 pub const DEFAULT_THEME: &str = "default";
259
260 /// What a template's first line asks for.
261 #[derive(Debug, Clone, PartialEq, Eq)]
262 pub enum Directive {
263 /// No directive: render once against [`DEFAULT_THEME`], to the plain target
264 /// path.
265 None,
266 /// `theme = X`: render once against X, to the plain target path.
267 Theme(String),
268 /// `variants = A, B, ...`: render once per name. A goes to the plain target
269 /// path; every later name N goes to `<target>.N`.
270 Variants(Vec<String>),
271 }
272
273 impl Directive {
274 /// The renders this directive asks for, as `(theme name, target path)`.
275 ///
276 /// The suffix is the `--theme` name verbatim, so a third palette is a third
277 /// word in the template and no change here.
278 pub fn renders(&self, target: &str) -> Result<Vec<(String, String)>> {
279 Ok(match self {
280 Self::None => vec![(DEFAULT_THEME.to_string(), target.to_string())],
281 Self::Theme(name) => vec![(name.clone(), target.to_string())],
282 Self::Variants(names) => {
283 // Checked here rather than at parse time because it is a rule
284 // about what the image ships, not about the grammar: the plain
285 // file is what lands in `/etc/skel`, `useradd` copies it before
286 // anything has read a mode file, so a reversed list would give
287 // every new account a dark desktop it never asked for.
288 let first = names
289 .first()
290 .ok_or_else(|| anyhow!("`variants` names no themes"))?;
291 if first != DEFAULT_THEME {
292 bail!(
293 "`variants` must start with `{DEFAULT_THEME}`; the plain file is what \
294 /etc/skel ships and that has to be the light render"
295 );
296 }
297 names
298 .iter()
299 .enumerate()
300 .map(|(i, name)| {
301 let path = if i == 0 {
302 target.to_string()
303 } else {
304 format!("{target}.{name}")
305 };
306 (name.clone(), path)
307 })
308 .collect()
309 }
310 })
311 }
312 }
313
314 /// Read the first-line directive, if the file carries one.
315 ///
316 /// Returns what it asks for plus the template with the directive line removed.
317 /// Most files take the default and carry no directive at all.
318 ///
319 /// A first line that looks like a directive and is not one is an error. Treating
320 /// it as "no directive" leaves the `@{! ... }` line in the body, where `render`
321 /// fails complaining about a token named `! varients = default, night`, naming
322 /// neither the line nor the mistake.
323 pub fn theme_directive(template: &str) -> Result<(Directive, String)> {
324 let Some(first) = template.lines().next() else {
325 return Ok((Directive::None, template.to_string()));
326 };
327 let trimmed = first.trim();
328 let Some(body) = trimmed
329 .strip_prefix("@{!")
330 .and_then(|s| s.strip_suffix('}'))
331 else {
332 return Ok((Directive::None, template.to_string()));
333 };
334 let (key, value) = body
335 .split_once('=')
336 .ok_or_else(|| anyhow!("directive `{trimmed}` is not `key = value`"))?;
337 let (key, value) = (key.trim(), value.trim());
338
339 let directive = match key {
340 "theme" => {
341 if value.contains(',') {
342 bail!("`theme` takes one name; use `variants` for more than one");
343 }
344 Directive::Theme(value.to_string())
345 }
346 "variants" => {
347 let names: Vec<String> = value
348 .split(',')
349 .map(str::trim)
350 .filter(|name| !name.is_empty())
351 .map(str::to_string)
352 .collect();
353 if names.len() < 2 {
354 bail!("`variants` needs at least two names; use `theme` for one");
355 }
356 for (i, name) in names.iter().enumerate() {
357 if names[..i].contains(name) {
358 bail!("`variants` names `{name}` twice");
359 }
360 }
361 Directive::Variants(names)
362 }
363 other => bail!("unknown directive key `{other}`; expected `theme` or `variants`"),
364 };
365
366 let rest = template
367 .split_once('\n')
368 .map_or(String::new(), |(_, rest)| rest.to_string());
369 Ok((directive, rest))
370 }
371
372 /// Substitute every `@{ }` expression in `template` against `palette`.
373 pub fn render(template: &str, palette: &Palette) -> Result<String> {
374 let mut out = String::with_capacity(template.len());
375 let mut rest = template;
376
377 while let Some(start) = rest.find("@{") {
378 out.push_str(&rest[..start]);
379 let after = &rest[start + 2..];
380 // Closing on the first `}` is safe because the grammar has no braces of
381 // its own: an expression is token paths, calls and numbers.
382 let end = after
383 .find('}')
384 .ok_or_else(|| anyhow!("unterminated `@{{` near: {}", snippet(after)))?;
385 let expr = after[..end].trim();
386 out.push_str(&eval(expr, palette).with_context(|| format!("evaluating `@{{{expr}}}`"))?);
387 rest = &after[end + 1..];
388 }
389 out.push_str(rest);
390 Ok(out)
391 }
392
393 fn snippet(s: &str) -> String {
394 s.chars().take(40).collect()
395 }
396
397 /// Evaluate one expression to the text it stands for.
398 ///
399 /// Most expressions are colors and render as `#rrggbb`. Two families are not,
400 /// and are handled before the color grammar rather than inside it: `vt.*`
401 /// stands for a whole sixteen-byte row, and `meta.*` for a piece of the theme's
402 /// own metadata. `meta.name` is what lets the two Helix templates be the same
403 /// file but for their directive — the only thing that differed between the
404 /// hand-written pair was the theme's name in a header comment.
405 ///
406 /// `meta.id` and `meta.is_dark` are what the mode-dependent files that carry no
407 /// color at all render from: `theme = "@{meta.id}"` in Helix's config and
408 /// `gtk-application-prefer-dark-theme = @{meta.is_dark}` in the GTK settings.
409 /// Both differ between the day and night renders without a single hex literal
410 /// changing, which is the reason `variants` is a property of the template rather
411 /// than of the token list.
412 fn eval(expr: &str, palette: &Palette) -> Result<String> {
413 match expr {
414 "vt.red" => return palette.vt_channel(Channel::Red),
415 "vt.grn" => return palette.vt_channel(Channel::Green),
416 "vt.blu" => return palette.vt_channel(Channel::Blue),
417 "vt.table" => return palette.vtrgb_table(),
418 "meta.id" => return Ok(palette.id().to_string()),
419 "meta.name" => return Ok(palette.display_name().to_string()),
420 "meta.variant" => return Ok(palette.variant().to_string()),
421 // `high-contrast` renders `false`, following `achromatic_slot`'s rule
422 // that anything not `dark` takes the light anchors.
423 "meta.is_dark" => return Ok((palette.variant() == "dark").to_string()),
424 _ => {}
425 }
426 if let Some(formatted) = eval_format(expr, palette)? {
427 return Ok(formatted);
428 }
429 Ok(eval_color(expr, palette)?.to_hex())
430 }
431
432 /// The shapes a color takes that are not `#rrggbb`.
433 ///
434 /// Every color expression resolves to an [`Rgb`], and most config files want it
435 /// written the CSS way, so [`eval`] ends in `to_hex`. Three consumers in the
436 /// tree cannot read that, and each was the reason a file stayed hand-written:
437 ///
438 /// - **swaylock** wants `rrggbbaa`, bare and with an alpha. It has no `#` form
439 /// at all, so its config could not be templated until there was a function
440 /// here. It is on the lock path, which made it the worst file in the tree to
441 /// leave in one polarity: locking a night session flashed a light screen.
442 /// - **imv** wants bare `rrggbb`.
443 /// - **zathura** wants `rgba(r, g, b, a)` with the channels in decimal, for the
444 /// two search-highlight colors that need to let the glyph under them through.
445 ///
446 /// Returns `None` for anything that is not one of these, so [`eval_color`] keeps
447 /// reporting unknown functions and malformed calls. That is also why a missing
448 /// closing paren is passed along rather than diagnosed here: one place should own
449 /// that message.
450 fn eval_format(expr: &str, palette: &Palette) -> Result<Option<String>> {
451 let expr = expr.trim();
452 let Some(open) = expr.find('(') else {
453 return Ok(None);
454 };
455 if !expr.ends_with(')') {
456 return Ok(None);
457 }
458 let name = expr[..open].trim();
459 if !matches!(name, "hex_bare" | "hex_alpha" | "rgba") {
460 return Ok(None);
461 }
462
463 let args = split_args(&expr[open + 1..expr.len() - 1])?;
464 let color = eval_color(
465 args.first()
466 .ok_or_else(|| anyhow!("`{name}` wants a color in position 0"))?,
467 palette,
468 )?;
469 // Bare, not `#rrggbb` minus a character: `to_hex` stays the one place that
470 // decides the digits and their case.
471 let bare = || color.to_hex().trim_start_matches('#').to_string();
472
473 // Out of range is an error rather than a clamp. An alpha is written by hand
474 // in the template, so a 35 meant as a percentage is a typo worth a build
475 // failure, not a value to quietly read as opaque.
476 let alpha = |i: usize| -> Result<f32> {
477 let arg = args
478 .get(i)
479 .ok_or_else(|| anyhow!("`{name}` wants an alpha in position {i}"))?;
480 let value: f32 = arg
481 .trim()
482 .parse()
483 .with_context(|| format!("`{arg}` is not a number"))?;
484 if !(0.0..=1.0).contains(&value) {
485 bail!("`{name}` wants an alpha in 0.0..=1.0, not `{arg}`");
486 }
487 Ok(value)
488 };
489
490 let formatted = match name {
491 "hex_bare" => bare(),
492 "hex_alpha" => format!("{}{:02x}", bare(), (alpha(1)? * 255.0).round() as u8),
493 // The alpha is written through as the template gave it, so what a
494 // reader sees in the config is what the template says.
495 _ => format!("rgba({}, {}, {}, {})", color.r, color.g, color.b, alpha(1)?),
496 };
497 Ok(Some(formatted))
498 }
499
500 /// The color grammar: a token path, or a call over other colors.
501 fn eval_color(expr: &str, palette: &Palette) -> Result<Rgb> {
502 let expr = expr.trim();
503
504 let Some(open) = expr.find('(') else {
505 // A bare path. `ansi.7` indexes the shared table; anything else is a
506 // token name.
507 if let Some(index) = expr.strip_prefix("ansi.") {
508 let index: usize = index
509 .parse()
510 .with_context(|| format!("`{expr}` is not an ANSI index"))?;
511 return palette.ansi(index);
512 }
513 return palette
514 .get(expr)
515 .ok_or_else(|| anyhow!("theme `{}` has no token `{expr}`", palette.name()));
516 };
517
518 if !expr.ends_with(')') {
519 bail!("call `{expr}` is missing its closing paren");
520 }
521 let name = expr[..open].trim();
522 let args = split_args(&expr[open + 1..expr.len() - 1])?;
523
524 let color = |i: usize| -> Result<Rgb> {
525 let arg = args
526 .get(i)
527 .ok_or_else(|| anyhow!("`{name}` wants an argument in position {i}"))?;
528 eval_color(arg, palette)
529 };
530 let amount = |i: usize| -> Result<f32> {
531 let arg = args
532 .get(i)
533 .ok_or_else(|| anyhow!("`{name}` wants a number in position {i}"))?;
534 arg.trim()
535 .parse::<f32>()
536 .with_context(|| format!("`{arg}` is not a number"))
537 };
538
539 match name {
540 // Perceptual, from makeover. What every other make-family app uses to
541 // step a color, so a tone composed here matches one composed in a
542 // webview or in egui.
543 "mix" => Ok(makeover::mix(color(0)?, color(1)?, amount(2)?)),
544 "lighten" => Ok(makeover::lighten(color(0)?, amount(1)?)),
545 "darken" => Ok(makeover::darken(color(0)?, amount(1)?)),
546 "readable_on" => Ok(makeover::readable_on(color(0)?)),
547 // Linear sRGB, from alloy_tui. Only for tones that have to line up with
548 // TOKENS.md's contrast tables, which are computed this way; `mix` is
549 // the right default everywhere else.
550 "mix_srgb" => Ok(alloy_tui::mix_linear_srgb(color(0)?, color(1)?, amount(2)?)),
551 other => bail!("unknown function `{other}`"),
552 }
553 }
554
555 /// Split a call's arguments on top-level commas, so nested calls survive.
556 fn split_args(s: &str) -> Result<Vec<String>> {
557 let mut args = Vec::new();
558 let mut depth = 0usize;
559 let mut current = String::new();
560 for c in s.chars() {
561 match c {
562 '(' => {
563 depth += 1;
564 current.push(c);
565 }
566 ')' => {
567 depth = depth
568 .checked_sub(1)
569 .ok_or_else(|| anyhow!("unbalanced parens in `{s}`"))?;
570 current.push(c);
571 }
572 ',' if depth == 0 => args.push(std::mem::take(&mut current)),
573 _ => current.push(c),
574 }
575 }
576 if depth != 0 {
577 bail!("unbalanced parens in `{s}`");
578 }
579 if !current.trim().is_empty() || !args.is_empty() {
580 args.push(current);
581 }
582 Ok(args)
583 }
584
585 #[cfg(test)]
586 mod tests {
587 use super::*;
588
589 // The real Akari Dawn, from makeover's own bundled set rather than a
590 // fixture, so these assertions are about the theme the image ships and not
591 // about a copy of it that can quietly stop matching.
592 fn dawn() -> Palette {
593 let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes");
594 let theme = makeover::load_theme(&[(dir, false)], "akari-dawn").expect("akari-dawn ships");
595 Palette::new("akari-dawn", &theme).expect("akari-dawn resolves")
596 }
597
598 #[test]
599 fn an_authored_intent_renders_as_the_theme_wrote_it() {
600 assert_eq!(
601 render("bg = \"@{surface.page}\"", &dawn()).unwrap(),
602 "bg = \"#e4ded6\""
603 );
604 }
605
606 // The two tokens the console and the skeleton have to agree on. These are
607 // the values every generated file was transcribed with by hand.
608 #[test]
609 fn the_derived_borders_match_the_console() {
610 let p = dawn();
611 assert_eq!(p.get("border.subtle").unwrap().to_hex(), "#dad2c7");
612 assert_eq!(p.get("border.strong").unwrap().to_hex(), "#7f786d");
613 }
614
615 #[test]
616 fn nested_calls_evaluate_inside_out() {
617 let p = dawn();
618 let got = render("@{mix(surface.page, darken(surface.sunken, 0.1), 0.5)}", &p).unwrap();
619 let want = makeover::mix(
620 p.get("surface.page").unwrap(),
621 makeover::darken(p.get("surface.sunken").unwrap(), 0.1),
622 0.5,
623 );
624 assert_eq!(got, want.to_hex());
625 }
626
627 // The skeleton leans on this for every "text on a colored chip" slot, and
628 // it is the answer the nine hand-written `#ffffff`s were standing in for.
629 #[test]
630 fn readable_on_picks_a_legible_foreground() {
631 let p = dawn();
632 for intent in ["action.primary", "status.danger", "status.success"] {
633 let got = render(&format!("@{{readable_on({intent})}}"), &p).unwrap();
634 assert_eq!(got, "#ffffff", "{intent} wanted white text");
635 }
636 }
637
638 // ---- output formats ----
639
640 // The three shapes that are not `#rrggbb`, against the same color, so the
641 // digits are visibly the same color written three ways.
642 #[test]
643 fn a_color_renders_in_every_shape_its_consumer_can_read() {
644 let p = dawn();
645 let hex = p.get("surface.page").unwrap().to_hex();
646 let bare = hex.trim_start_matches('#');
647
648 assert_eq!(render("@{surface.page}", &p).unwrap(), hex);
649 assert_eq!(render("@{hex_bare(surface.page)}", &p).unwrap(), bare);
650 assert_eq!(
651 render("@{hex_alpha(surface.page, 1.0)}", &p).unwrap(),
652 format!("{bare}ff"),
653 "swaylock's opaque suffix",
654 );
655 }
656
657 // Alpha is two hex digits for swaylock and a decimal for zathura, from the
658 // same written value, which is the whole reason there are two functions.
659 #[test]
660 fn an_alpha_is_written_the_way_its_consumer_spells_it() {
661 let p = dawn();
662 let (r, g, b) = p.get("status.warning").unwrap().tuple();
663
664 assert_eq!(
665 render("@{rgba(status.warning, 0.35)}", &p).unwrap(),
666 format!("rgba({r}, {g}, {b}, 0.35)"),
667 );
668 // 0.35 * 255 = 89.25, so the round lands on 89 = 0x59.
669 assert_eq!(
670 render("@{hex_alpha(status.warning, 0.35)}", &p).unwrap(),
671 format!("{r:02x}{g:02x}{b:02x}59"),
672 );
673 // Fully transparent is a real value, and `00` must not be mistaken for
674 // a failure to write an alpha at all.
675 assert!(
676 render("@{hex_alpha(status.warning, 0.0)}", &p)
677 .unwrap()
678 .ends_with("00")
679 );
680 }
681
682 // An output format wraps a color expression, so a composed tone can still
683 // reach a consumer that cannot read `#rrggbb`.
684 #[test]
685 fn an_output_format_takes_a_whole_expression_not_only_a_token() {
686 let p = dawn();
687 let want = makeover::mix(
688 p.get("surface.page").unwrap(),
689 p.get("surface.sunken").unwrap(),
690 0.5,
691 );
692 assert_eq!(
693 render("@{hex_bare(mix(surface.page, surface.sunken, 0.5))}", &p).unwrap(),
694 want.to_hex().trim_start_matches('#'),
695 );
696 }
697
698 // A percentage written where a fraction belongs is a typo, and reading it as
699 // opaque would ship a lockscreen nobody could see through the wrong alpha.
700 #[test]
701 fn an_alpha_outside_the_unit_range_is_an_error() {
702 let p = dawn();
703 for bad in ["35", "-0.5", "255"] {
704 assert!(
705 render(&format!("@{{hex_alpha(surface.page, {bad})}}"), &p).is_err(),
706 "alpha `{bad}` was accepted",
707 );
708 }
709 assert!(render("@{rgba(surface.page, 1.5)}", &p).is_err());
710 }
711
712 // The formats are additions to the grammar, not a new way to typo past it.
713 #[test]
714 fn an_output_format_still_reports_a_bad_inner_expression() {
715 let p = dawn();
716 assert!(render("@{hex_bare(no.such.token)}", &p).is_err());
717 assert!(render("@{hex_alpha(surface.page)}", &p).is_err());
718 assert!(render("@{hex_bare()}", &p).is_err());
719 }
720
721 #[test]
722 fn the_vt_table_is_sixteen_bytes_per_channel() {
723 let p = dawn();
724 for channel in [Channel::Red, Channel::Green, Channel::Blue] {
725 let row = p.vt_channel(channel).unwrap();
726 assert_eq!(row.split(',').count(), 16, "{channel:?} row: {row}");
727 assert!(row.split(',').all(|b| b.starts_with("0x") && b.len() == 4));
728 }
729 }
730
731 // Slot 7 is the one the greeter cannot do without: tuigreet draws its
732 // container on `white` and has no way to say anything else, so it has to be
733 // a surface. On the light theme it is the *raised* surface specifically —
734 // the login card, sitting on the darker field slot 0 paints.
735 #[test]
736 fn the_ansi_table_puts_a_surface_at_seven_not_a_text_color() {
737 let p = dawn();
738 assert_eq!(p.ansi(7).unwrap().to_hex(), "#ede7de");
739 assert_eq!(p.ansi(15).unwrap().to_hex(), "#f0ece4");
740 }
741
742 // The values are the ones vtrgb.py emitted, because the greeter's look was
743 // tuned against them. The separator is not: that script wrote spaces, this
744 // inherited them, and `setvtrgb` reads commas, so the table it produced was
745 // rejected at parse time on every boot for as long as either existed. The
746 // test that used to live here pinned the whole string including the
747 // spaces, which is how a bug gets a guard pointed the wrong way.
748 //
749 // Slot 8 is the one exception and no longer traces to that script.
750 // `content.muted` is derived from the ink and the page as of makeover
751 // 2.6.0 rather than authored, so akari-dawn's went #514b45 -> #67635f and
752 // the console palette moved with it on installed machines. Accepted
753 // 2026-08-17: deriving is the point, and pinning this slot back would be
754 // the per-theme escape hatch the derivation exists to replace.
755 #[test]
756 fn the_vtrgb_table_carries_the_tuned_values_in_the_format_setvtrgb_reads() {
757 let want = "26,106,58,176,48,128,48,237,103,138,58,176,48,128,48,240\n\
758 24,40,88,120,64,96,88,231,99,69,88,120,64,96,88,236\n\
759 22,40,48,64,80,128,88,222,95,48,48,64,80,128,88,228\n";
760 assert_eq!(dawn().vtrgb_table().unwrap(), want);
761 }
762
763 // The property behind that literal, stated so a future edit to the values
764 // cannot quietly reintroduce the separator bug: three lines, sixteen
765 // comma-separated fields each, every field a decimal byte. This is
766 // `setvtrgb`'s documented format and the whole of what its parser accepts.
767 #[test]
768 fn every_vtrgb_line_is_sixteen_comma_separated_bytes() {
769 for palette in [dawn(), night()] {
770 let table = palette.vtrgb_table().unwrap();
771 let lines: Vec<&str> = table.lines().collect();
772 assert_eq!(lines.len(), 3, "{table}");
773 for line in lines {
774 assert!(!line.contains(' '), "a space would fail the parse: {line}");
775 let fields: Vec<&str> = line.split(',').collect();
776 assert_eq!(fields.len(), 16, "{line}");
777 assert!(
778 fields.iter().all(|f| f.parse::<u8>().is_ok()),
779 "every field is a decimal byte: {line}"
780 );
781 }
782 }
783 }
784
785 fn night() -> Palette {
786 let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes");
787 let theme =
788 makeover::load_theme(&[(dir, false)], "akari-night").expect("akari-night ships");
789 Palette::new("akari-night", &theme).expect("akari-night resolves")
790 }
791
792 // The property the four achromatic slots exist to hold, on both polarities:
793 // ANSI 0 is the darkest thing the palette offers and 15 the lightest. A
794 // table that pins slot 0 to `content.primary` passes this on a light theme
795 // and fails it on a dark one, which is the bug `achromatic_slot` fixes.
796 #[test]
797 fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() {
798 for p in [dawn(), night()] {
799 let (dark, light) = (p.ansi(0).unwrap(), p.ansi(15).unwrap());
800 assert!(
801 luma(dark) < luma(light),
802 "{}: ANSI 0 {} should be darker than ANSI 15 {}",
803 p.name(),
804 dark.to_hex(),
805 light.to_hex()
806 );
807 }
808 }
809
810 // And the pair the greeter actually draws with: a container on 7, its text
811 // on 0. If those two collapse, the login screen is one flat block.
812 #[test]
813 fn the_greeters_container_and_its_text_stay_apart() {
814 for p in [dawn(), night()] {
815 let contrast = makeover::wcag_contrast(p.ansi(0).unwrap(), p.ansi(7).unwrap());
816 assert!(
817 contrast >= 4.5,
818 "{}: ANSI 0 on ANSI 7 is only {contrast:.2}:1",
819 p.name()
820 );
821 }
822 }
823
824 fn luma(c: Rgb) -> f32 {
825 0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
826 }
827
828 #[test]
829 fn a_directive_selects_a_theme_and_leaves_the_file_behind() {
830 let (directive, body) =
831 theme_directive("@{! theme = night }\nbg = \"@{surface.page}\"\n").unwrap();
832 assert_eq!(directive, Directive::Theme("night".into()));
833 assert_eq!(body, "bg = \"@{surface.page}\"\n");
834 }
835
836 #[test]
837 fn a_file_without_a_directive_is_untouched() {
838 let (directive, body) = theme_directive("bg = \"@{surface.page}\"\n").unwrap();
839 assert_eq!(directive, Directive::None);
840 assert_eq!(body, "bg = \"@{surface.page}\"\n");
841 }
842
843 #[test]
844 fn a_variants_directive_lists_every_name_in_order() {
845 let (directive, body) =
846 theme_directive("@{! variants = default, night }\nbg = \"@{surface.page}\"\n").unwrap();
847 assert_eq!(
848 directive,
849 Directive::Variants(vec!["default".into(), "night".into()])
850 );
851 assert_eq!(body, "bg = \"@{surface.page}\"\n");
852 }
853
854 // The plain path and one sibling per later name. `config.night` and not
855 // `night.config` because nothing scanning a config directory picks up a
856 // trailing suffix: Helix globs `*.toml`, sway reads `config` by name.
857 #[test]
858 fn variants_render_the_first_name_to_the_plain_path() {
859 let (directive, _) = theme_directive("@{! variants = default, night }\n").unwrap();
860 assert_eq!(
861 directive.renders(".config/mako/config").unwrap(),
862 vec![
863 ("default".to_string(), ".config/mako/config".to_string()),
864 ("night".to_string(), ".config/mako/config.night".to_string()),
865 ]
866 );
867 }
868
869 #[test]
870 fn a_single_theme_directive_renders_one_file_at_the_plain_path() {
871 for directive in [Directive::None, Directive::Theme("night".into())] {
872 let renders = directive.renders("themes/akari-night.toml").unwrap();
873 assert_eq!(renders.len(), 1, "{directive:?}");
874 assert_eq!(renders[0].1, "themes/akari-night.toml");
875 }
876 }
877
878 // Was silently ignored, leaving the directive line in the body for `render`
879 // to fail on with a message about a token named `! varients = ...`.
880 #[test]
881 fn an_unknown_directive_key_is_an_error() {
882 let err = theme_directive("@{! varients = default, night }\n")
883 .unwrap_err()
884 .to_string();
885 assert!(err.contains("varients"), "{err}");
886 }
887
888 #[test]
889 fn variants_with_one_name_is_an_error() {
890 assert!(theme_directive("@{! variants = default }\n").is_err());
891 }
892
893 #[test]
894 fn variants_repeating_a_name_is_an_error() {
895 let err = theme_directive("@{! variants = default, night, night }\n")
896 .unwrap_err()
897 .to_string();
898 assert!(err.contains("`night` twice"), "{err}");
899 }
900
901 #[test]
902 fn theme_with_two_names_is_an_error() {
903 assert!(theme_directive("@{! theme = default, night }\n").is_err());
904 }
905
906 // A reversed list would put the dark render at the plain path, which is the
907 // file `/etc/skel` ships and every new account starts from.
908 #[test]
909 fn variants_not_starting_with_default_is_an_error() {
910 let (directive, _) = theme_directive("@{! variants = night, default }\n").unwrap();
911 let err = directive.renders("config").unwrap_err().to_string();
912 assert!(err.contains("must start with `default`"), "{err}");
913 }
914
915 // `meta.id` is the theme's own id and not the `--theme` key it was loaded
916 // under. Emitting the key into Helix's `theme =` line gives an editor that
917 // falls back to its default theme without a word.
918 #[test]
919 fn meta_id_is_the_theme_id_not_the_palette_name() {
920 let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes");
921 let theme =
922 makeover::load_theme(&[(dir, false)], "akari-night").expect("akari-night ships");
923 let palette = Palette::new("night", &theme).expect("akari-night resolves");
924 assert_eq!(
925 render("theme = \"@{meta.id}\"", &palette).unwrap(),
926 "theme = \"akari-night\""
927 );
928 }
929
930 #[test]
931 fn meta_is_dark_renders_a_bare_boolean_per_polarity() {
932 assert_eq!(render("@{meta.is_dark}", &dawn()).unwrap(), "false");
933 assert_eq!(render("@{meta.is_dark}", &night()).unwrap(), "true");
934 }
935
936 // A typo in a token name has to stop the build. The failure it replaces is
937 // a config file shipped with an empty color value, which most of these
938 // programs treat as "use your default" without a word.
939 #[test]
940 fn an_unknown_token_is_an_error_not_an_empty_string() {
941 let err = render("@{surface.pge}", &dawn()).unwrap_err().to_string();
942 assert!(err.contains("surface.pge"), "{err}");
943 }
944
945 #[test]
946 fn an_unknown_function_is_an_error() {
947 assert!(render("@{frobnicate(surface.page)}", &dawn()).is_err());
948 }
949
950 #[test]
951 fn an_unterminated_expression_is_an_error() {
952 assert!(render("bg = @{surface.page", &dawn()).is_err());
953 }
954 }
955