Compare commits
8 Commits
bfc908f075
...
24666266d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24666266d3 | ||
|
|
9b50509910 | ||
|
|
3711e99528 | ||
|
|
7798d556bc | ||
|
|
b4a8084350 | ||
|
|
2bb0a5c099 | ||
|
|
2b0c852e99 | ||
|
|
30ca17e17f |
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -1427,6 +1427,7 @@ dependencies = [
|
|||||||
"tar",
|
"tar",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|||||||
@ -28,3 +28,4 @@ typst-utils = "0.15.1"
|
|||||||
tree-house = "0.4.0"
|
tree-house = "0.4.0"
|
||||||
tree-house-bindings = { version = "0.3.2", features = ["ropey"] }
|
tree-house-bindings = { version = "0.3.2", features = ["ropey"] }
|
||||||
ropey = "1.6.1"
|
ropey = "1.6.1"
|
||||||
|
toml = "0.8.23"
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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,10 +65,9 @@ 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>,
|
||||||
|
|
||||||
/// Grammars to load, separated by commas.
|
/// The directory where Helix theme `.toml` files are stored.
|
||||||
/// Useful to avoid the memory required to load all languages.
|
#[arg(short = 't', long, value_name = "DIR", default_value = None)]
|
||||||
#[arg(short = 'L', long = "lang", value_name = "NAME", value_delimiter = ',')]
|
themes_root: Option<PathBuf>,
|
||||||
languages: Vec<String>,
|
|
||||||
|
|
||||||
/// The log level to use
|
/// The log level to use
|
||||||
#[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)]
|
#[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)]
|
||||||
@ -105,6 +105,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();
|
||||||
|
|||||||
210
src/syntax.rs
210
src/syntax.rs
@ -1,10 +1,10 @@
|
|||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
collections::{HashMap, HashSet},
|
collections::{BTreeSet, HashMap},
|
||||||
error::Error,
|
error::Error,
|
||||||
fs,
|
fs,
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
sync::OnceLock,
|
sync::{Arc, OnceLock, RwLock},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -24,48 +24,18 @@ use typst_html::{HtmlAttr, HtmlElem, attr, tag};
|
|||||||
|
|
||||||
use crate::AppConfig;
|
use crate::AppConfig;
|
||||||
|
|
||||||
static REGISTRY: OnceLock<LanguageRegistry> = OnceLock::new();
|
/// Where grammars are loaded from, captured at `init`.
|
||||||
|
static SYNTAX_ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||||
|
|
||||||
/// Class names used in highlighted code.
|
/// LanguageRegistries keyed by ScopeSet
|
||||||
const HIGHLIGHT_NAMES: &[&str] = &[
|
static REGISTRIES: OnceLock<RwLock<HashMap<BTreeSet<String>, Arc<LanguageRegistry>>>> =
|
||||||
"attribute",
|
OnceLock::new();
|
||||||
"comment",
|
|
||||||
"comment.block",
|
|
||||||
"comment.line",
|
|
||||||
"constant",
|
|
||||||
"constant.builtin",
|
|
||||||
"constructor",
|
|
||||||
"function",
|
|
||||||
"function.builtin",
|
|
||||||
"function.macro",
|
|
||||||
"function.method",
|
|
||||||
"keyword",
|
|
||||||
"keyword.control",
|
|
||||||
"keyword.operator",
|
|
||||||
"label",
|
|
||||||
"namespace",
|
|
||||||
"number",
|
|
||||||
"operator",
|
|
||||||
"property",
|
|
||||||
"punctuation",
|
|
||||||
"punctuation.bracket",
|
|
||||||
"punctuation.delimiter",
|
|
||||||
"special",
|
|
||||||
"string",
|
|
||||||
"string.special",
|
|
||||||
"tag",
|
|
||||||
"type",
|
|
||||||
"type.builtin",
|
|
||||||
"variable",
|
|
||||||
"variable.builtin",
|
|
||||||
"variable.parameter",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Get the longest matching highlight name for a given scope.
|
/// Get the index of the longest prefix of `scope` present in `scopes`.
|
||||||
fn highlight_for_scope(scope: &str) -> Option<Highlight> {
|
fn highlight_for_scope(scope: &str, scopes: &[String]) -> Option<Highlight> {
|
||||||
let mut candidate = scope;
|
let mut candidate = scope;
|
||||||
loop {
|
loop {
|
||||||
if let Some(i) = HIGHLIGHT_NAMES.iter().position(|&n| n == candidate) {
|
if let Some(i) = scopes.iter().position(|n| n == candidate) {
|
||||||
return Some(Highlight::new(i as u32));
|
return Some(Highlight::new(i as u32));
|
||||||
}
|
}
|
||||||
match candidate.rfind('.') {
|
match candidate.rfind('.') {
|
||||||
@ -76,49 +46,89 @@ fn highlight_for_scope(scope: &str) -> Option<Highlight> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct LanguageRegistry {
|
struct LanguageRegistry {
|
||||||
by_name: HashMap<String, Language>,
|
syntax_root: PathBuf,
|
||||||
configs: Vec<LanguageConfig>,
|
/// The scope vocabulary.
|
||||||
|
scopes: Vec<String>,
|
||||||
|
/// Name to Language mapping, Option to allow storing None for failed
|
||||||
|
/// configuration and preventing reloading.
|
||||||
|
by_name: RwLock<HashMap<String, Option<Language>>>,
|
||||||
|
configs: RwLock<Vec<&'static LanguageConfig>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LanguageRegistry {
|
impl LanguageRegistry {
|
||||||
/// Loads grammars from `syntax_root`. When `languages` is non-empty, only
|
fn new(syntax_root: PathBuf, scopes: Vec<String>) -> Self {
|
||||||
/// grammars with those names are loaded; otherwise every grammar found is.
|
Self {
|
||||||
fn new(syntax_root: &Path, languages: &[String]) -> Self {
|
syntax_root,
|
||||||
let filter: Option<HashSet<&str>> =
|
scopes,
|
||||||
(!languages.is_empty()).then(|| languages.iter().map(String::as_str).collect());
|
by_name: RwLock::new(HashMap::new()),
|
||||||
|
configs: RwLock::new(Vec::new()),
|
||||||
let mut by_name = HashMap::new();
|
|
||||||
let mut configs = Vec::new();
|
|
||||||
|
|
||||||
for entry in fs::read_dir(syntax_root).into_iter().flatten().flatten() {
|
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
|
||||||
if filter.as_ref().is_some_and(|f| !f.contains(name.as_str())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let so_path = entry.path().join(format!("{name}.so"));
|
|
||||||
if !so_path.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match load_language(&name, &entry.path(), syntax_root) {
|
|
||||||
Ok(config) => {
|
|
||||||
by_name.insert(name, Language::new(configs.len() as u32));
|
|
||||||
configs.push(config);
|
|
||||||
}
|
|
||||||
Err(e) => warn!("Failed to load grammar {name}: {e}"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn about explicitly requested grammars that were not found.
|
|
||||||
for lang in languages {
|
|
||||||
if !by_name.contains_key(lang) {
|
|
||||||
warn!("Requested grammar {lang} was not found in the syntax root");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("Loaded {} syntax highlighting grammars", configs.len());
|
|
||||||
Self { by_name, configs }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `Language` for `name`, loading and configuring its grammar on first
|
||||||
|
/// request. Returns `None` if the grammar is missing or fails to load.
|
||||||
|
fn language(&self, name: &str) -> Option<Language> {
|
||||||
|
if let Some(cached) = self.by_name.read().unwrap().get(name) {
|
||||||
|
return *cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaded = self.load(name);
|
||||||
|
|
||||||
|
let mut by_name = self.by_name.write().unwrap();
|
||||||
|
if let Some(cached) = by_name.get(name) {
|
||||||
|
return *cached;
|
||||||
|
}
|
||||||
|
let language = loaded.map(|config| {
|
||||||
|
let mut configs = self.configs.write().unwrap();
|
||||||
|
configs.push(config);
|
||||||
|
Language::new((configs.len() - 1) as u32)
|
||||||
|
});
|
||||||
|
by_name.insert(name.to_owned(), language);
|
||||||
|
language
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads and configures the grammar named `name`, leaking the config to a
|
||||||
|
/// `'static` borrow. `None` if the `.so` is absent or parsing fails.
|
||||||
|
fn load(&self, name: &str) -> Option<&'static LanguageConfig> {
|
||||||
|
let lang_dir = self.syntax_root.join(name);
|
||||||
|
if !lang_dir.join(format!("{name}.so")).exists() {
|
||||||
|
warn!("No grammar found for language {name}");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match load_language(name, &lang_dir, &self.syntax_root) {
|
||||||
|
Ok(config) => {
|
||||||
|
config.configure(|scope| highlight_for_scope(scope, &self.scopes));
|
||||||
|
Some(Box::leak(Box::new(config)))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to load grammar {name}: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch or build the registry configured for `scopes`.
|
||||||
|
fn registry_for(scopes: BTreeSet<String>) -> Option<Arc<LanguageRegistry>> {
|
||||||
|
let registries = REGISTRIES.get()?;
|
||||||
|
|
||||||
|
// Fast path: already built.
|
||||||
|
if let Some(registry) = registries.read().unwrap().get(&scopes) {
|
||||||
|
return Some(registry.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let syntax_root = SYNTAX_ROOT.get()?;
|
||||||
|
let ordered = scopes.iter().cloned().collect();
|
||||||
|
let registry = Arc::new(LanguageRegistry::new(syntax_root.clone(), ordered));
|
||||||
|
|
||||||
|
Some(
|
||||||
|
registries
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.entry(scopes)
|
||||||
|
.or_insert(registry)
|
||||||
|
.clone(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_language(
|
fn load_language(
|
||||||
@ -141,7 +151,6 @@ fn load_language(
|
|||||||
&read("injections"),
|
&read("injections"),
|
||||||
&read("locals"),
|
&read("locals"),
|
||||||
)?;
|
)?;
|
||||||
config.configure(highlight_for_scope);
|
|
||||||
|
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
@ -149,17 +158,17 @@ fn load_language(
|
|||||||
impl LanguageLoader for LanguageRegistry {
|
impl LanguageLoader for LanguageRegistry {
|
||||||
fn language_for_marker(&self, marker: InjectionLanguageMarker<'_>) -> Option<Language> {
|
fn language_for_marker(&self, marker: InjectionLanguageMarker<'_>) -> Option<Language> {
|
||||||
match marker {
|
match marker {
|
||||||
InjectionLanguageMarker::Name(name) => self.by_name.get(name).copied(),
|
InjectionLanguageMarker::Name(name) => self.language(name),
|
||||||
InjectionLanguageMarker::Match(text) => {
|
InjectionLanguageMarker::Match(text) => {
|
||||||
let name: Cow<str> = text.into();
|
let name: Cow<str> = text.into();
|
||||||
self.by_name.get(name.as_ref()).copied()
|
self.language(name.as_ref())
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_config(&self, lang: Language) -> Option<&LanguageConfig> {
|
fn get_config(&self, lang: Language) -> Option<&LanguageConfig> {
|
||||||
self.configs.get(lang.idx())
|
self.configs.read().unwrap().get(lang.idx()).copied()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,7 +176,8 @@ pub fn init(config: &AppConfig) {
|
|||||||
match &config.syntax_root {
|
match &config.syntax_root {
|
||||||
Some(syntax_root) => {
|
Some(syntax_root) => {
|
||||||
info!("Loading syntax files from {}", syntax_root.display());
|
info!("Loading syntax files from {}", syntax_root.display());
|
||||||
REGISTRY.get_or_init(|| LanguageRegistry::new(syntax_root, &config.languages));
|
SYNTAX_ROOT.get_or_init(|| syntax_root.clone());
|
||||||
|
REGISTRIES.get_or_init(|| RwLock::new(HashMap::new()));
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
warn!(
|
warn!(
|
||||||
@ -190,8 +200,15 @@ fn highlight(
|
|||||||
#[named]
|
#[named]
|
||||||
#[default(None)]
|
#[default(None)]
|
||||||
lang: Option<String>,
|
lang: Option<String>,
|
||||||
|
#[named]
|
||||||
|
#[default(None)]
|
||||||
|
light: Option<String>,
|
||||||
|
#[named]
|
||||||
|
#[default(None)]
|
||||||
|
dark: Option<String>,
|
||||||
) -> Content {
|
) -> Content {
|
||||||
let highlighted = highlight_code(&text, lang.as_deref()).unwrap_or(TextElem::packed(text));
|
let highlighted = highlight_code(&text, lang.as_deref(), light.as_deref(), dark.as_deref())
|
||||||
|
.unwrap_or_else(|| TextElem::packed(text));
|
||||||
|
|
||||||
if block {
|
if block {
|
||||||
HtmlElem::new(tag::pre)
|
HtmlElem::new(tag::pre)
|
||||||
@ -207,14 +224,25 @@ fn highlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Highlights `source` as `lang`.
|
/// Highlights `source` as `lang`.
|
||||||
fn highlight_code(source: &str, lang: Option<&str>) -> Option<Content> {
|
fn highlight_code(
|
||||||
let registry = REGISTRY.get()?;
|
source: &str,
|
||||||
let language = *registry.by_name.get(lang?)?;
|
lang: Option<&str>,
|
||||||
|
light: Option<&str>,
|
||||||
|
dark: Option<&str>,
|
||||||
|
) -> Option<Content> {
|
||||||
|
let (lang, light, dark) = (lang?, light?, dark?);
|
||||||
|
let Some(scopes) = crate::themes::shared_scopes(light, dark) else {
|
||||||
|
warn!("Unknown theme in pair (light={light}, dark={dark})");
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let registry = registry_for(scopes)?;
|
||||||
|
let language = registry.language(lang)?;
|
||||||
|
|
||||||
let rope = Rope::from_str(source);
|
let rope = Rope::from_str(source);
|
||||||
let slice = rope.slice(..);
|
let slice = rope.slice(..);
|
||||||
let syntax = Syntax::new(slice, language, Duration::from_millis(500), registry).ok()?;
|
let loader = registry.as_ref();
|
||||||
let mut hl = Highlighter::new(&syntax, slice, registry, 0..source.len() as u32);
|
let syntax = Syntax::new(slice, language, Duration::from_secs(3), loader).ok()?;
|
||||||
|
let mut hl = Highlighter::new(&syntax, slice, loader, 0..source.len() as u32);
|
||||||
|
|
||||||
let src = source.as_bytes();
|
let src = source.as_bytes();
|
||||||
let src_len = source.len() as u32;
|
let src_len = source.len() as u32;
|
||||||
@ -251,7 +279,7 @@ fn highlight_code(source: &str, lang: Option<&str>) -> Option<Content> {
|
|||||||
HighlightEvent::Push => classes.clone(),
|
HighlightEvent::Push => classes.clone(),
|
||||||
};
|
};
|
||||||
classes = highlights.fold(base, |mut acc, h| {
|
classes = highlights.fold(base, |mut acc, h| {
|
||||||
let name = HIGHLIGHT_NAMES[h.idx()].replace('.', "-");
|
let name = registry.scopes[h.idx()].replace('.', "-");
|
||||||
if !acc.is_empty() {
|
if !acc.is_empty() {
|
||||||
acc.push(' ');
|
acc.push(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
336
src/themes.rs
Normal file
336
src/themes.rs
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
use std::{
|
||||||
|
collections::{BTreeSet, HashMap, HashSet},
|
||||||
|
fs,
|
||||||
|
path::Path,
|
||||||
|
sync::OnceLock,
|
||||||
|
};
|
||||||
|
|
||||||
|
use toml::{Table, Value};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use typst::foundations::{Func, NativeFunc, func};
|
||||||
|
|
||||||
|
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)| match Theme::from_toml(table) {
|
||||||
|
Some(theme) => Some((name, theme)),
|
||||||
|
None => {
|
||||||
|
warn!("Theme {name} is missing an `ui.background`, skipping it");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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 if let Some(color) = palette.get(color) {
|
||||||
|
Some(color.clone())
|
||||||
|
} else {
|
||||||
|
// Neither a hex literal nor a palette entry; assume a named CSS
|
||||||
|
// colour and pass it through verbatim.
|
||||||
|
warn!("Unresolved colour {color}, passing it through as a literal");
|
||||||
|
Some(Self(color.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The intersection of scopes between two themes.
|
||||||
|
pub fn shared_scopes(light: &str, dark: &str) -> Option<BTreeSet<String>> {
|
||||||
|
let registry = REGISTRY.get()?;
|
||||||
|
let light = registry.themes.get(light)?;
|
||||||
|
let dark = registry.themes.get(dark)?;
|
||||||
|
Some(
|
||||||
|
light
|
||||||
|
.styles
|
||||||
|
.keys()
|
||||||
|
.filter(|k| dark.styles.contains_key(*k))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rules = Vec::new();
|
||||||
|
|
||||||
|
// The theme's editor foreground/background colour the code block itself.
|
||||||
|
let mut block_style = Vec::new();
|
||||||
|
if let Some(Color(hex)) = &theme.foreground {
|
||||||
|
block_style.push(format!("color:{hex}"));
|
||||||
|
}
|
||||||
|
if let Some(Color(hex)) = &theme.background {
|
||||||
|
block_style.push(format!("background:{hex}"));
|
||||||
|
}
|
||||||
|
if !block_style.is_empty() {
|
||||||
|
rules.push(format!(".code-block{{{}}}", block_style.join(";")));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut group_rules: Vec<String> = groups
|
||||||
|
.iter()
|
||||||
|
.map(|(style, scopes)| {
|
||||||
|
let selectors = scopes
|
||||||
|
.iter()
|
||||||
|
.map(|s| scope_to_selector(s))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
format!("{}{{{}}}", selectors, style_declarations(style))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// Sort so the emitted stylesheet is deterministic across runs.
|
||||||
|
group_rules.sort();
|
||||||
|
rules.extend(group_rules);
|
||||||
|
|
||||||
|
rules.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 shared = shared_scopes(&light, &dark).unwrap_or_default();
|
||||||
|
let shared: Vec<&str> = shared.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
|
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}}}")
|
||||||
|
}
|
||||||
@ -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())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user