From 30ca17e17faa0aba464e153a9cd60178375188e4 Mon Sep 17 00:00:00 2001 From: Jan-Bulthuis Date: Sun, 19 Jul 2026 18:34:06 +0200 Subject: [PATCH] feat: read helix themes --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 6 ++ src/themes.rs | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 src/themes.rs diff --git a/Cargo.lock b/Cargo.lock index e16991e..ab13014 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,6 +1427,7 @@ dependencies = [ "tar", "time", "tokio", + "toml", "tower-http", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index e68c0fd..7aa5aeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ typst-utils = "0.15.1" tree-house = "0.4.0" tree-house-bindings = { version = "0.3.2", features = ["ropey"] } ropey = "1.6.1" +toml = "0.8.23" diff --git a/src/main.rs b/src/main.rs index dd82fb8..6e564fa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ use tracing_subscriber::FmtSubscriber; use crate::typst::TypstContext; mod syntax; +mod themes; mod typst; #[derive(Clone)] @@ -64,6 +65,10 @@ struct AppConfig { #[arg(short = 'g', long, value_name = "DIR", default_value = None)] syntax_root: Option, + /// The directory where Helix theme `.toml` files are stored. + #[arg(short = 't', long, value_name = "DIR", default_value = None)] + themes_root: Option, + /// Grammars to load, separated by commas. /// Useful to avoid the memory required to load all languages. #[arg(short = 'L', long = "lang", value_name = "NAME", value_delimiter = ',')] @@ -105,6 +110,7 @@ async fn main() { .init(); syntax::init(&state.config); + themes::init(&state.config); // TODO: Replace with config option let mut favicon = state.config.get_full_assets_path(); diff --git a/src/themes.rs b/src/themes.rs new file mode 100644 index 0000000..ae0a748 --- /dev/null +++ b/src/themes.rs @@ -0,0 +1,216 @@ +use std::{ + collections::{HashMap, HashSet}, + error::Error, + fs, + path::Path, + sync::OnceLock, +}; + +use toml::{Table, Value}; +use tracing::{info, warn}; +use typst::foundations::{Content, Func, NativeFunc, func}; + +use crate::AppConfig; + +static REGISTRY: OnceLock = OnceLock::new(); + +#[derive(Debug)] +struct ThemeRegistry { + themes: HashMap, +} + +impl ThemeRegistry { + fn new(theme_root: &Path) -> Self { + let mut helix_themes = HashMap::new(); + for entry in fs::read_dir(theme_root).into_iter().flatten().flatten() { + let name = entry + .path() + .file_prefix() + .unwrap() + .to_string_lossy() + .into_owned(); + if let Ok(text) = fs::read_to_string(entry.path()) + && let Ok(toml) = toml::from_str::(&text) + { + helix_themes.insert(name, toml); + } + } + + // resolve inheritance + while helix_themes + .values() + .any(|table| table.contains_key("inherits")) + { + let old_themes = helix_themes.clone(); + old_themes + .keys() + .filter(|name| old_themes.get(*name).unwrap().contains_key("inherits")) + .for_each(|name| { + let parent_name = old_themes + .get(name) + .unwrap() + .get("inherits") + .unwrap() + .as_str() + .unwrap(); + let parent = old_themes.get(parent_name).unwrap(); + if !parent.contains_key("inherits") { + let table = helix_themes.get_mut(name).unwrap(); + parent.into_iter().for_each(|(k, v)| { + if k == "palette" { + let parent_palette = v.as_table(); + let child_palette = table + .entry(k.clone()) + .or_insert_with(|| Value::Table(Table::new())) + .as_table_mut(); + if let (Some(parent_palette), Some(child_palette)) = + (parent_palette, child_palette) + { + for (pk, pv) in parent_palette { + child_palette + .entry(pk.clone()) + .or_insert_with(|| pv.clone()); + } + } + } else if !table.contains_key(k) { + table.insert(k.clone(), v.clone()); + } + }); + table.remove("inherits"); + } + }); + } + + let themes = helix_themes + .into_iter() + .flat_map(|(name, table)| Theme::from_toml(table).map(|t| (name, t))) + .collect(); + + Self { themes } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Color(String); + +impl Color { + fn from_str(color: &str) -> Option { + if color.starts_with('#') { + Some(Self(color.into())) + } else { + None + } + } + + fn from_palette(color: &str, palette: &HashMap) -> Option { + if color.starts_with('#') { + Self::from_str(color) + } else { + palette.get(color).cloned() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Style { + fg: Option, + bold: bool, + italic: bool, + underlined: bool, +} + +impl Style { + fn from_toml(style: Value, palette: &HashMap) -> Option { + let resolve = |s: &str| Color::from_palette(s, palette); + match style { + Value::String(s) => Some(Self { + fg: resolve(&s), + bold: false, + italic: false, + underlined: false, + }), + Value::Table(table) => { + let fg = table.get("fg").and_then(Value::as_str).and_then(resolve); + let modifiers: HashSet<&str> = table + .get("modifiers") + .and_then(Value::as_array) + .into_iter() + .flatten() + .flat_map(Value::as_str) + .collect(); + Some(Self { + fg, + bold: modifiers.contains("bold"), + italic: modifiers.contains("italic"), + underlined: modifiers.contains("underlined"), + }) + } + _ => None, + } + } +} + +#[derive(Debug)] +struct Theme { + foreground: Option, + background: Option, + styles: HashMap, +} + +static IGNORED_SCOPES: &[&str] = &["warning", "error", "info", "hint"]; + +impl Theme { + fn from_toml(theme: Table) -> Option { + let palette = theme + .get("palette") + .and_then(Value::as_table) + .cloned() + .map(|table| { + table + .into_iter() + .flat_map(|(k, v)| v.as_str().and_then(Color::from_str).map(|v| (k, v))) + .collect::>() + }) + .unwrap_or_default(); + + let resolve = |c: &str| Color::from_palette(c, &palette); + + let ui_background = theme.get("ui.background").and_then(Value::as_table)?; + let foreground = ui_background + .get("fg") + .and_then(Value::as_str) + .and_then(resolve); + let background = ui_background + .get("bg") + .and_then(Value::as_str) + .and_then(resolve); + + let styles = theme + .into_iter() + .filter(|(k, _)| { + !k.starts_with("ui.") + && !k.starts_with("diagnostic.") + && !IGNORED_SCOPES.contains(&k.as_str()) + }) + .flat_map(|(k, v)| Style::from_toml(v, &palette).map(|v| (k, v))) + .collect(); + + Some(Self { + foreground, + background, + styles, + }) + } +} + +pub fn init(config: &AppConfig) { + match &config.themes_root { + Some(themes_root) => { + info!("Loading themes from {}", themes_root.display()); + REGISTRY.get_or_init(|| ThemeRegistry::new(themes_root)); + } + None => { + warn!("No themes root has been configured, theme-based colouring will not be possible.") + } + } +}