use std::{collections::HashMap, fmt, io::Write};
use handlebars::{Handlebars, RenderError, RenderErrorReason, Template as HandlebarsTemplate};
use serde::{Deserialize, Serialize};
use self::{data::CompleteHandlebarsData, helpers::register_helpers};
pub use self::{
data::{CreatorData, HandlebarsData, SerializedInteraction},
palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors},
};
pub use crate::utils::{RgbColor, RgbColorParseError};
use crate::{write::SvgLine, TermError, Transcript};
mod data;
mod helpers;
mod palette;
#[cfg(test)]
mod tests;
const DEFAULT_TEMPLATE: &str = include_str!("default.svg.handlebars");
const PURE_TEMPLATE: &str = include_str!("pure.svg.handlebars");
const MAIN_TEMPLATE_NAME: &str = "main";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LineNumbers {
EachOutput,
ContinuousOutputs,
Continuous,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateOptions {
#[serde(default = "TemplateOptions::default_width")]
pub width: usize,
#[serde(default)]
pub palette: Palette,
#[serde(skip_serializing_if = "str::is_empty", default)]
pub additional_styles: String,
#[serde(default = "TemplateOptions::default_font_family")]
pub font_family: String,
#[serde(default)]
pub window_frame: bool,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub scroll: Option<ScrollOptions>,
#[serde(default = "TemplateOptions::default_wrap")]
pub wrap: Option<WrapOptions>,
#[serde(default)]
pub line_numbers: Option<LineNumbers>,
}
impl Default for TemplateOptions {
fn default() -> Self {
Self {
width: Self::default_width(),
palette: Palette::default(),
additional_styles: String::new(),
font_family: Self::default_font_family(),
window_frame: false,
scroll: None,
wrap: Self::default_wrap(),
line_numbers: None,
}
}
}
impl TemplateOptions {
fn default_width() -> usize {
720
}
fn default_font_family() -> String {
"SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned()
}
#[allow(clippy::unnecessary_wraps)] fn default_wrap() -> Option<WrapOptions> {
Some(WrapOptions::default())
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip(transcript), err)
)]
pub fn render_data<'s>(
&'s self,
transcript: &'s Transcript,
) -> Result<HandlebarsData<'s>, TermError> {
let rendered_outputs = self.render_outputs(transcript)?;
let mut has_failures = false;
let interactions: Vec<_> = transcript
.interactions()
.iter()
.zip(rendered_outputs)
.map(|(interaction, (output_html, output_svg))| {
let failure = interaction
.exit_status()
.is_some_and(|status| !status.is_success());
has_failures = has_failures || failure;
SerializedInteraction {
input: interaction.input(),
output_html,
output_svg,
exit_status: interaction.exit_status().map(|status| status.0),
failure,
}
})
.collect();
Ok(HandlebarsData {
creator: CreatorData::default(),
interactions,
options: self,
has_failures,
})
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(level = "debug", skip_all, err)
)]
fn render_outputs(
&self,
transcript: &Transcript,
) -> Result<Vec<(String, Vec<SvgLine>)>, TermError> {
let max_width = self.wrap.as_ref().map(|wrap_options| match wrap_options {
WrapOptions::HardBreakAt(width) => *width,
});
transcript
.interactions
.iter()
.map(|interaction| {
let output = interaction.output();
let mut buffer = String::with_capacity(output.as_ref().len());
output.write_as_html(&mut buffer, max_width)?;
let svg_lines = output.write_as_svg(max_width)?;
Ok((buffer, svg_lines))
})
.collect()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScrollOptions {
pub max_height: usize,
pub pixels_per_scroll: usize,
pub interval: f32,
}
impl Default for ScrollOptions {
fn default() -> Self {
const DEFAULT_LINE_HEIGHT: usize = 18; Self {
max_height: DEFAULT_LINE_HEIGHT * 19,
pixels_per_scroll: DEFAULT_LINE_HEIGHT * 4,
interval: 4.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum WrapOptions {
HardBreakAt(usize),
}
impl Default for WrapOptions {
fn default() -> Self {
Self::HardBreakAt(80)
}
}
pub struct Template {
options: TemplateOptions,
handlebars: Handlebars<'static>,
constants: HashMap<&'static str, u32>,
}
impl fmt::Debug for Template {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Template")
.field("options", &self.options)
.field("constants", &self.constants)
.finish_non_exhaustive()
}
}
impl Default for Template {
fn default() -> Self {
Self::new(TemplateOptions::default())
}
}
impl Template {
const STD_CONSTANTS: &'static [(&'static str, u32)] = &[
("BLOCK_MARGIN", 6),
("USER_INPUT_PADDING", 4),
("WINDOW_PADDING", 10),
("LINE_HEIGHT", 18),
("WINDOW_FRAME_HEIGHT", 22),
("SCROLLBAR_RIGHT_OFFSET", 7),
("SCROLLBAR_HEIGHT", 40),
];
const PURE_SVG_CONSTANTS: &'static [(&'static str, u32)] = &[
("USER_INPUT_PADDING", 2), ("LN_WIDTH", 24),
("LN_PADDING", 8),
];
#[allow(clippy::missing_panics_doc)] pub fn new(options: TemplateOptions) -> Self {
let template = HandlebarsTemplate::compile(DEFAULT_TEMPLATE)
.expect("Default template should be valid");
Self {
constants: Self::STD_CONSTANTS.iter().copied().collect(),
..Self::custom(template, options)
}
}
#[allow(clippy::missing_panics_doc)] pub fn pure_svg(options: TemplateOptions) -> Self {
let template =
HandlebarsTemplate::compile(PURE_TEMPLATE).expect("Pure template should be valid");
Self {
constants: Self::STD_CONSTANTS
.iter()
.chain(Self::PURE_SVG_CONSTANTS)
.copied()
.collect(),
..Self::custom(template, options)
}
}
pub fn custom(template: HandlebarsTemplate, options: TemplateOptions) -> Self {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
register_helpers(&mut handlebars);
handlebars.register_template(MAIN_TEMPLATE_NAME, template);
Self {
options,
handlebars,
constants: HashMap::new(),
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(skip_all, err, fields(self.options = ?self.options))
)]
pub fn render<W: Write>(
&self,
transcript: &Transcript,
destination: W,
) -> Result<(), RenderError> {
let data = self
.options
.render_data(transcript)
.map_err(|err| RenderErrorReason::NestedError(Box::new(err)))?;
let data = CompleteHandlebarsData {
inner: data,
constants: &self.constants,
};
#[cfg(feature = "tracing")]
let _entered = tracing::debug_span!("render_to_write").entered();
self.handlebars
.render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
}
}