use handlebars::{Handlebars, RenderError};
use serde::{Deserialize, Serialize};
use std::{fmt::Write as _, io::Write};
mod palette;
pub use self::palette::{NamedPalette, NamedPaletteParseError, Palette, TermColors};
pub use crate::utils::{RgbColor, RgbColorParseError};
use crate::{TermError, Transcript, UserInput};
const MAIN_TEMPLATE_NAME: &str = "main";
const TEMPLATE: &str = include_str!("default.svg.handlebars");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateOptions {
pub width: usize,
pub palette: Palette,
pub font_family: String,
pub window_frame: bool,
pub scroll: Option<ScrollOptions>,
pub wrap: Option<WrapOptions>,
}
impl Default for TemplateOptions {
fn default() -> Self {
Self {
width: 720,
palette: Palette::default(),
font_family: "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned(),
window_frame: false,
scroll: None,
wrap: Some(WrapOptions::default()),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ScrollOptions {
pub max_height: usize,
pub interval: f32,
}
impl Default for ScrollOptions {
fn default() -> Self {
Self {
max_height: Template::LINE_HEIGHT * 19,
interval: 4.0,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
pub enum WrapOptions {
HardBreakAt(usize),
}
impl Default for WrapOptions {
fn default() -> Self {
Self::HardBreakAt(80)
}
}
#[derive(Debug)]
pub struct Template {
options: TemplateOptions,
handlebars: Handlebars<'static>,
}
impl Default for Template {
fn default() -> Self {
Self::new(TemplateOptions::default())
}
}
impl Template {
const BLOCK_MARGIN: usize = 6;
const USER_INPUT_PADDING: usize = 4;
const WINDOW_PADDING: usize = 10;
const LINE_HEIGHT: usize = 18;
const WINDOW_FRAME_HEIGHT: usize = 22;
const PIXELS_PER_SCROLL: usize = Self::LINE_HEIGHT * 4;
const SCROLLBAR_RIGHT_OFFSET: usize = 7;
const SCROLLBAR_HEIGHT: usize = 40;
pub fn new(options: TemplateOptions) -> Self {
let mut handlebars = Handlebars::new();
handlebars.set_strict_mode(true);
handlebars
.register_template_string(MAIN_TEMPLATE_NAME, TEMPLATE)
.expect("Default template should be valid");
Self {
options,
handlebars,
}
}
pub fn render<W: Write>(
&self,
transcript: &Transcript,
destination: W,
) -> Result<(), RenderError> {
let rendered_outputs = self
.render_outputs(transcript)
.map_err(|err| RenderError::from_error("content", err))?;
let content_height = Self::compute_content_height(transcript, &rendered_outputs);
let scroll_animation = self.scroll_animation(content_height);
let screen_height = if scroll_animation.is_some() {
self.options
.scroll
.as_ref()
.map_or(content_height, |scroll| scroll.max_height)
} else {
content_height
};
let mut height = screen_height + 2 * Self::WINDOW_PADDING;
if self.options.window_frame {
height += Self::WINDOW_FRAME_HEIGHT;
}
let data = HandlebarsData {
creator: CreatorData::default(),
height,
content_height,
screen_height,
interactions: transcript
.interactions()
.iter()
.zip(rendered_outputs)
.map(|(interaction, output_html)| SerializedInteraction {
input: interaction.input(),
output_html,
})
.collect(),
options: &self.options,
scroll_animation,
};
self.handlebars
.render_to_write(MAIN_TEMPLATE_NAME, &data, destination)
}
fn render_outputs(&self, transcript: &Transcript) -> Result<Vec<String>, TermError> {
let max_width = self
.options
.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)?;
Ok(buffer)
})
.collect()
}
fn compute_content_height(transcript: &Transcript, rendered_outputs: &[String]) -> usize {
let line_count: usize = transcript
.interactions
.iter()
.zip(rendered_outputs)
.map(|(interaction, output_html)| {
Self::count_lines_in_input(interaction.input().as_ref())
+ Self::count_lines_in_output(output_html)
})
.sum();
let margin_count = transcript
.interactions
.iter()
.map(|interaction| {
if interaction.output().as_ref().is_empty() {
1
} else {
2
}
})
.sum::<usize>()
.saturating_sub(1); line_count * Self::LINE_HEIGHT
+ margin_count * Self::BLOCK_MARGIN
+ transcript.interactions.len() * Self::USER_INPUT_PADDING
}
fn count_lines_in_input(input_str: &str) -> usize {
let mut input_lines = bytecount::count(input_str.as_bytes(), b'\n');
if !input_str.is_empty() && !input_str.ends_with('\n') {
input_lines += 1;
}
input_lines
}
fn count_lines_in_output(output_html: &str) -> usize {
let mut output_lines =
bytecount::count(output_html.as_bytes(), b'\n') + output_html.matches("<br/>").count();
if !output_html.is_empty() && !output_html.ends_with('\n') {
output_lines += 1;
}
output_lines
}
#[allow(clippy::cast_precision_loss)] fn scroll_animation(&self, content_height: usize) -> Option<ScrollAnimationConfig> {
fn div_ceil(x: usize, y: usize) -> usize {
(x + y - 1) / y
}
let scroll_options = self.options.scroll.as_ref()?;
let max_height = scroll_options.max_height;
let max_offset = content_height.checked_sub(max_height)?;
let steps = div_ceil(max_offset, Self::PIXELS_PER_SCROLL);
debug_assert!(steps > 0);
let mut view_box = (0..=steps).fold(String::new(), |mut acc, i| {
let y = (Self::PIXELS_PER_SCROLL as f32 * i as f32).round();
write!(
&mut acc,
"0 {y} {width} {height};",
y = y,
width = self.options.width,
height = max_height
)
.unwrap(); acc
});
view_box.pop();
let y_step = (max_height - Self::SCROLLBAR_HEIGHT) as f32 / steps as f32;
let mut scrollbar_y = (0..=steps).fold(String::new(), |mut acc, i| {
let y = (y_step * i as f32).round();
write!(&mut acc, "0 {};", y).unwrap();
acc
});
scrollbar_y.pop();
Some(ScrollAnimationConfig {
duration: scroll_options.interval * steps as f32,
view_box,
scrollbar_x: self.options.width - Self::SCROLLBAR_RIGHT_OFFSET,
scrollbar_y,
})
}
}
#[derive(Debug, Serialize)]
struct HandlebarsData<'r> {
creator: CreatorData,
height: usize,
screen_height: usize,
content_height: usize,
interactions: Vec<SerializedInteraction<'r>>,
#[serde(flatten)]
options: &'r TemplateOptions,
scroll_animation: Option<ScrollAnimationConfig>,
}
#[derive(Debug, Serialize)]
struct CreatorData {
name: &'static str,
version: &'static str,
repo: &'static str,
}
impl Default for CreatorData {
fn default() -> Self {
Self {
name: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
repo: env!("CARGO_PKG_REPOSITORY"),
}
}
}
#[derive(Debug, Serialize)]
struct SerializedInteraction<'a> {
input: &'a UserInput,
output_html: String,
}
#[derive(Debug, Serialize)]
struct ScrollAnimationConfig {
duration: f32,
view_box: String,
scrollbar_x: usize,
scrollbar_y: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendering_simple_transcript() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
Template::new(TemplateOptions::default())
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains(r#"Hello, <span class="fg2">world</span>!"#));
assert!(!buffer.contains("<circle"));
}
#[test]
fn rendering_transcript_with_frame() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
window_frame: true,
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains("<circle"));
}
#[test]
fn rendering_transcript_with_animation() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!\n".repeat(22),
);
let mut buffer = vec![];
let options = TemplateOptions {
scroll: Some(ScrollOptions {
max_height: 240,
interval: 3.0,
}),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains(r#"viewBox="0 0 720 260""#), "{}", buffer);
assert!(buffer.contains("<animateTransform"), "{}", buffer);
}
#[test]
fn rendering_transcript_with_wraps() {
let mut transcript = Transcript::new();
transcript.add_interaction(
UserInput::command("test"),
"Hello, \u{1b}[32mworld\u{1b}[0m!",
);
let mut buffer = vec![];
let options = TemplateOptions {
wrap: Some(WrapOptions::HardBreakAt(5)),
..TemplateOptions::default()
};
Template::new(options)
.render(&transcript, &mut buffer)
.unwrap();
let buffer = String::from_utf8(buffer).unwrap();
assert!(buffer.contains(r#"viewBox="0 0 720 102""#), "{}", buffer);
assert!(buffer.contains("<br/>"), "{}", buffer);
}
}