Compare commits

...

4 Commits

Author SHA1 Message Date
Jan-Bulthuis
bfc908f075 feat: generate css from theme 2026-07-19 19:07:09 +02:00
Jan-Bulthuis
255d6e9611 feat: add themes derivation 2026-07-19 18:34:37 +02:00
Jan-Bulthuis
08504aac28 feat: read helix themes 2026-07-19 18:34:06 +02:00
Jan-Bulthuis
cac6d0ae31 feat: perform highlighting 2026-07-19 16:04:29 +02:00
5 changed files with 401 additions and 5 deletions

View File

@ -46,6 +46,14 @@
cp -r ./* $out/ cp -r ./* $out/
''; '';
}; };
packages.themes = pkgs.stdenv.mkDerivation {
name = "themes";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out
cp ${helix}/runtime/themes/*.toml $out/
'';
};
packages.karnaugh = pkgs.rustPlatform.buildRustPackage (final: { packages.karnaugh = pkgs.rustPlatform.buildRustPackage (final: {
pname = "karnaugh"; pname = "karnaugh";
version = "0.1.5"; version = "0.1.5";

View File

@ -26,6 +26,7 @@ use tracing_subscriber::FmtSubscriber;
use crate::typst::TypstContext; use crate::typst::TypstContext;
mod syntax; mod syntax;
mod themes;
mod typst; mod typst;
#[derive(Clone)] #[derive(Clone)]
@ -64,6 +65,10 @@ struct AppConfig {
#[arg(short = 'g', long, value_name = "DIR", default_value = None)] #[arg(short = 'g', long, value_name = "DIR", default_value = None)]
syntax_root: Option<PathBuf>, syntax_root: Option<PathBuf>,
/// The directory where Helix theme `.toml` files are stored.
#[arg(short = 't', long, value_name = "DIR", default_value = None)]
themes_root: Option<PathBuf>,
/// Grammars to load, separated by commas. /// Grammars to load, separated by commas.
/// Useful to avoid the memory required to load all languages. /// Useful to avoid the memory required to load all languages.
#[arg(short = 'L', long = "lang", value_name = "NAME", value_delimiter = ',')] #[arg(short = 'L', long = "lang", value_name = "NAME", value_delimiter = ',')]
@ -105,6 +110,7 @@ async fn main() {
.init(); .init();
syntax::init(&state.config); syntax::init(&state.config);
themes::init(&state.config);
// TODO: Replace with config option // TODO: Replace with config option
let mut favicon = state.config.get_full_assets_path(); let mut favicon = state.config.get_full_assets_path();

View File

@ -5,15 +5,22 @@ use std::{
fs, fs,
path::Path, path::Path,
sync::OnceLock, sync::OnceLock,
time::Duration,
}; };
use ropey::Rope;
use tracing::{info, warn}; use tracing::{info, warn};
use tree_house::{ use tree_house::{
InjectionLanguageMarker, Language, LanguageConfig, LanguageLoader, highlighter::Highlight, InjectionLanguageMarker, Language, LanguageConfig, LanguageLoader, Syntax,
highlighter::{Highlight, HighlightEvent, Highlighter},
read_query, read_query,
}; };
use tree_house_bindings::Grammar; use tree_house_bindings::Grammar;
use typst::foundations::{Func, NativeFunc, func}; use typst::{
foundations::{Content, Func, NativeElement, NativeFunc, func},
text::TextElem,
};
use typst_html::{HtmlAttr, HtmlElem, attr, tag};
use crate::AppConfig; use crate::AppConfig;
@ -183,7 +190,76 @@ fn highlight(
#[named] #[named]
#[default(None)] #[default(None)]
lang: Option<String>, lang: Option<String>,
) -> String { ) -> Content {
let rev = text.chars().rev(); let highlighted = highlight_code(&text, lang.as_deref()).unwrap_or(TextElem::packed(text));
rev.collect()
if block {
HtmlElem::new(tag::pre)
.with_body(Some(highlighted))
.with_attr(HtmlAttr::constant("class"), "code-block")
.pack()
} else {
HtmlElem::new(tag::code)
.with_body(Some(highlighted))
.with_attr(HtmlAttr::constant("class"), "code-inline")
.pack()
}
}
/// Highlights `source` as `lang`.
fn highlight_code(source: &str, lang: Option<&str>) -> Option<Content> {
let registry = REGISTRY.get()?;
let language = *registry.by_name.get(lang?)?;
let rope = Rope::from_str(source);
let slice = rope.slice(..);
let syntax = Syntax::new(slice, language, Duration::from_millis(500), registry).ok()?;
let mut hl = Highlighter::new(&syntax, slice, registry, 0..source.len() as u32);
let src = source.as_bytes();
let src_len = source.len() as u32;
let mut pos: u32 = 0;
let mut classes = String::new();
let mut nodes = Vec::<Content>::new();
loop {
let next = hl.next_event_offset().min(src_len);
if next > pos {
let text = std::str::from_utf8(&src[pos as usize..next as usize]).ok()?;
let node = TextElem::packed(text);
nodes.push(if classes.is_empty() {
node
} else {
HtmlElem::new(tag::span)
.with_attr(attr::class, classes.clone())
.with_body(Some(node))
.pack()
});
pos = next;
}
if pos >= src_len {
break;
}
let (event, highlights) = hl.advance();
let base = match event {
// Replace the active highlights
HighlightEvent::Refresh => String::new(),
// Extend the active highlights
HighlightEvent::Push => classes.clone(),
};
classes = highlights.fold(base, |mut acc, h| {
let name = HIGHLIGHT_NAMES[h.idx()].replace('.', "-");
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str("hl-");
acc.push_str(&name);
acc
});
}
Some(Content::sequence(nodes))
} }

300
src/themes.rs Normal file
View File

@ -0,0 +1,300 @@
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, NativeElement, NativeFunc, func},
text::TextElem,
};
use crate::AppConfig;
static REGISTRY: OnceLock<ThemeRegistry> = OnceLock::new();
#[derive(Debug)]
struct ThemeRegistry {
themes: HashMap<String, Theme>,
}
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::<Table>(&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, Hash)]
struct Color(String);
impl Color {
fn from_str(color: &str) -> Option<Self> {
if color.starts_with('#') {
Some(Self(color.into()))
} else {
None
}
}
fn from_palette(color: &str, palette: &HashMap<String, Color>) -> Option<Self> {
if color.starts_with('#') {
Self::from_str(color)
} else {
palette.get(color).cloned()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Style {
fg: Option<Color>,
bold: bool,
italic: bool,
underlined: bool,
}
impl Style {
fn from_toml(style: Value, palette: &HashMap<String, Color>) -> Option<Self> {
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<Color>,
background: Option<Color>,
styles: HashMap<String, Style>,
}
static IGNORED_SCOPES: &[&str] = &["warning", "error", "info", "hint"];
impl Theme {
fn from_toml(theme: Table) -> Option<Self> {
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::<HashMap<String, Color>>()
})
.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.")
}
}
}
pub fn theme_css_func() -> Func {
theme_css::func()
}
fn scope_to_selector(scope: &str) -> String {
format!(".hl-{}", scope.replace('.', "-"))
}
fn style_declarations(style: &Style) -> String {
let mut parts = Vec::new();
if let Some(Color(hex)) = &style.fg {
parts.push(format!("color:{hex}"));
}
parts.push(format!(
"font-weight:{}",
if style.bold { "bold" } else { "normal" }
));
parts.push(format!(
"font-style:{}",
if style.italic { "italic" } else { "normal" }
));
parts.push(format!(
"text-decoration:{}",
if style.underlined {
"underline"
} else {
"none"
}
));
parts.join(";")
}
fn generate_css(scopes: &[&str], theme: &Theme) -> String {
let mut groups: HashMap<&Style, Vec<&str>> = HashMap::new();
for &scope in scopes {
if let Some(style) = theme.styles.get(scope) {
groups.entry(style).or_default().push(scope);
}
}
groups
.iter()
.map(|(style, scopes)| {
let selectors = scopes
.iter()
.map(|s| scope_to_selector(s))
.collect::<Vec<_>>()
.join(",");
format!("{}{{{}}}", selectors, style_declarations(style))
})
.collect::<Vec<_>>()
.join("\n")
}
#[func]
fn theme_css(light: String, dark: String) -> String {
let Some(registry) = REGISTRY.get() else {
return String::new();
};
let Some(light_theme) = registry.themes.get(&light) else {
warn!("Unknown light theme: {light}");
return String::new();
};
let Some(dark_theme) = registry.themes.get(&dark) else {
warn!("Unknown dark theme: {dark}");
return String::new();
};
let mut shared: Vec<&str> = light_theme
.styles
.keys()
.filter(|k| dark_theme.styles.contains_key(*k))
.map(String::as_str)
.collect();
shared.sort();
let light_css = generate_css(&shared, light_theme);
let dark_css = generate_css(&shared, dark_theme);
format!("{light_css}\n@media (prefers-color-scheme:dark){{{dark_css}}}")
}

View File

@ -30,6 +30,12 @@ impl TypstContext {
Value::Func(crate::syntax::highlight_func()), Value::Func(crate::syntax::highlight_func()),
); );
} }
if config.themes_root.is_some() {
inputs.insert(
"theme_css".into(),
Value::Func(crate::themes::theme_css_func()),
);
}
let library = Library::builder() let library = Library::builder()
.with_inputs(inputs) .with_inputs(inputs)
.with_features([typst::Feature::Html].into_iter().collect()) .with_features([typst::Feature::Html].into_iter().collect())