use super::{Formatter, HtmlElement};
use crate::decorations::{LineSelection, SteppedLineRange};
use crate::events::HighlightEvent;
use crate::formatter::html_inline::HighlightLines;
use crate::languages::Language;
use crate::themes::Theme;
use derive_builder::Builder;
use std::collections::HashMap;
use std::io::{self, Write};
use std::str::FromStr;
#[derive(Clone, Debug)]
pub enum DefaultTheme {
Theme(String),
LightDark,
}
impl FromStr for DefaultTheme {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"light-dark()" => DefaultTheme::LightDark,
theme_name => DefaultTheme::Theme(theme_name.to_string()),
})
}
}
#[derive(Builder, Clone, Debug)]
#[builder(default, build_fn(skip))]
pub struct HtmlMultiThemes {
#[builder(setter(custom))]
language: Language,
structure: crate::formatter::html::HtmlStructure,
themes: HashMap<String, Theme>,
#[builder(setter(custom))]
default_theme: Option<DefaultTheme>,
#[builder(setter(into))]
css_variable_prefix: String,
pre_class: Option<String>,
pre_attrs: crate::formatter::html::HtmlAttrs,
code_attrs: crate::formatter::html::HtmlAttrs,
#[builder(setter(custom))]
italic: bool,
include_highlights: bool,
highlight_lines: Option<HighlightLines>,
#[builder(setter(skip), default)]
stepped_highlight_lines: Vec<SteppedLineRange>,
line_numbers: bool,
header: Option<HtmlElement>,
}
impl HtmlMultiThemesBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn language(&mut self, language: Language) -> &mut Self {
self.language = Some(language);
self
}
#[doc(hidden)]
#[deprecated(
since = "4.2.0",
note = "customize theme styles with Theme::map_styles instead"
)]
pub fn italic(&mut self, italic: bool) -> &mut Self {
self.italic = Some(italic);
self
}
#[deprecated(since = "0.1.0", note = "use `.language(...)` instead")]
pub fn lang(&mut self, language: Language) -> &mut Self {
self.language(language)
}
pub fn default_theme<T: Into<DefaultThemeArg>>(&mut self, value: T) -> &mut Self {
self.default_theme = Some(value.into().into_enum());
self
}
pub fn build(&mut self) -> Result<HtmlMultiThemes, String> {
let result = HtmlMultiThemes {
language: self.language.take().unwrap_or(Language::PlainText),
structure: self.structure.take().unwrap_or_default(),
themes: self.themes.take().unwrap_or_default(),
default_theme: self.default_theme.take().flatten(),
css_variable_prefix: self
.css_variable_prefix
.take()
.unwrap_or_else(|| "--lumis".to_string()),
pre_class: self.pre_class.take().flatten(),
pre_attrs: self.pre_attrs.take().unwrap_or_default(),
code_attrs: self.code_attrs.take().unwrap_or_default(),
italic: self.italic.take().unwrap_or(true),
include_highlights: self.include_highlights.take().unwrap_or(false),
highlight_lines: self.highlight_lines.take().flatten(),
stepped_highlight_lines: Vec::new(),
line_numbers: self.line_numbers.take().unwrap_or(false),
header: self.header.take().flatten(),
};
if result.themes.is_empty() {
return Err("At least one theme is required".to_string());
}
match &result.default_theme {
Some(DefaultTheme::Theme(name)) if !result.themes.contains_key(name) => {
return Err(format!("Default theme '{name}' not found in themes map"));
}
Some(DefaultTheme::LightDark)
if !result.themes.contains_key("light") || !result.themes.contains_key("dark") =>
{
return Err("LightDark mode requires themes named 'light' and 'dark'".to_string());
}
_ => {}
}
Ok(result)
}
}
#[doc(hidden)]
pub enum DefaultThemeArg {
String(String),
Bool(bool),
}
impl DefaultThemeArg {
fn into_enum(self) -> Option<DefaultTheme> {
match self {
DefaultThemeArg::String(s) => Some(s.parse().unwrap()),
DefaultThemeArg::Bool(false) => None,
DefaultThemeArg::Bool(true) => Some(DefaultTheme::Theme("light".to_string())),
}
}
}
impl From<&str> for DefaultThemeArg {
fn from(s: &str) -> Self {
DefaultThemeArg::String(s.to_string())
}
}
impl From<String> for DefaultThemeArg {
fn from(s: String) -> Self {
DefaultThemeArg::String(s)
}
}
impl From<bool> for DefaultThemeArg {
fn from(b: bool) -> Self {
DefaultThemeArg::Bool(b)
}
}
impl Default for HtmlMultiThemes {
fn default() -> Self {
Self {
language: Language::PlainText,
structure: crate::formatter::html::HtmlStructure::Block,
themes: HashMap::new(),
default_theme: None,
css_variable_prefix: "--lumis".to_string(),
pre_class: None,
pre_attrs: Vec::new(),
code_attrs: Vec::new(),
italic: true,
include_highlights: false,
highlight_lines: None,
stepped_highlight_lines: Vec::new(),
line_numbers: false,
header: None,
}
}
}
impl HtmlMultiThemes {
#[allow(clippy::too_many_arguments)]
pub fn new(
language: Language,
themes: HashMap<String, Theme>,
default_theme: Option<DefaultTheme>,
css_variable_prefix: String,
pre_class: Option<String>,
include_highlights: bool,
highlight_lines: Option<HighlightLines>,
line_numbers: bool,
header: Option<HtmlElement>,
) -> Self {
Self {
language,
structure: crate::formatter::html::HtmlStructure::Block,
themes,
default_theme,
css_variable_prefix,
pre_class,
pre_attrs: Vec::new(),
code_attrs: Vec::new(),
italic: true,
include_highlights,
highlight_lines,
stepped_highlight_lines: Vec::new(),
line_numbers,
header,
}
}
#[doc(hidden)]
pub fn set_stepped_highlight_lines(&mut self, lines: Vec<SteppedLineRange>) {
self.stepped_highlight_lines = lines;
}
fn line_selection(&self) -> LineSelection {
LineSelection::new(
self.highlight_lines
.as_ref()
.map_or(&[][..], |highlight| &highlight.lines),
&self.stepped_highlight_lines,
)
}
fn open_pre_tag(
&self,
output: &mut dyn Write,
exhausted: Option<super::BudgetExhausted>,
) -> io::Result<()> {
crate::formatter::html::write_multi_themes_pre_tag(
output,
self.pre_class.as_deref(),
&self.themes,
self.default_theme_name(),
&self.css_variable_prefix,
&crate::formatter::html::budget_attrs(&self.pre_attrs, exhausted),
)
}
fn default_theme_name(&self) -> Option<&str> {
match &self.default_theme {
Some(DefaultTheme::Theme(name)) => Some(name.as_str()),
Some(DefaultTheme::LightDark) => Some("light-dark()"),
None => None,
}
}
fn get_line_attrs(&self, is_highlighted: bool) -> (Option<String>, Option<String>) {
if !is_highlighted {
return (None, None);
}
let class_suffix = self
.highlight_lines
.as_ref()
.and_then(|hl| hl.class.as_ref())
.map(|c| format!(" {c}"));
let style = Some(format!(
"{}{}",
crate::formatter::html::LINE_LAYOUT,
self.get_highlight_style()
.map_or(String::new(), |style| format!(" {style}")),
));
(class_suffix, style)
}
fn get_highlight_style(&self) -> Option<String> {
use crate::formatter::html_inline::HighlightLinesStyle;
let highlight_lines = self.highlight_lines.as_ref()?;
match &highlight_lines.style {
Some(HighlightLinesStyle::Theme) => match &self.default_theme {
Some(DefaultTheme::Theme(default_name)) => {
let theme = self.themes.get(default_name)?;
let highlighted_style = theme.get_style("highlighted")?;
Some(highlighted_style.css(" "))
}
Some(DefaultTheme::LightDark) => self.light_dark_highlight_style(),
None => None,
},
Some(HighlightLinesStyle::Style(style_string)) => Some(style_string.clone()),
None => None,
}
}
fn light_dark_highlight_style(&self) -> Option<String> {
let light = self.themes.get("light")?.get_style("highlighted")?;
let dark = self.themes.get("dark")?.get_style("highlighted")?;
let light_bg = light.bg.as_ref()?;
let dark_bg = dark.bg.as_ref()?;
Some(format!(
"background-color: light-dark({light_bg}, {dark_bg});"
))
}
fn span_attrs_from_index(&self, scope_index: usize, language: &str) -> String {
let scope = crate::highlights::HIGHLIGHT_NAMES
.get(scope_index)
.copied()
.unwrap_or("");
let lang = language.parse::<Language>().ok();
crate::formatter::html::span_multi_themes_attrs(
scope,
lang,
&self.themes,
self.default_theme_name(),
&self.css_variable_prefix,
self.include_highlights,
)
}
fn line_number_attrs(&self, highlighted: bool) -> String {
let scope = if highlighted {
"line_number.highlighted"
} else {
"line_number"
};
crate::formatter::html::unselectable_gutter(
&crate::formatter::html::span_multi_themes_attrs(
scope,
None,
&self.themes,
self.default_theme_name(),
&self.css_variable_prefix,
false,
),
)
}
}
impl<T> Formatter<T> for HtmlMultiThemes {
fn language(&self) -> Language {
self.language
}
fn render(
&self,
source: &str,
events: &[HighlightEvent<'_, T>],
output: &mut dyn Write,
) -> io::Result<()> {
self.write(source, events, output, None)
}
fn render_budgeted(
&self,
source: &str,
events: &[HighlightEvent<'_, T>],
output: &mut dyn Write,
exhausted: super::BudgetExhausted,
) -> io::Result<()> {
self.write(source, events, output, Some(exhausted))
}
}
impl HtmlMultiThemes {
fn write<T>(
&self,
source: &str,
events: &[HighlightEvent<'_, T>],
output: &mut dyn Write,
exhausted: Option<super::BudgetExhausted>,
) -> io::Result<()> {
if !self.italic {
let upright = Self {
themes: self
.themes
.iter()
.map(|(name, theme)| (name.clone(), theme.clone().without_italics()))
.collect(),
italic: true,
..self.clone()
};
return upright.write(source, events, output, exhausted);
}
if self.structure == crate::formatter::html::HtmlStructure::Inline {
return crate::formatter::html::write_html_spans(
output,
source,
events,
self.language,
&|scope_index, language| self.span_attrs_from_index(scope_index, language),
);
}
if let Some(ref header) = self.header {
write!(output, "{}", header.open_tag)?;
}
self.open_pre_tag(output, exhausted)?;
let code_attrs = if self.highlight_lines.is_some() {
crate::formatter::html::merge_attrs(
vec![("style".into(), crate::formatter::html::CODE_LAYOUT.into())],
&self.code_attrs,
)
} else {
self.code_attrs.clone()
};
crate::formatter::html::write_code_tag(output, self.language, &code_attrs)?;
let (class_suffix, style) = self.get_line_attrs(true);
let line_number_attrs = self.line_numbers.then(|| self.line_number_attrs(false));
let highlighted_line_number_attrs = self.line_numbers.then(|| self.line_number_attrs(true));
crate::formatter::html::write_html_lines(
output,
source,
events,
&crate::formatter::html::HtmlLines {
structure: crate::formatter::html::HtmlStructure::Block,
language: self.language,
selection: &self.line_selection(),
numbered: self.line_numbers,
line_number_attrs: line_number_attrs.as_deref(),
highlighted_line_number_attrs: highlighted_line_number_attrs.as_deref(),
highlighted_class: class_suffix.as_deref(),
highlighted_style: style.as_deref(),
empty_style: Some(crate::formatter::html::LINE_LAYOUT),
},
&|scope_index, language| self.span_attrs_from_index(scope_index, language),
)?;
crate::formatter::html::closing_tags(output)?;
if let Some(ref header) = self.header {
write!(output, "{}", header.close_tag)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn attr_value<'a>(tag: &'a str, attr: &str) -> &'a str {
let marker = format!(r#"{attr}=""#);
let start = tag.find(&marker).expect("missing attribute") + marker.len();
let end = tag[start..].find('"').expect("unterminated attribute");
&tag[start..start + end]
}
fn assert_classes(tag: &str, expected: &[&str]) {
let classes: std::collections::HashSet<&str> =
attr_value(tag, "class").split(' ').collect();
assert_eq!(classes.len(), expected.len());
for class in expected {
assert!(classes.contains(class), "missing class {class:?} in {tag}");
}
}
#[test]
fn render_uses_multi_themes_pre_tag_helper() {
let mut themes = HashMap::new();
themes.insert(
"light".to_string(),
crate::themes::get("catppuccin_latte").unwrap(),
);
themes.insert(
"dark".to_string(),
crate::themes::get("catppuccin_mocha").unwrap(),
);
let formatter = HtmlMultiThemes::new(
Language::PlainText,
themes,
Some(DefaultTheme::LightDark),
"--lumis".to_string(),
Some("custom-pre".to_string()),
false,
None,
false,
None,
);
let mut output = Vec::new();
let events: [HighlightEvent<'_, ()>; 0] = [];
formatter.render("", &events, &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
let pre_tag = html.split_once('>').expect("missing pre tag").0;
assert_classes(
pre_tag,
&[
"lumis",
"lumis-themes",
"custom-pre",
"catppuccin_latte",
"catppuccin_mocha",
],
);
assert_eq!(
attr_value(pre_tag, "style"),
"color: light-dark(#4c4f69, #cdd6f4); background-color: light-dark(#eff1f5, #1e1e2e);"
);
}
fn render_highlighted_line(
default_theme: DefaultTheme,
light_background: Option<&str>,
dark_background: Option<&str>,
) -> String {
use crate::formatter::html_inline::{HighlightLines, HighlightLinesStyle};
let mut themes = HashMap::new();
let mut light = crate::themes::get("catppuccin_latte").unwrap();
let light_highlighted = light.highlights.get_mut("highlighted").unwrap();
light_highlighted.fg = Some("#111111".to_string());
light_highlighted.bg = light_background.map(str::to_string);
themes.insert("light".to_string(), light);
let mut dark = crate::themes::get("catppuccin_mocha").unwrap();
let dark_highlighted = dark.highlights.get_mut("highlighted").unwrap();
dark_highlighted.fg = Some("#eeeeee".to_string());
dark_highlighted.bg = dark_background.map(str::to_string);
themes.insert("dark".to_string(), dark);
let formatter = HtmlMultiThemes::new(
Language::PlainText,
themes,
Some(default_theme),
"--lumis".to_string(),
None,
false,
Some(HighlightLines {
lines: std::iter::once(1..=1).collect(),
style: Some(HighlightLinesStyle::Theme),
class: None,
}),
false,
None,
);
let mut output = Vec::new();
Formatter::<()>::render(&formatter, "one\ntwo", &[], &mut output).unwrap();
let html = String::from_utf8(output).unwrap();
let line_tag = html
.split_once("<span ")
.expect("missing first line")
.1
.split_once('>')
.expect("unterminated line tag")
.0;
assert!(
line_tag.contains(r#"data-line="1""#),
"wrong line: {line_tag}"
);
line_tag.to_string()
}
#[test]
fn theme_highlight_lines_follow_the_colour_scheme() {
let line_tag =
render_highlighted_line(DefaultTheme::LightDark, Some("#e9ebf1"), Some("#2a2b3c"));
assert_eq!(
attr_value(&line_tag, "style"),
"display: inline-block; width: 100%; min-height: 1lh; vertical-align: top; background-color: light-dark(#e9ebf1, #2a2b3c);"
);
}
#[test]
fn light_dark_theme_highlight_lines_require_a_light_background() {
let line_tag = render_highlighted_line(DefaultTheme::LightDark, None, Some("#2a2b3c"));
assert_eq!(
attr_value(&line_tag, "style"),
crate::formatter::html::LINE_LAYOUT
);
}
#[test]
fn light_dark_theme_highlight_lines_require_a_dark_background() {
let line_tag = render_highlighted_line(DefaultTheme::LightDark, Some("#e9ebf1"), None);
assert_eq!(
attr_value(&line_tag, "style"),
crate::formatter::html::LINE_LAYOUT
);
}
#[test]
fn theme_highlight_lines_use_the_named_default_theme() {
let line_tag = render_highlighted_line(
DefaultTheme::Theme("dark".to_string()),
Some("#e9ebf1"),
Some("#2a2b3c"),
);
assert_eq!(
attr_value(&line_tag, "style"),
"display: inline-block; width: 100%; min-height: 1lh; vertical-align: top; color: #eeeeee; background-color: #2a2b3c;"
);
}
}