//! Syntax highlighting via syntect, flattened to colored spans per line.
//!
//! The code area is drawn as SVG text, so this hands back concrete colors
//! rather than CSS classes; one renderer then serves the preview, the embed,
//! and both export formats.
use std::sync::LazyLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::{Color, FontStyle, Theme, ThemeSet};
use syntect::parsing::{SyntaxReference, SyntaxSet};
use syntect::util::LinesWithEndings;
use super::palette::{Palette, Rgb};
static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
static THEMES: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
pub const TAB_WIDTH: usize = 4;
/// A selectable highlight theme. `solarized` is the default and resolves to
/// its light or dark variant from the chrome palette, so a deck switching
/// schemes flips the code area along with the window.
#[derive(Clone, Copy, Debug, serde::Serialize)]
pub struct HlTheme {
pub id: &'static str,
pub name: &'static str,
#[serde(skip)]
syntect: Option<&'static str>,
}
pub static HL_THEMES: &[HlTheme] = &[
HlTheme { id: "solarized", name: "Solarized (follows palette)", syntect: None },
HlTheme { id: "solarized-dark", name: "Solarized dark", syntect: Some("Solarized (dark)") },
HlTheme { id: "solarized-light", name: "Solarized light", syntect: Some("Solarized (light)") },
HlTheme { id: "base16-ocean-dark", name: "Base16 Ocean dark", syntect: Some("base16-ocean.dark") },
HlTheme { id: "base16-ocean-light", name: "Base16 Ocean light", syntect: Some("base16-ocean.light") },
HlTheme { id: "base16-eighties", name: "Base16 Eighties", syntect: Some("base16-eighties.dark") },
HlTheme { id: "base16-mocha", name: "Base16 Mocha", syntect: Some("base16-mocha.dark") },
HlTheme { id: "inspired-github", name: "InspiredGitHub", syntect: Some("InspiredGitHub") },
];
pub const DEFAULT_HL: &str = "solarized";
pub fn hl_theme(id: &str) -> Option<&'static HlTheme> {
HL_THEMES.iter().find(|t| t.id == id)
}
fn resolve_theme(id: &str, palette_dark: bool) -> &'static Theme {
let name = hl_theme(id)
.and_then(|t| t.syntect)
.unwrap_or(if palette_dark { "Solarized (dark)" } else { "Solarized (light)" });
&THEMES.themes[name]
}
/// A language choice: syntect's syntax name is the stored id.
#[derive(Clone, Debug, serde::Serialize)]
pub struct Language {
pub id: String,
pub name: String,
}
/// Languages offered in the picker, alphabetised, hidden syntaxes dropped.
pub fn languages() -> &'static [Language] {
static LANGS: LazyLock<Vec<Language>> = LazyLock::new(|| {
let mut v: Vec<Language> = SYNTAXES
.syntaxes()
.iter()
.filter(|s| !s.hidden && !s.file_extensions.is_empty())
.map(|s| Language { id: s.name.clone(), name: s.name.clone() })
.collect();
v.sort_by_key(|l| l.name.to_lowercase());
v.dedup_by(|a, b| a.id == b.id);
v
});
&LANGS
}
pub const AUTO_LANG: &str = "auto";
fn find_syntax(lang: &str, code: &str) -> &'static SyntaxReference {
let ss = &*SYNTAXES;
if lang != AUTO_LANG
&& let Some(s) = ss.find_syntax_by_name(lang).or_else(|| ss.find_syntax_by_token(lang)) {
return s;
}
code.lines()
.next()
.and_then(|first| ss.find_syntax_by_first_line(first))
.unwrap_or_else(|| ss.find_syntax_plain_text())
}
#[derive(Clone, Debug, PartialEq)]
pub struct Span {
pub text: String,
pub fg: Rgb,
pub bold: bool,
pub italic: bool,
}
#[derive(Clone, Debug)]
pub struct Highlighted {
pub background: Rgb,
pub foreground: Rgb,
pub gutter: Rgb,
pub lines: Vec<Vec<Span>>,
}
fn rgb(c: Color) -> Rgb {
Rgb(c.r, c.g, c.b)
}
/// Replace tabs with spaces up to the next tab stop, so columns line up in a
/// monospace grid regardless of the renderer's own tab handling.
fn expand_tabs(line: &str, col: &mut usize) -> String {
let mut out = String::with_capacity(line.len());
for ch in line.chars() {
if ch == '\t' {
let n = TAB_WIDTH - (*col % TAB_WIDTH);
out.extend(std::iter::repeat_n(' ', n));
*col += n;
} else {
out.push(ch);
*col += unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
}
}
out
}
/// Solarized's greys (and any other near-neutral token color) are the ones
/// that carry its background's tone; the accents read on any surface.
fn is_neutral(c: Rgb) -> bool {
let (hi, lo) = (c.0.max(c.1).max(c.2), c.0.min(c.1).min(c.2));
hi - lo < 48
}
pub fn highlight(code: &str, lang: &str, hl: &str, palette: &Palette) -> Highlighted {
let theme = resolve_theme(hl, palette.dark);
// A palette with its own surface repaints the follow-the-palette theme:
// its fill, and the neutral text tones re-derived from its content color.
let surface = palette.surface.filter(|_| hl_theme(hl).is_some_and(|t| t.syntect.is_none()));
let syntax = find_syntax(lang, code);
let mut h = HighlightLines::new(syntax, theme);
let background = theme.settings.background.map(rgb).unwrap_or(Rgb(0x1e, 0x1e, 0x1e));
let foreground = theme.settings.foreground.map(rgb).unwrap_or(Rgb(0xdd, 0xdd, 0xdd));
let gutter = theme
.settings
.gutter_foreground
.map(rgb)
.unwrap_or_else(|| foreground.mix(background, 0.55));
let theme_fg = foreground;
let (background, foreground, gutter) = match surface {
Some((bg, fg)) => (bg, fg.mix(bg, 0.1), fg.mix(bg, 0.55)),
None => (background, foreground, gutter),
};
let recolor = |c: Rgb| match surface {
Some(_) if c == theme_fg => foreground,
Some((bg, fg)) if is_neutral(c) => fg.mix(bg, 0.45),
_ => c,
};
let normalized = code.replace("\r\n", "\n");
let mut lines = Vec::new();
for line in LinesWithEndings::from(&normalized) {
let mut spans: Vec<Span> = Vec::new();
let mut col = 0usize;
let ranges = h.highlight_line(line, &SYNTAXES).unwrap_or_default();
for (style, piece) in ranges {
let piece = piece.trim_end_matches(['\n', '\r']);
if piece.is_empty() {
continue;
}
let text = expand_tabs(piece, &mut col);
let span = Span {
text,
fg: recolor(rgb(style.foreground)),
bold: style.font_style.contains(FontStyle::BOLD),
italic: style.font_style.contains(FontStyle::ITALIC),
};
// Coalesce neighbours of the same style to keep the SVG small.
match spans.last_mut() {
Some(prev) if prev.fg == span.fg && prev.bold == span.bold && prev.italic == span.italic => {
prev.text.push_str(&span.text)
}
_ => spans.push(span),
}
}
lines.push(spans);
}
if lines.is_empty() {
lines.push(Vec::new());
}
Highlighted { background, foreground, gutter, lines }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::palette;
#[test]
fn solarized_follows_palette() {
let pal = |id| palette::get(id).unwrap();
let dark = highlight("fn main() {}", "Rust", "solarized", pal("breeze-dark"));
let light = highlight("fn main() {}", "Rust", "solarized", pal("oxygen"));
assert_eq!(dark.background, Rgb::hex(0x002b36));
assert_eq!(light.background, Rgb::hex(0xfdf6e3));
}
#[test]
fn neiam_palettes_fill_with_their_own_surface() {
let her = palette::get("her").unwrap();
let h = highlight("// hi\nfn main() {}", "Rust", "solarized", her);
assert_eq!(h.background, Rgb::hex(0x661e1e));
// An explicit theme keeps its own background.
let h = highlight("fn main() {}", "Rust", "solarized-dark", her);
assert_eq!(h.background, Rgb::hex(0x002b36));
}
#[test]
fn tabs_expand_to_stops() {
let h = highlight("a\tb\n\tc", "Plain Text", "solarized-dark", palette::get("breeze").unwrap());
let line0: String = h.lines[0].iter().map(|s| s.text.as_str()).collect();
let line1: String = h.lines[1].iter().map(|s| s.text.as_str()).collect();
assert_eq!(line0, "a b");
assert_eq!(line1, " c");
}
#[test]
fn every_hl_theme_resolves() {
for t in HL_THEMES {
resolve_theme(t.id, true);
resolve_theme(t.id, false);
}
}
}
neiam
/bismuth
MOTHER-FUCKING-BISMUTH
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
8.4 KiB · text
History
487bb12