use std::{borrow::Cow, num::NonZeroUsize, ops};
use anyhow::Context;
use serde::{Deserialize, Serialize};
#[cfg(feature = "font-subset")]
use super::subset::FontSubsetter;
use super::{FontEmbedder, HandlebarsData, Palette, font::BoxedErrorEmbedder};
use crate::{TermError, Transcript, types::BoxedError};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum LineNumbers {
EachOutput,
ContinuousOutputs,
#[default]
Continuous,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ContinuedLineNumbers {
#[default]
Inherit,
Mark(Cow<'static, str>),
}
impl ContinuedLineNumbers {
pub const fn mark(mark: &'static str) -> Self {
Self::Mark(Cow::Borrowed(mark))
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineNumberingOptions {
#[serde(default)]
pub scope: LineNumbers,
#[serde(default)]
pub continued: ContinuedLineNumbers,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TemplateOptions {
#[serde(default = "TemplateOptions::default_width")]
pub width: NonZeroUsize,
pub line_height: Option<f64>,
pub advance_width: Option<f64>,
#[serde(default)]
pub palette: Palette,
#[serde(default = "TemplateOptions::default_dim_opacity")]
pub dim_opacity: f64,
#[serde(default)]
pub blink: BlinkOptions,
#[serde(skip_serializing_if = "str::is_empty", default)]
pub additional_styles: String,
#[serde(default = "TemplateOptions::default_font_family")]
pub font_family: String,
pub window: Option<WindowOptions>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub scroll: Option<ScrollOptions>,
#[serde(default = "TemplateOptions::default_wrap")]
pub wrap: Option<WrapOptions>,
pub line_numbers: Option<LineNumberingOptions>,
#[serde(skip)]
pub font_embedder: Option<Box<dyn FontEmbedder<Error = BoxedError>>>,
}
impl Default for TemplateOptions {
fn default() -> Self {
Self {
width: Self::default_width(),
line_height: None,
advance_width: None,
palette: Palette::default(),
dim_opacity: Self::default_dim_opacity(),
blink: BlinkOptions::default(),
additional_styles: String::new(),
font_family: Self::default_font_family(),
window: None,
scroll: None,
wrap: Self::default_wrap(),
line_numbers: None,
font_embedder: None,
}
}
}
impl TemplateOptions {
fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(
self.dim_opacity > 0.0 && self.dim_opacity < 1.0,
"invalid dimmed text opacity ({:?}), should be in (0, 1)",
self.dim_opacity
);
if let Some(line_height) = self.line_height {
anyhow::ensure!(line_height > 0.0, "line_height must be positive");
#[cfg(feature = "tracing")]
if line_height > 2.0 {
tracing::warn!(
line_height,
"line_height is too large, the produced SVG may look broken"
);
}
}
if let Some(advance_width) = self.advance_width {
anyhow::ensure!(advance_width > 0.0, "advance_width must be positive");
#[cfg(feature = "tracing")]
if advance_width > 0.7 {
tracing::warn!(
advance_width,
"advance_width is too large, the produced SVG may look broken"
);
}
#[cfg(feature = "tracing")]
if advance_width < 0.5 {
tracing::warn!(
advance_width,
"advance_width is too small, the produced SVG may look broken"
);
}
}
if let Some(scroll_options) = &self.scroll {
scroll_options
.validate()
.context("invalid scroll options")?;
}
self.blink.validate().context("invalid blink options")?;
Ok(())
}
#[must_use]
pub fn with_font_embedder(mut self, embedder: impl FontEmbedder) -> Self {
self.font_embedder = Some(Box::new(BoxedErrorEmbedder(embedder)));
self
}
#[cfg(feature = "font-subset")]
#[must_use]
pub fn with_font_subsetting(self, options: FontSubsetter) -> Self {
self.with_font_embedder(options)
}
const fn default_width() -> NonZeroUsize {
NonZeroUsize::new(720).unwrap()
}
const fn default_dim_opacity() -> f64 {
0.7
}
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())
}
pub fn validated(self) -> anyhow::Result<ValidTemplateOptions> {
self.try_into()
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct WindowOptions {
pub title: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ScrollOptions {
#[serde(default = "ScrollOptions::default_max_height")]
pub max_height: NonZeroUsize,
#[serde(default = "ScrollOptions::default_min_scrollbar_height")]
pub min_scrollbar_height: NonZeroUsize,
#[serde(default = "ScrollOptions::default_pixels_per_scroll")]
pub pixels_per_scroll: NonZeroUsize,
#[serde(default = "ScrollOptions::default_interval")]
pub interval: f64,
#[serde(default = "ScrollOptions::default_elision_threshold")]
pub elision_threshold: f64,
}
impl Default for ScrollOptions {
fn default() -> Self {
Self::DEFAULT
}
}
impl ScrollOptions {
pub const DEFAULT: Self = Self {
max_height: Self::default_max_height(),
min_scrollbar_height: Self::default_min_scrollbar_height(),
pixels_per_scroll: Self::default_pixels_per_scroll(),
interval: Self::default_interval(),
elision_threshold: Self::default_elision_threshold(),
};
const fn default_max_height() -> NonZeroUsize {
NonZeroUsize::new(18 * 19).unwrap()
}
const fn default_min_scrollbar_height() -> NonZeroUsize {
NonZeroUsize::new(14).unwrap()
}
const fn default_pixels_per_scroll() -> NonZeroUsize {
NonZeroUsize::new(52).unwrap()
}
const fn default_interval() -> f64 {
4.0
}
const fn default_elision_threshold() -> f64 {
0.25
}
fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(self.interval > 0.0, "interval must be positive");
anyhow::ensure!(
self.elision_threshold >= 0.0 && self.elision_threshold < 1.0,
"elision_threshold must be in [0, 1)"
);
anyhow::ensure!(
self.min_scrollbar_height < self.max_height,
"min_scrollbar_height={} must be lesser than max_height={}",
self.min_scrollbar_height,
self.max_height
);
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum WrapOptions {
HardBreakAt {
#[serde(default = "WrapOptions::default_width")]
chars: NonZeroUsize,
#[serde(default = "WrapOptions::serde_default_mark")]
mark: Cow<'static, str>,
},
}
impl Default for WrapOptions {
fn default() -> Self {
Self::HardBreakAt {
chars: Self::default_width(),
mark: Self::default_mark().into(),
}
}
}
#[doc(hidden)] impl WrapOptions {
pub const fn default_width() -> NonZeroUsize {
NonZeroUsize::new(80).unwrap()
}
pub const fn default_mark() -> &'static str {
"»"
}
const fn serde_default_mark() -> Cow<'static, str> {
Cow::Borrowed(Self::default_mark())
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BlinkOptions {
#[serde(default = "BlinkOptions::default_interval")]
pub interval: f64,
#[serde(default = "TemplateOptions::default_dim_opacity")]
pub opacity: f64,
}
impl Default for BlinkOptions {
fn default() -> Self {
Self {
interval: Self::default_interval(),
opacity: TemplateOptions::default_dim_opacity(),
}
}
}
impl BlinkOptions {
const fn default_interval() -> f64 {
1.0
}
fn validate(&self) -> anyhow::Result<()> {
anyhow::ensure!(self.interval > 0.0, "interval must be positive");
anyhow::ensure!(
self.opacity >= 0.0 && self.opacity <= 1.0,
"opacity must be in [0, 1]"
);
Ok(())
}
}
#[derive(Debug, Default)]
pub struct ValidTemplateOptions(TemplateOptions);
impl ops::Deref for ValidTemplateOptions {
type Target = TemplateOptions;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl TryFrom<TemplateOptions> for ValidTemplateOptions {
type Error = anyhow::Error;
fn try_from(options: TemplateOptions) -> Result<Self, Self::Error> {
options.validate()?;
Ok(Self(options))
}
}
impl ValidTemplateOptions {
pub fn render_data<'s>(
&'s self,
transcript: &'s Transcript,
) -> Result<HandlebarsData<'s>, TermError> {
self.0.render_data(transcript)
}
pub fn into_inner(self) -> TemplateOptions {
self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsing_scroll_options() {
let json = serde_json::json!({});
let options: ScrollOptions = serde_json::from_value(json).unwrap();
assert_eq!(options, ScrollOptions::DEFAULT);
let json = serde_json::json!({
"pixels_per_scroll": 40,
"elision_threshold": 0.1,
});
let options: ScrollOptions = serde_json::from_value(json).unwrap();
assert_eq!(
options,
ScrollOptions {
pixels_per_scroll: NonZeroUsize::new(40).unwrap(),
elision_threshold: 0.1,
..ScrollOptions::DEFAULT
}
);
}
#[test]
fn validating_options() {
TemplateOptions::default().validate().unwrap();
let bogus_options = TemplateOptions {
line_height: Some(-1.0),
..TemplateOptions::default()
};
let err = bogus_options.validate().unwrap_err().to_string();
assert!(err.contains("line_height"), "{err}");
let bogus_options = TemplateOptions {
advance_width: Some(-1.0),
..TemplateOptions::default()
};
let err = bogus_options.validate().unwrap_err().to_string();
assert!(err.contains("advance_width"), "{err}");
let bogus_options = TemplateOptions {
scroll: Some(ScrollOptions {
interval: -1.0,
..ScrollOptions::default()
}),
..TemplateOptions::default()
};
let err = format!("{:#}", bogus_options.validate().unwrap_err());
assert!(err.contains("interval"), "{err}");
for elision_threshold in [-1.0, 1.0] {
let bogus_options = TemplateOptions {
scroll: Some(ScrollOptions {
elision_threshold,
..ScrollOptions::default()
}),
..TemplateOptions::default()
};
let err = format!("{:#}", bogus_options.validate().unwrap_err());
assert!(err.contains("elision_threshold"), "{err}");
}
}
}