//! Converts the themes Helix ships in `runtime/themes` into `src/themes.rs`. //! //! Helix themes describe an editor: they name syntax scopes, not the sixteen //! ANSI colors a terminal needs. The conversion therefore reads the UI scopes //! directly (`ui.background`, `ui.statusline`, ...) and *infers* the ANSI //! palette by scoring every color the theme uses against the hue each slot is //! expected to hold, preferring the scopes that conventionally carry it. //! //! cargo run --example gen_themes -- /runtime/themes src/themes.rs use std::{collections::BTreeMap, fmt::Write as _, path::Path}; use toml::Value; type Rgb = [u8; 3]; /// A color as written in a theme: its RGB value, plus the ANSI slot it named if /// it was written as one of Helix's terminal color names rather than as hex. #[derive(Clone, Copy, PartialEq, Eq)] struct Color { rgb: Rgb, ansi: Option, } /// Helix's terminal color names and the palette slots they select. `gray` is /// the dim one and `light-gray` the bright one, matching how Helix hands them /// to crossterm (`Gray` -> dark grey, `LightGray` -> grey). const NAMED: &[(&str, u8)] = &[ ("black", 0), ("red", 1), ("green", 2), ("yellow", 3), ("blue", 4), ("magenta", 5), ("cyan", 6), ("light-gray", 7), ("gray", 8), ("light-red", 9), ("light-green", 10), ("light-yellow", 11), ("light-blue", 12), ("light-magenta", 13), ("light-cyan", 14), ("white", 15), ]; /// The xterm defaults, used both for themes that name terminal colors outright /// and as the last resort for a slot nothing in a theme fits. const XTERM: [Rgb; 16] = [ [0x00, 0x00, 0x00], [0xcd, 0x00, 0x00], [0x00, 0xcd, 0x00], [0xcd, 0xcd, 0x00], [0x00, 0x00, 0xee], [0xcd, 0x00, 0xcd], [0x00, 0xcd, 0xcd], [0xe5, 0xe5, 0xe5], [0x7f, 0x7f, 0x7f], [0xff, 0x00, 0x00], [0x00, 0xff, 0x00], [0xff, 0xff, 0x00], [0x5c, 0x5c, 0xff], [0xff, 0x00, 0xff], [0x00, 0xff, 0xff], [0xff, 0xff, 0xff], ]; /// The hue each chromatic slot aims for, and the scopes that conventionally /// carry it. Earlier scopes are preferred, but only as a tie-break: a theme /// whose `function` is teal still gets a teal blue rather than a stray hue. const SLOTS: [(u8, f32, &[&str]); 6] = [ ( 1, 0.0, &[ "error", "diagnostic.error", "diff.minus", "keyword.control.exception", "markup.list.unchecked", "variable.builtin", ], ), ( 2, 120.0, &[ "string", "diff.plus", "markup.raw", "hint", "diagnostic.hint", "variable.other.member", ], ), ( 3, 48.0, &[ "warning", "diagnostic.warning", "constant.numeric", "attribute", "type", "markup.heading", ], ), ( 4, 230.0, &[ "function", "info", "diagnostic.info", "constructor", "markup.link.url", "diff.delta", ], ), ( 5, 300.0, &[ "keyword", "constant", "special", "namespace", "markup.link.text", "type.enum.variant", ], ), ( 6, 185.0, &[ "operator", "constant.character.escape", "markup.quote", "string.special", "punctuation.special", "label", ], ), ]; /// One theme file, before inheritance is resolved. #[derive(Default)] struct Raw { inherits: Option, palette: BTreeMap, /// Scope -> (fg, bg), each already flattened out of Helix's several /// spellings (bare string, `{ fg, bg }`, `{ underline = { color } }`). scopes: BTreeMap, Option)>, } fn read(path: &Path) -> Raw { let text = std::fs::read_to_string(path).expect("cannot read theme file"); let table: toml::Table = toml::from_str(&text).expect("theme is not valid TOML"); let mut raw = Raw::default(); for (key, value) in table { match (key.as_str(), value) { ("inherits", Value::String(name)) => raw.inherits = Some(name), ("palette", Value::Table(entries)) => { for (name, value) in entries { if let Value::String(color) = value { raw.palette.insert(name, color); } } } (_, Value::String(fg)) => { raw.scopes.insert(key, (Some(fg), None)); } (_, Value::Table(style)) => { let text = |v: Option<&Value>| v.and_then(Value::as_str).map(str::to_owned); let fg = text(style.get("fg")).or_else(|| { style .get("underline") .and_then(Value::as_table) .and_then(|u| text(u.get("color"))) }); raw.scopes.insert(key, (fg, text(style.get("bg")))); } _ => {} } } raw } /// A theme with its `inherits` chain folded in: the child's palette entries and /// whole scope entries win over the parent's, exactly as Helix merges them. struct Theme { palette: BTreeMap, scopes: BTreeMap, Option)>, } fn flatten(name: &str, raws: &BTreeMap) -> Theme { let mut chain = vec![]; let mut current = Some(name.to_owned()); while let Some(name) = current { if chain.contains(&name) { break; } current = raws.get(&name).and_then(|r| r.inherits.clone()); chain.push(name); } let mut theme = Theme { palette: BTreeMap::new(), scopes: BTreeMap::new(), }; // Ancestors first so that descendants overwrite them. for name in chain.iter().rev() { let Some(raw) = raws.get(name) else { continue }; theme.palette.extend(raw.palette.clone()); theme.scopes.extend(raw.scopes.clone()); } theme } impl Theme { fn color(&self, text: &str) -> Option { let text = self.palette.get(text).map(String::as_str).unwrap_or(text); if let Some((_, index)) = NAMED.iter().find(|(name, _)| *name == text) { return Some(Color { rgb: XTERM[*index as usize], ansi: Some(*index), }); } let digits = text.strip_prefix('#')?; let expand = |c: u8| (c << 4) | c; let nibble = |i: usize| u8::from_str_radix(&digits[i..i + 1], 16).ok(); let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok(); let rgb = match digits.len() { 3 => [expand(nibble(0)?), expand(nibble(1)?), expand(nibble(2)?)], 6 | 8 => [byte(0)?, byte(2)?, byte(4)?], _ => return None, }; Some(Color { rgb, ansi: None }) } fn fg(&self, scope: &str) -> Option { self.color(self.scopes.get(scope)?.0.as_deref()?) } fn bg(&self, scope: &str) -> Option { self.color(self.scopes.get(scope)?.1.as_deref()?) } /// Every color the theme mentions anywhere, with the number of scopes that /// use it. This is the pool the ANSI slots are chosen from; the count /// separates a theme's working colors from one-off accents such as the /// color of a jump label. fn all(&self) -> Vec<(Color, usize)> { let mut counts: BTreeMap = BTreeMap::new(); let named = self.palette.values().map(String::as_str); // `ui.*` carries backgrounds, greys and one-off markers such as the // color of a jump label, none of which belong in a terminal palette. let scoped = self .scopes .iter() .filter(|(scope, _)| !scope.starts_with("ui.")) .flat_map(|(_, (fg, bg))| [fg, bg]) .filter_map(|c| c.as_deref()); for color in named.chain(scoped).filter_map(|c| self.color(c)) { counts.entry(color.rgb).or_insert((color, 0)).1 += 1; } counts.into_values().collect() } } fn luma(c: Rgb) -> f32 { let channel = |v: u8| { let v = v as f32 / 255.0; if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }; 0.2126 * channel(c[0]) + 0.7152 * channel(c[1]) + 0.0722 * channel(c[2]) } fn contrast(a: Rgb, b: Rgb) -> f32 { let (a, b) = (luma(a) + 0.05, luma(b) + 0.05); if a > b { a / b } else { b / a } } /// Hue in degrees, saturation and lightness in 0..1. fn hsl(c: Rgb) -> (f32, f32, f32) { let [r, g, b] = [ c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, ]; let max = r.max(g).max(b); let min = r.min(g).min(b); let (span, sum) = (max - min, max + min); let lightness = sum / 2.0; if span < f32::EPSILON { return (0.0, 0.0, lightness); } let saturation = span / (1.0 - (sum - 1.0).abs()); let hue = if max == r { 60.0 * (((g - b) / span) % 6.0) } else if max == g { 60.0 * ((b - r) / span + 2.0) } else { 60.0 * ((r - g) / span + 4.0) }; (hue.rem_euclid(360.0), saturation, lightness) } fn hue_distance(a: f32, b: f32) -> f32 { let d = (a - b).abs() % 360.0; d.min(360.0 - d) } fn mix(a: Rgb, b: Rgb, t: f32) -> Rgb { let channel = |i: usize| (a[i] as f32 + (b[i] as f32 - a[i] as f32) * t).round() as u8; [channel(0), channel(1), channel(2)] } const BLACK: Rgb = [0, 0, 0]; const WHITE: Rgb = [255, 255, 255]; /// The converted theme, mirroring the struct in `src/theme.rs`. struct Out { dark: bool, background: Rgb, foreground: Rgb, cursor: Rgb, selection_bg: Rgb, selection_fg: Rgb, accent: Rgb, chrome_bg: Rgb, chrome_fg: Rgb, chrome_active_bg: Rgb, border: Rgb, popup_bg: Rgb, ansi: [Rgb; 16], } fn convert(theme: &Theme) -> Out { let text = theme .fg("ui.text") .or_else(|| theme.fg("ui.background")) .or_else(|| theme.fg("ui.statusline")); let background = theme .bg("ui.background") .or_else(|| theme.bg("ui.popup")) .or_else(|| theme.bg("ui.statusline")); // A theme that never states a background (the `term16` family leaves it to // the terminal) is placed by the brightness of its text instead. let dark = match (background, text) { (Some(bg), _) => luma(bg.rgb) < 0.22, (None, Some(fg)) => luma(fg.rgb) > 0.22, (None, None) => true, }; let background = background.map_or(if dark { BLACK } else { WHITE }, |c| c.rgb); let mut foreground = text.map_or(if dark { WHITE } else { BLACK }, |c| c.rgb); // A theme that states neither (peachpuff leaves both to the terminal) can // resolve to text the same shade as its background, which renders nothing. if contrast(foreground, background) < 2.0 { foreground = if dark { WHITE } else { BLACK }; } let far = if dark { WHITE } else { BLACK }; let ansi = palette(theme, background, foreground, dark); let cursor = theme .bg("ui.cursor.primary") .or_else(|| theme.bg("ui.cursor")) .or_else(|| theme.fg("ui.cursor.primary")) .or_else(|| theme.fg("ui.cursor")) .map_or(foreground, |c| c.rgb); let selection_bg = theme .bg("ui.selection.primary") .or_else(|| theme.bg("ui.selection")) .map_or(mix(background, far, 0.22), |c| c.rgb); let selection_fg = theme .fg("ui.selection.primary") .or_else(|| theme.fg("ui.selection")) .map(|c| c.rgb) .filter(|c| contrast(*c, selection_bg) >= 2.5) .unwrap_or_else(|| { if contrast(foreground, selection_bg) >= 2.5 { foreground } else { mix( selection_bg, if luma(selection_bg) > 0.3 { BLACK } else { WHITE }, 0.85, ) } }); let chrome_bg = theme .bg("ui.statusline") .or_else(|| theme.bg("ui.popup")) .map(|c| c.rgb) .filter(|c| *c != background) .unwrap_or_else(|| mix(background, far, 0.09)); let chrome_fg = theme .fg("ui.statusline") .map(|c| c.rgb) .filter(|c| contrast(*c, chrome_bg) >= 2.0) .unwrap_or_else(|| mix(chrome_bg, far, 0.7)); // The accent marks the focused pane: it is drawn both on the title bar and // as a border on the terminal background, so it has to read on both. let readable = |c: Rgb| contrast(c, chrome_bg) >= 2.5 && contrast(c, background) >= 2.5; let vivid = [theme.fg("ui.cursor.match").map(|c| c.rgb), Some(cursor)] .into_iter() .flatten() .find(|c| readable(*c) && hsl(*c).1 > 0.30); let accent = vivid // A theme whose cursor is a plain grey still deserves a colored focus // ring, so fall back to its own blue, cyan or magenta. .or_else(|| { [4, 6, 5, 3] .into_iter() .map(|i| ansi[i]) .find(|c| readable(*c)) }) .or(Some(cursor).filter(|c| readable(*c))) .unwrap_or(foreground); Out { dark, background, foreground, cursor, selection_bg, selection_fg, accent, chrome_bg, chrome_fg, // The focused pane's title bar steps away from the unfocused ones, // whichever direction that is for this theme's chrome. chrome_active_bg: mix( chrome_bg, if luma(chrome_bg) > 0.18 { BLACK } else { WHITE }, 0.14, ), // Unfocused borders are a hint, not a frame, so a theme with a loud // window color is pulled back towards its background. border: theme .fg("ui.window") .or_else(|| theme.fg("ui.virtual.indent-guide")) .map_or(mix(background, far, 0.28), |c| mix(background, c.rgb, 0.65)), popup_bg: theme.bg("ui.popup").map_or(chrome_bg, |c| c.rgb), ansi, } } /// Picks the sixteen ANSI colors. The six chromatic slots score every color the /// theme uses against the slot's hue; the greys are derived from the background /// and foreground so that they always stay legible against the theme's own /// background. fn palette(theme: &Theme, background: Rgb, foreground: Rgb, dark: bool) -> [Rgb; 16] { let pool = theme.all(); let mut ansi = XTERM; // A theme whose palette names terminal colors outright is already // describing a palette; those entries are taken as written. let named: BTreeMap = theme .palette .keys() .filter_map(|name| Some((slot_named(name)?, theme.color(name)?.rgb))) .collect(); let mut taken: Vec = vec![]; let mut assigned = [false; 7]; for (index, rgb) in &named { ansi[*index as usize] = *rgb; if let Some(slot) = assigned.get_mut(*index as usize) { *slot = true; taken.push(*rgb); } } // Score every slot against every color the theme uses, then hand each color // to the slot that wants it most. Assigning greedily one slot at a time // would let an early slot take a color a later slot needed far more, which // is how palettes with no true blue end up with two cyans. let mut pairs: Vec<(f32, usize, Rgb)> = vec![]; for (index, hue, scopes) in SLOTS { if assigned[index as usize] { continue; } let preferred: Vec = scopes .iter() .filter_map(|s| Some(theme.fg(s)?.rgb)) .collect(); for (candidate, uses) in &pool { let (h, s, l) = hsl(candidate.rgb); // A grey, an unreadable color, the plain text color and a color the // theme only spelled as a terminal name (which says nothing about // what that terminal color should be) are never palette entries. if s < 0.10 || contrast(candidate.rgb, background) < 1.8 || candidate.ansi.is_some() || candidate.rgb == foreground || candidate.rgb == background { continue; } let rank = preferred .iter() .position(|p| *p == candidate.rgb) .map_or(20.0, |r| r as f32 * 2.5); // Whichever slot's hue this color sits closest to has the better // claim on it, so that a theme's red is not spent on magenta just // because it happens to be the color of `keyword`. let closest = SLOTS .iter() .map(|(_, target, _)| *target) .min_by(|a, b| hue_distance(h, *a).total_cmp(&hue_distance(h, *b))); let score = hue_distance(h, hue) * 1.5 + rank + (1.0 - s) * 38.0 + if closest == Some(hue) { 0.0 } else { 45.0 } // A color used once is a one-off accent, not a working color. + match uses { 0 | 1 => 22.0, 2 => 10.0, _ => 0.0, } + if l > 0.85 { 40.0 } else { 0.0 }; pairs.push((score, index as usize, candidate.rgb)); } } pairs.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); for (_, index, rgb) in &pairs { if !assigned[*index] && !taken.contains(rgb) { assigned[*index] = true; ansi[*index] = *rgb; taken.push(*rgb); } } // A theme with fewer than six usable colors leaves slots over; those share // whichever color came closest rather than falling back to a stock xterm // primary that belongs to no theme at all. for (_, index, rgb) in &pairs { if !assigned[*index] { assigned[*index] = true; ansi[*index] = *rgb; } } for (index, _, _) in SLOTS { if !assigned[index as usize] { ansi[index as usize] = mix(XTERM[index as usize], foreground, 0.4); } } // The bright variant is the theme's own lighter shade of the same color // where it has one, and a lightened copy otherwise. Matching the hue is not // enough: a pale grey-blue is a poor bright blue, so the candidate has to // keep most of the base color's chroma, and it cannot be a color another // slot already owns. let chroma = |c: Rgb| (*c.iter().max().unwrap() - *c.iter().min().unwrap()) as f32; for (index, _, _) in SLOTS { if named.contains_key(&(index + 8)) { continue; } let base = ansi[index as usize]; let (base_hue, _, lightness) = hsl(base); let brighter = pool .iter() .map(|(c, _)| c) .filter(|c| { let (h, _, l) = hsl(c.rgb); hue_distance(h, base_hue) <= 30.0 && chroma(c.rgb) >= chroma(base) * 0.7 && (0.05..=0.40).contains(&(l - lightness)) && contrast(c.rgb, background) >= 2.0 && !taken.contains(&c.rgb) }) .min_by(|a, b| hsl(a.rgb).2.total_cmp(&hsl(b.rgb).2)); ansi[index as usize + 8] = brighter.map_or_else( || mix(base, WHITE, if lightness > 0.7 { 0.12 } else { 0.26 }), |c| c.rgb, ); } // Greys: black through bright white always span from the theme's darker end // to its lighter end, so ANSI text stays readable in light themes too. let comment = theme .fg("comment") .map(|c| c.rgb) .filter(|c| contrast(*c, background) >= 2.0); let greys = if dark { [ mix(background, WHITE, 0.13), foreground, comment.unwrap_or_else(|| mix(background, WHITE, 0.42)), mix(foreground, WHITE, 0.35), ] } else { [ mix(foreground, BLACK, 0.25), mix(background, BLACK, 0.13), comment.unwrap_or_else(|| mix(foreground, background, 0.45)), background, ] }; for (slot, grey) in [0, 7, 8, 15].into_iter().zip(greys) { ansi[slot as usize] = *named.get(&slot).unwrap_or(&grey); } ansi } /// The palette slot a `[palette]` key names outright, so that a theme defining /// `red`/`bright-blue` and friends keeps its own values. fn slot_named(name: &str) -> Option { let name = name.to_ascii_lowercase().replace('_', "-"); let (bright, base) = match name .strip_prefix("bright-") .or_else(|| name.strip_prefix("light-")) { Some(base) => (8, base.to_owned()), None => (0, name), }; let index = match base.as_str() { "black" => 0, "red" => 1, "green" => 2, "yellow" => 3, "blue" => 4, "magenta" => 5, "cyan" => 6, "white" => 7, _ => return None, }; Some(index + bright) } fn main() { let mut args = std::env::args().skip(1); let dir = args .next() .expect("usage: gen_themes "); let out = args .next() .expect("usage: gen_themes "); let mut raws = BTreeMap::new(); for entry in std::fs::read_dir(&dir).expect("cannot read themes directory") { let path = entry.expect("cannot read theme").path(); if path.extension().is_some_and(|e| e == "toml") { let name = path.file_stem().unwrap().to_string_lossy().into_owned(); raws.insert(name, read(&path)); } } let mut text = String::from( "//! The themes Helix ships with, converted by `cargo run --example gen_themes`.\n\ //! Generated file: edit the generator, not this.\n\ //!\n\ //! Upstream: .\n\ //! The individual themes keep their own authors and licenses; see `themes/LICENSES`.\n\n\ use crate::theme::Theme;\n\n\ /// Every built-in theme, sorted by name so that lookup can binary search.\n\ pub static THEMES: &[(&str, Theme)] = &[\n", ); for name in raws.keys() { let out = convert(&flatten(name, &raws)); let hex = |c: Rgb| format!("0x{:02x}{:02x}{:02x}", c[0], c[1], c[2]); let ansi = out .ansi .iter() .map(|c| hex(*c)) .collect::>() .join(", "); writeln!( text, " (\n {name:?},\n Theme {{\n \ dark: {dark},\n background: {bg},\n foreground: {fg},\n \ cursor: {cursor},\n \ selection_bg: {sel_bg},\n selection_fg: {sel_fg},\n \ accent: {accent},\n chrome_bg: {chrome_bg},\n \ chrome_fg: {chrome_fg},\n chrome_active_bg: {chrome_active},\n \ border: {border},\n popup_bg: {popup},\n \ ansi: [\n {ansi},\n ],\n }},\n ),", dark = out.dark, bg = hex(out.background), fg = hex(out.foreground), cursor = hex(out.cursor), sel_bg = hex(out.selection_bg), sel_fg = hex(out.selection_fg), accent = hex(out.accent), chrome_bg = hex(out.chrome_bg), chrome_fg = hex(out.chrome_fg), chrome_active = hex(out.chrome_active_bg), border = hex(out.border), popup = hex(out.popup_bg), ) .unwrap(); } text.push_str("];\n"); std::fs::write(&out, text).expect("cannot write output"); eprintln!("wrote {} themes to {out}", raws.len()); }