//! The desktop skeleton, rendered from a makeover theme. //! //! A color anywhere under `etc/skel/` written as a hex literal is a hand //! transcription of `themes/akari-dawn.toml` that nothing would notice going out //! of agreement with the theme. So the tree is templates and this renders them, //! at image-build time, from the same theme file the console loads. Same rule //! docs/TOKENS.md states for the console itself: no hex in the source. //! //! A config that cannot read `#rrggbb`, such as swaylock's or imv's, is rendered //! through [`eval_format`] rather than left with literals. `etc/skel/` holds //! only the files that carry no //! color at all. //! //! # Template syntax //! //! A template is the target file with its color literals replaced by `@{ }` //! expressions. Everything outside the braces is copied through untouched, so a //! template still reads as the config file it produces. //! //! The delimiter is `@{` and not the more usual `{{` because these are real //! config files and `{{` is taken: yazi's theme carries vim fold markers //! (`{{{`) in its section comments, and TOML inline tables put braces next to //! each other freely. `@{` appears nowhere in the tree. //! //! ```text //! background = "@{surface.page}" an authored intent //! border = "@{border.strong}" an Alloy-derived token //! edge = "@{bevel.light}" a makeover-derived token //! raised = "@{mix(surface.page, surface.sunken, 0.5)}" //! label = "@{readable_on(status.danger)}" //! ``` //! //! A color renders as `#rrggbb`, which is what all but three of the consumers //! read. The rest take an output format, and those are the outermost call rather //! than something a color function composes over, because their result is text //! and not a color (see [`eval_format`]). //! //! ```text //! background = "@{hex_bare(surface.page)}" e4ded6 //! color = "@{hex_alpha(surface.page, 1.0)}" e4ded6ff //! highlight = "@{rgba(status.warning, 0.35)}" rgba(176, 120, 64, 0.35) //! ``` //! //! An optional first-line directive picks which theme the file renders against; //! without one it takes the default. The directive line is stripped from the //! output. //! //! ```text //! @{! theme = night } render once, against `night` //! @{! variants = default, night } render once per name //! ``` //! //! `variants` is how a file that every program reads by one fixed path ships in //! both polarities: the first name renders to the plain path and each later name //! `N` renders to a `.N` sibling. `~/.config/mako/config` and //! `~/.config/mako/config.night` are the same template, and `alloy theme apply` //! copies whichever the session's mode calls for. The Helix themes are the other //! case and still take `theme`: Helix picks between them by filename, so they are //! two files by name rather than one file in two renders. use std::collections::BTreeMap; use anyhow::{Context, Result, anyhow, bail}; use makeover::{Rgb, ThemeColors}; /// The intent painting ANSI slot `index` under a theme of `variant`. /// /// The table itself is `makeover::ansi_intent`. It lives there rather than here /// because `shop` paints its palette at runtime from a theme rather than reading /// a generated config, so it needs the mapping as code and not as rendered hex, /// and a copy in shop would be a second copy of the one table every surface /// answers to. /// /// Kept as a re-export rather than deleted: templates reach it through /// `@{ansi.N}` and [`Palette::ansi`], and the callers here read better naming /// the crate that owns the rest of the skeleton. pub use makeover::ansi_intent; /// Every color a template can name, resolved from one theme. /// /// Authored intents come from the theme file as-is. The derived ones are asked /// of the crates that own them rather than recomputed here — `bevel.*` from /// makeover, `border.*` from `alloy_tui` — because a second implementation of a /// derivation is a second answer to what the token is, and the whole point of /// generating this tree is that there is one answer. pub struct Palette { name: String, /// The theme's own id (`akari-dawn`), which is not [`Palette::name`]: that /// is the `--theme` key (`default`, `night`) and is a build-time label. A /// file naming a theme to a program has to name the id, because that is /// what the program will look for. Helix handed `theme = "night"` finds no /// such theme and falls back to its own default without saying so. id: String, /// `light`, `dark` or `high-contrast`, straight from the theme's `[meta]`. /// Only the ANSI table consults it; everything else is polarity-agnostic /// because the intents already carry the meaning. variant: String, /// The theme's own display name, for files that print it. display_name: String, tokens: BTreeMap, } impl Palette { /// Resolve a loaded theme into every token a template may name. pub fn new(name: impl Into, theme: &ThemeColors) -> Result { let name = name.into(); let mut tokens = BTreeMap::new(); // Authored intents, under the dotted names the theme file uses. for (key, value) in &theme.colors { if let Some(rgb) = Rgb::from_hex(value) { tokens.insert(key.clone(), rgb); } } let need = |k: &str| -> Result { tokens .get(k) .copied() .ok_or_else(|| anyhow!("theme `{name}` is missing required intent `{k}`")) }; let line_border = need("line.border")?; let surface_page = need("surface.page")?; let content_primary = need("content.primary")?; // Alloy's two border tiers, from alloy_tui — the console renders these // same two functions, so the skeleton and the console cannot disagree. tokens.insert( "border.subtle".into(), alloy_tui::border_subtle(line_border, surface_page), ); tokens.insert( "border.strong".into(), alloy_tui::border_strong(line_border, content_primary), ); // The bevel pair, from makeover, for the same reason. let resolved = makeover::resolve(theme); for (token, intent) in [("bevel.light", "bevel-light"), ("bevel.dark", "bevel-dark")] { let hex = resolved .hex(intent) .ok_or_else(|| anyhow!("theme `{name}` yielded no `{intent}`"))?; let rgb = Rgb::from_hex(hex) .ok_or_else(|| anyhow!("theme `{name}` gave `{intent}` as invalid hex `{hex}`"))?; tokens.insert(token.into(), rgb); } Ok(Self { name, id: theme.meta.id.clone(), variant: theme.meta.variant.clone(), display_name: theme.meta.name.clone(), tokens, }) } /// The name this palette was loaded under, for error messages. pub fn name(&self) -> &str { &self.name } /// The theme's own `[meta] id` ("akari-dawn"), as another program names it. pub fn id(&self) -> &str { &self.id } /// The theme's own `[meta] name`, as a human reads it ("Akari Dawn"). pub fn display_name(&self) -> &str { &self.display_name } /// `light`, `dark` or `high-contrast`. pub fn variant(&self) -> &str { &self.variant } /// Look up an authored or derived token. pub fn get(&self, path: &str) -> Option { self.tokens.get(path).copied() } /// The ANSI slot at `index`, per [`ansi_intent`] and this theme's polarity. pub fn ansi(&self, index: usize) -> Result { let path = ansi_intent(index, &self.variant) .ok_or_else(|| anyhow!("ANSI index {index} is out of range (0-15)"))?; self.get(path) .ok_or_else(|| anyhow!("theme `{}` is missing `{path}` (ANSI {index})", self.name)) } /// One channel of the sixteen-entry `setvtrgb` table, as the kernel's /// `vt.default_*` cmdline argument expects it: sixteen `0xNN` bytes. /// /// The Linux console is the one surface with no emulator under it and no /// 24-bit escape to fall back on, so this table *is* the greeter's palette. pub fn vt_channel(&self, channel: Channel) -> Result { let mut out = Vec::with_capacity(16); for index in 0..16 { let rgb = self.ansi(index)?; let byte = match channel { Channel::Red => rgb.r, Channel::Green => rgb.g, Channel::Blue => rgb.b, }; out.push(format!("0x{byte:02x}")); } Ok(out.join(",")) } /// The whole `setvtrgb` table, in the file format that tool reads: three /// lines of sixteen comma-separated decimal values, all reds then all /// greens then all blues. /// /// **Comma, not whitespace.** kbd's `setvtrgb` parses each line with a /// comma-delimited scan and rejects anything else with "Insufficient number /// of fields", which is a parse failure before it ever opens a console. /// Emitting spaces makes `alloy-vtrgb.service` fail on every boot with the /// console palette never applied. Verified both ways against the shipped /// kbd: the space form dies at parsing, the comma form parses and reaches /// the console. /// /// The kernel cmdline (`vt.default_*`, via [`Palette::vt_channel`]) and /// this file are the same sixteen colors applied twice — once by the kernel /// before userspace, once by `alloy-vtrgb.service` after. They were /// maintained separately and had drifted apart; now neither can move /// without the other. pub fn vtrgb_table(&self) -> Result { let slots: Vec = (0..16).map(|i| self.ansi(i)).collect::>()?; let mut out = String::new(); for channel in [Channel::Red, Channel::Green, Channel::Blue] { let row: Vec = slots .iter() .map(|c| { match channel { Channel::Red => c.r, Channel::Green => c.g, Channel::Blue => c.b, } .to_string() }) .collect(); out.push_str(&row.join(",")); out.push('\n'); } Ok(out) } } /// Which channel of the console palette table to emit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Channel { Red, Green, Blue, } /// The theme a template with no directive renders against, and the name a /// `variants` list has to start with. pub const DEFAULT_THEME: &str = "default"; /// What a template's first line asks for. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Directive { /// No directive: render once against [`DEFAULT_THEME`], to the plain target /// path. None, /// `theme = X`: render once against X, to the plain target path. Theme(String), /// `variants = A, B, ...`: render once per name. A goes to the plain target /// path; every later name N goes to `.N`. Variants(Vec), } impl Directive { /// The renders this directive asks for, as `(theme name, target path)`. /// /// The suffix is the `--theme` name verbatim, so a third palette is a third /// word in the template and no change here. pub fn renders(&self, target: &str) -> Result> { Ok(match self { Self::None => vec![(DEFAULT_THEME.to_string(), target.to_string())], Self::Theme(name) => vec![(name.clone(), target.to_string())], Self::Variants(names) => { // Checked here rather than at parse time because it is a rule // about what the image ships, not about the grammar: the plain // file is what lands in `/etc/skel`, `useradd` copies it before // anything has read a mode file, so a reversed list would give // every new account a dark desktop it never asked for. let first = names .first() .ok_or_else(|| anyhow!("`variants` names no themes"))?; if first != DEFAULT_THEME { bail!( "`variants` must start with `{DEFAULT_THEME}`; the plain file is what \ /etc/skel ships and that has to be the light render" ); } names .iter() .enumerate() .map(|(i, name)| { let path = if i == 0 { target.to_string() } else { format!("{target}.{name}") }; (name.clone(), path) }) .collect() } }) } } /// Read the first-line directive, if the file carries one. /// /// Returns what it asks for plus the template with the directive line removed. /// Most files take the default and carry no directive at all. /// /// A first line that looks like a directive and is not one is an error. Treating /// it as "no directive" leaves the `@{! ... }` line in the body, where `render` /// fails complaining about a token named `! varients = default, night`, naming /// neither the line nor the mistake. pub fn theme_directive(template: &str) -> Result<(Directive, String)> { let Some(first) = template.lines().next() else { return Ok((Directive::None, template.to_string())); }; let trimmed = first.trim(); let Some(body) = trimmed .strip_prefix("@{!") .and_then(|s| s.strip_suffix('}')) else { return Ok((Directive::None, template.to_string())); }; let (key, value) = body .split_once('=') .ok_or_else(|| anyhow!("directive `{trimmed}` is not `key = value`"))?; let (key, value) = (key.trim(), value.trim()); let directive = match key { "theme" => { if value.contains(',') { bail!("`theme` takes one name; use `variants` for more than one"); } Directive::Theme(value.to_string()) } "variants" => { let names: Vec = value .split(',') .map(str::trim) .filter(|name| !name.is_empty()) .map(str::to_string) .collect(); if names.len() < 2 { bail!("`variants` needs at least two names; use `theme` for one"); } for (i, name) in names.iter().enumerate() { if names[..i].contains(name) { bail!("`variants` names `{name}` twice"); } } Directive::Variants(names) } other => bail!("unknown directive key `{other}`; expected `theme` or `variants`"), }; let rest = template .split_once('\n') .map_or(String::new(), |(_, rest)| rest.to_string()); Ok((directive, rest)) } /// Substitute every `@{ }` expression in `template` against `palette`. pub fn render(template: &str, palette: &Palette) -> Result { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some(start) = rest.find("@{") { out.push_str(&rest[..start]); let after = &rest[start + 2..]; // Closing on the first `}` is safe because the grammar has no braces of // its own: an expression is token paths, calls and numbers. let end = after .find('}') .ok_or_else(|| anyhow!("unterminated `@{{` near: {}", snippet(after)))?; let expr = after[..end].trim(); out.push_str(&eval(expr, palette).with_context(|| format!("evaluating `@{{{expr}}}`"))?); rest = &after[end + 1..]; } out.push_str(rest); Ok(out) } fn snippet(s: &str) -> String { s.chars().take(40).collect() } /// Evaluate one expression to the text it stands for. /// /// Most expressions are colors and render as `#rrggbb`. Two families are not, /// and are handled before the color grammar rather than inside it: `vt.*` /// stands for a whole sixteen-byte row, and `meta.*` for a piece of the theme's /// own metadata. `meta.name` is what lets the two Helix templates be the same /// file but for their directive — the only thing that differed between the /// hand-written pair was the theme's name in a header comment. /// /// `meta.id` and `meta.is_dark` are what the mode-dependent files that carry no /// color at all render from: `theme = "@{meta.id}"` in Helix's config and /// `gtk-application-prefer-dark-theme = @{meta.is_dark}` in the GTK settings. /// Both differ between the day and night renders without a single hex literal /// changing, which is the reason `variants` is a property of the template rather /// than of the token list. fn eval(expr: &str, palette: &Palette) -> Result { match expr { "vt.red" => return palette.vt_channel(Channel::Red), "vt.grn" => return palette.vt_channel(Channel::Green), "vt.blu" => return palette.vt_channel(Channel::Blue), "vt.table" => return palette.vtrgb_table(), "meta.id" => return Ok(palette.id().to_string()), "meta.name" => return Ok(palette.display_name().to_string()), "meta.variant" => return Ok(palette.variant().to_string()), // `high-contrast` renders `false`, following `achromatic_slot`'s rule // that anything not `dark` takes the light anchors. "meta.is_dark" => return Ok((palette.variant() == "dark").to_string()), _ => {} } if let Some(formatted) = eval_format(expr, palette)? { return Ok(formatted); } Ok(eval_color(expr, palette)?.to_hex()) } /// The shapes a color takes that are not `#rrggbb`. /// /// Every color expression resolves to an [`Rgb`], and most config files want it /// written the CSS way, so [`eval`] ends in `to_hex`. Three consumers in the /// tree cannot read that, and each was the reason a file stayed hand-written: /// /// - **swaylock** wants `rrggbbaa`, bare and with an alpha. It has no `#` form /// at all, so its config could not be templated until there was a function /// here. It is on the lock path, which made it the worst file in the tree to /// leave in one polarity: locking a night session flashed a light screen. /// - **imv** wants bare `rrggbb`. /// - **zathura** wants `rgba(r, g, b, a)` with the channels in decimal, for the /// two search-highlight colors that need to let the glyph under them through. /// /// Returns `None` for anything that is not one of these, so [`eval_color`] keeps /// reporting unknown functions and malformed calls. That is also why a missing /// closing paren is passed along rather than diagnosed here: one place should own /// that message. fn eval_format(expr: &str, palette: &Palette) -> Result> { let expr = expr.trim(); let Some(open) = expr.find('(') else { return Ok(None); }; if !expr.ends_with(')') { return Ok(None); } let name = expr[..open].trim(); if !matches!(name, "hex_bare" | "hex_alpha" | "rgba") { return Ok(None); } let args = split_args(&expr[open + 1..expr.len() - 1])?; let color = eval_color( args.first() .ok_or_else(|| anyhow!("`{name}` wants a color in position 0"))?, palette, )?; // Bare, not `#rrggbb` minus a character: `to_hex` stays the one place that // decides the digits and their case. let bare = || color.to_hex().trim_start_matches('#').to_string(); // Out of range is an error rather than a clamp. An alpha is written by hand // in the template, so a 35 meant as a percentage is a typo worth a build // failure, not a value to quietly read as opaque. let alpha = |i: usize| -> Result { let arg = args .get(i) .ok_or_else(|| anyhow!("`{name}` wants an alpha in position {i}"))?; let value: f32 = arg .trim() .parse() .with_context(|| format!("`{arg}` is not a number"))?; if !(0.0..=1.0).contains(&value) { bail!("`{name}` wants an alpha in 0.0..=1.0, not `{arg}`"); } Ok(value) }; let formatted = match name { "hex_bare" => bare(), "hex_alpha" => format!("{}{:02x}", bare(), (alpha(1)? * 255.0).round() as u8), // The alpha is written through as the template gave it, so what a // reader sees in the config is what the template says. _ => format!("rgba({}, {}, {}, {})", color.r, color.g, color.b, alpha(1)?), }; Ok(Some(formatted)) } /// The color grammar: a token path, or a call over other colors. fn eval_color(expr: &str, palette: &Palette) -> Result { let expr = expr.trim(); let Some(open) = expr.find('(') else { // A bare path. `ansi.7` indexes the shared table; anything else is a // token name. if let Some(index) = expr.strip_prefix("ansi.") { let index: usize = index .parse() .with_context(|| format!("`{expr}` is not an ANSI index"))?; return palette.ansi(index); } return palette .get(expr) .ok_or_else(|| anyhow!("theme `{}` has no token `{expr}`", palette.name())); }; if !expr.ends_with(')') { bail!("call `{expr}` is missing its closing paren"); } let name = expr[..open].trim(); let args = split_args(&expr[open + 1..expr.len() - 1])?; let color = |i: usize| -> Result { let arg = args .get(i) .ok_or_else(|| anyhow!("`{name}` wants an argument in position {i}"))?; eval_color(arg, palette) }; let amount = |i: usize| -> Result { let arg = args .get(i) .ok_or_else(|| anyhow!("`{name}` wants a number in position {i}"))?; arg.trim() .parse::() .with_context(|| format!("`{arg}` is not a number")) }; match name { // Perceptual, from makeover. What every other make-family app uses to // step a color, so a tone composed here matches one composed in a // webview or in egui. "mix" => Ok(makeover::mix(color(0)?, color(1)?, amount(2)?)), "lighten" => Ok(makeover::lighten(color(0)?, amount(1)?)), "darken" => Ok(makeover::darken(color(0)?, amount(1)?)), "readable_on" => Ok(makeover::readable_on(color(0)?)), // Linear sRGB, from alloy_tui. Only for tones that have to line up with // TOKENS.md's contrast tables, which are computed this way; `mix` is // the right default everywhere else. "mix_srgb" => Ok(alloy_tui::mix_linear_srgb(color(0)?, color(1)?, amount(2)?)), other => bail!("unknown function `{other}`"), } } /// Split a call's arguments on top-level commas, so nested calls survive. fn split_args(s: &str) -> Result> { let mut args = Vec::new(); let mut depth = 0usize; let mut current = String::new(); for c in s.chars() { match c { '(' => { depth += 1; current.push(c); } ')' => { depth = depth .checked_sub(1) .ok_or_else(|| anyhow!("unbalanced parens in `{s}`"))?; current.push(c); } ',' if depth == 0 => args.push(std::mem::take(&mut current)), _ => current.push(c), } } if depth != 0 { bail!("unbalanced parens in `{s}`"); } if !current.trim().is_empty() || !args.is_empty() { args.push(current); } Ok(args) } #[cfg(test)] mod tests { use super::*; // The real Akari Dawn, from makeover's own bundled set rather than a // fixture, so these assertions are about the theme the image ships and not // about a copy of it that can quietly stop matching. fn dawn() -> Palette { let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes"); let theme = makeover::load_theme(&[(dir, false)], "akari-dawn").expect("akari-dawn ships"); Palette::new("akari-dawn", &theme).expect("akari-dawn resolves") } #[test] fn an_authored_intent_renders_as_the_theme_wrote_it() { assert_eq!( render("bg = \"@{surface.page}\"", &dawn()).unwrap(), "bg = \"#e4ded6\"" ); } // The two tokens the console and the skeleton have to agree on. These are // the values every generated file was transcribed with by hand. #[test] fn the_derived_borders_match_the_console() { let p = dawn(); assert_eq!(p.get("border.subtle").unwrap().to_hex(), "#dad2c7"); assert_eq!(p.get("border.strong").unwrap().to_hex(), "#7f786d"); } #[test] fn nested_calls_evaluate_inside_out() { let p = dawn(); let got = render("@{mix(surface.page, darken(surface.sunken, 0.1), 0.5)}", &p).unwrap(); let want = makeover::mix( p.get("surface.page").unwrap(), makeover::darken(p.get("surface.sunken").unwrap(), 0.1), 0.5, ); assert_eq!(got, want.to_hex()); } // The skeleton leans on this for every "text on a colored chip" slot, and // it is the answer the nine hand-written `#ffffff`s were standing in for. #[test] fn readable_on_picks_a_legible_foreground() { let p = dawn(); for intent in ["action.primary", "status.danger", "status.success"] { let got = render(&format!("@{{readable_on({intent})}}"), &p).unwrap(); assert_eq!(got, "#ffffff", "{intent} wanted white text"); } } // ---- output formats ---- // The three shapes that are not `#rrggbb`, against the same color, so the // digits are visibly the same color written three ways. #[test] fn a_color_renders_in_every_shape_its_consumer_can_read() { let p = dawn(); let hex = p.get("surface.page").unwrap().to_hex(); let bare = hex.trim_start_matches('#'); assert_eq!(render("@{surface.page}", &p).unwrap(), hex); assert_eq!(render("@{hex_bare(surface.page)}", &p).unwrap(), bare); assert_eq!( render("@{hex_alpha(surface.page, 1.0)}", &p).unwrap(), format!("{bare}ff"), "swaylock's opaque suffix", ); } // Alpha is two hex digits for swaylock and a decimal for zathura, from the // same written value, which is the whole reason there are two functions. #[test] fn an_alpha_is_written_the_way_its_consumer_spells_it() { let p = dawn(); let (r, g, b) = p.get("status.warning").unwrap().tuple(); assert_eq!( render("@{rgba(status.warning, 0.35)}", &p).unwrap(), format!("rgba({r}, {g}, {b}, 0.35)"), ); // 0.35 * 255 = 89.25, so the round lands on 89 = 0x59. assert_eq!( render("@{hex_alpha(status.warning, 0.35)}", &p).unwrap(), format!("{r:02x}{g:02x}{b:02x}59"), ); // Fully transparent is a real value, and `00` must not be mistaken for // a failure to write an alpha at all. assert!( render("@{hex_alpha(status.warning, 0.0)}", &p) .unwrap() .ends_with("00") ); } // An output format wraps a color expression, so a composed tone can still // reach a consumer that cannot read `#rrggbb`. #[test] fn an_output_format_takes_a_whole_expression_not_only_a_token() { let p = dawn(); let want = makeover::mix( p.get("surface.page").unwrap(), p.get("surface.sunken").unwrap(), 0.5, ); assert_eq!( render("@{hex_bare(mix(surface.page, surface.sunken, 0.5))}", &p).unwrap(), want.to_hex().trim_start_matches('#'), ); } // A percentage written where a fraction belongs is a typo, and reading it as // opaque would ship a lockscreen nobody could see through the wrong alpha. #[test] fn an_alpha_outside_the_unit_range_is_an_error() { let p = dawn(); for bad in ["35", "-0.5", "255"] { assert!( render(&format!("@{{hex_alpha(surface.page, {bad})}}"), &p).is_err(), "alpha `{bad}` was accepted", ); } assert!(render("@{rgba(surface.page, 1.5)}", &p).is_err()); } // The formats are additions to the grammar, not a new way to typo past it. #[test] fn an_output_format_still_reports_a_bad_inner_expression() { let p = dawn(); assert!(render("@{hex_bare(no.such.token)}", &p).is_err()); assert!(render("@{hex_alpha(surface.page)}", &p).is_err()); assert!(render("@{hex_bare()}", &p).is_err()); } #[test] fn the_vt_table_is_sixteen_bytes_per_channel() { let p = dawn(); for channel in [Channel::Red, Channel::Green, Channel::Blue] { let row = p.vt_channel(channel).unwrap(); assert_eq!(row.split(',').count(), 16, "{channel:?} row: {row}"); assert!(row.split(',').all(|b| b.starts_with("0x") && b.len() == 4)); } } // Slot 7 is the one the greeter cannot do without: tuigreet draws its // container on `white` and has no way to say anything else, so it has to be // a surface. On the light theme it is the *raised* surface specifically — // the login card, sitting on the darker field slot 0 paints. #[test] fn the_ansi_table_puts_a_surface_at_seven_not_a_text_color() { let p = dawn(); assert_eq!(p.ansi(7).unwrap().to_hex(), "#ede7de"); assert_eq!(p.ansi(15).unwrap().to_hex(), "#f0ece4"); } // The values are the ones vtrgb.py emitted, because the greeter's look was // tuned against them. The separator is not: that script wrote spaces, this // inherited them, and `setvtrgb` reads commas, so the table it produced was // rejected at parse time on every boot for as long as either existed. The // test that used to live here pinned the whole string including the // spaces, which is how a bug gets a guard pointed the wrong way. // // Slot 8 is the one exception and no longer traces to that script. // `content.muted` is derived from the ink and the page as of makeover // 2.6.0 rather than authored, so akari-dawn's went #514b45 -> #67635f and // the console palette moved with it on installed machines. Accepted // 2026-08-17: deriving is the point, and pinning this slot back would be // the per-theme escape hatch the derivation exists to replace. #[test] fn the_vtrgb_table_carries_the_tuned_values_in_the_format_setvtrgb_reads() { let want = "26,106,58,176,48,128,48,237,103,138,58,176,48,128,48,240\n\ 24,40,88,120,64,96,88,231,99,69,88,120,64,96,88,236\n\ 22,40,48,64,80,128,88,222,95,48,48,64,80,128,88,228\n"; assert_eq!(dawn().vtrgb_table().unwrap(), want); } // The property behind that literal, stated so a future edit to the values // cannot quietly reintroduce the separator bug: three lines, sixteen // comma-separated fields each, every field a decimal byte. This is // `setvtrgb`'s documented format and the whole of what its parser accepts. #[test] fn every_vtrgb_line_is_sixteen_comma_separated_bytes() { for palette in [dawn(), night()] { let table = palette.vtrgb_table().unwrap(); let lines: Vec<&str> = table.lines().collect(); assert_eq!(lines.len(), 3, "{table}"); for line in lines { assert!(!line.contains(' '), "a space would fail the parse: {line}"); let fields: Vec<&str> = line.split(',').collect(); assert_eq!(fields.len(), 16, "{line}"); assert!( fields.iter().all(|f| f.parse::().is_ok()), "every field is a decimal byte: {line}" ); } } } fn night() -> Palette { let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes"); let theme = makeover::load_theme(&[(dir, false)], "akari-night").expect("akari-night ships"); Palette::new("akari-night", &theme).expect("akari-night resolves") } // The property the four achromatic slots exist to hold, on both polarities: // ANSI 0 is the darkest thing the palette offers and 15 the lightest. A // table that pins slot 0 to `content.primary` passes this on a light theme // and fails it on a dark one, which is the bug `achromatic_slot` fixes. #[test] fn ansi_zero_is_darker_than_ansi_fifteen_on_either_polarity() { for p in [dawn(), night()] { let (dark, light) = (p.ansi(0).unwrap(), p.ansi(15).unwrap()); assert!( luma(dark) < luma(light), "{}: ANSI 0 {} should be darker than ANSI 15 {}", p.name(), dark.to_hex(), light.to_hex() ); } } // And the pair the greeter actually draws with: a container on 7, its text // on 0. If those two collapse, the login screen is one flat block. #[test] fn the_greeters_container_and_its_text_stay_apart() { for p in [dawn(), night()] { let contrast = makeover::wcag_contrast(p.ansi(0).unwrap(), p.ansi(7).unwrap()); assert!( contrast >= 4.5, "{}: ANSI 0 on ANSI 7 is only {contrast:.2}:1", p.name() ); } } fn luma(c: Rgb) -> f32 { 0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b) } #[test] fn a_directive_selects_a_theme_and_leaves_the_file_behind() { let (directive, body) = theme_directive("@{! theme = night }\nbg = \"@{surface.page}\"\n").unwrap(); assert_eq!(directive, Directive::Theme("night".into())); assert_eq!(body, "bg = \"@{surface.page}\"\n"); } #[test] fn a_file_without_a_directive_is_untouched() { let (directive, body) = theme_directive("bg = \"@{surface.page}\"\n").unwrap(); assert_eq!(directive, Directive::None); assert_eq!(body, "bg = \"@{surface.page}\"\n"); } #[test] fn a_variants_directive_lists_every_name_in_order() { let (directive, body) = theme_directive("@{! variants = default, night }\nbg = \"@{surface.page}\"\n").unwrap(); assert_eq!( directive, Directive::Variants(vec!["default".into(), "night".into()]) ); assert_eq!(body, "bg = \"@{surface.page}\"\n"); } // The plain path and one sibling per later name. `config.night` and not // `night.config` because nothing scanning a config directory picks up a // trailing suffix: Helix globs `*.toml`, sway reads `config` by name. #[test] fn variants_render_the_first_name_to_the_plain_path() { let (directive, _) = theme_directive("@{! variants = default, night }\n").unwrap(); assert_eq!( directive.renders(".config/mako/config").unwrap(), vec![ ("default".to_string(), ".config/mako/config".to_string()), ("night".to_string(), ".config/mako/config.night".to_string()), ] ); } #[test] fn a_single_theme_directive_renders_one_file_at_the_plain_path() { for directive in [Directive::None, Directive::Theme("night".into())] { let renders = directive.renders("themes/akari-night.toml").unwrap(); assert_eq!(renders.len(), 1, "{directive:?}"); assert_eq!(renders[0].1, "themes/akari-night.toml"); } } // Was silently ignored, leaving the directive line in the body for `render` // to fail on with a message about a token named `! varients = ...`. #[test] fn an_unknown_directive_key_is_an_error() { let err = theme_directive("@{! varients = default, night }\n") .unwrap_err() .to_string(); assert!(err.contains("varients"), "{err}"); } #[test] fn variants_with_one_name_is_an_error() { assert!(theme_directive("@{! variants = default }\n").is_err()); } #[test] fn variants_repeating_a_name_is_an_error() { let err = theme_directive("@{! variants = default, night, night }\n") .unwrap_err() .to_string(); assert!(err.contains("`night` twice"), "{err}"); } #[test] fn theme_with_two_names_is_an_error() { assert!(theme_directive("@{! theme = default, night }\n").is_err()); } // A reversed list would put the dark render at the plain path, which is the // file `/etc/skel` ships and every new account starts from. #[test] fn variants_not_starting_with_default_is_an_error() { let (directive, _) = theme_directive("@{! variants = night, default }\n").unwrap(); let err = directive.renders("config").unwrap_err().to_string(); assert!(err.contains("must start with `default`"), "{err}"); } // `meta.id` is the theme's own id and not the `--theme` key it was loaded // under. Emitting the key into Helix's `theme =` line gives an editor that // falls back to its default theme without a word. #[test] fn meta_id_is_the_theme_id_not_the_palette_name() { let dir = makeover::bundled_themes_dir().expect("makeover bundles its themes"); let theme = makeover::load_theme(&[(dir, false)], "akari-night").expect("akari-night ships"); let palette = Palette::new("night", &theme).expect("akari-night resolves"); assert_eq!( render("theme = \"@{meta.id}\"", &palette).unwrap(), "theme = \"akari-night\"" ); } #[test] fn meta_is_dark_renders_a_bare_boolean_per_polarity() { assert_eq!(render("@{meta.is_dark}", &dawn()).unwrap(), "false"); assert_eq!(render("@{meta.is_dark}", &night()).unwrap(), "true"); } // A typo in a token name has to stop the build. The failure it replaces is // a config file shipped with an empty color value, which most of these // programs treat as "use your default" without a word. #[test] fn an_unknown_token_is_an_error_not_an_empty_string() { let err = render("@{surface.pge}", &dawn()).unwrap_err().to_string(); assert!(err.contains("surface.pge"), "{err}"); } #[test] fn an_unknown_function_is_an_error() { assert!(render("@{frobnicate(surface.page)}", &dawn()).is_err()); } #[test] fn an_unterminated_expression_is_an_error() { assert!(render("bg = @{surface.page", &dawn()).is_err()); } }