term_transcript/svg/
options.rs

1//! `TemplateOptions` and related types.
2
3use std::{borrow::Cow, num::NonZeroUsize, ops};
4
5use anyhow::Context;
6use serde::{Deserialize, Serialize};
7
8#[cfg(feature = "font-subset")]
9use super::subset::FontSubsetter;
10use super::{font::BoxedErrorEmbedder, FontEmbedder, HandlebarsData, Palette};
11use crate::{BoxedError, TermError, Transcript};
12
13/// Line numbering scope.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum LineNumbers {
18    /// Number lines in each output separately. Inputs are not numbered.
19    EachOutput,
20    /// Use continuous numbering for the lines in all outputs. Inputs are not numbered.
21    ContinuousOutputs,
22    /// Use continuous numbering for the lines in all displayed inputs (i.e., ones that
23    /// are not [hidden](crate::UserInput::hide())) and outputs.
24    #[default]
25    Continuous,
26}
27
28/// Numbering of continued lines.
29#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum ContinuedLineNumbers {
33    /// Continued lines are numbered in the same way as the ordinary lines.
34    #[default]
35    Inherit,
36    /// Mark continued lines with the specified constant string. The string may be empty.
37    Mark(Cow<'static, str>),
38}
39
40impl ContinuedLineNumbers {
41    /// Creates a [`Self::Mark`] variant.
42    pub const fn mark(mark: &'static str) -> Self {
43        Self::Mark(Cow::Borrowed(mark))
44    }
45}
46
47/// Line numbering options.
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct LineNumberingOptions {
50    /// Scoping of line numbers.
51    #[serde(default)]
52    pub scope: LineNumbers,
53    /// Numbering of continued lines.
54    #[serde(default)]
55    pub continued: ContinuedLineNumbers,
56}
57
58/// Configurable options of a [`Template`].
59///
60/// # Serialization
61///
62/// Options can be deserialized from `serde`-supported encoding formats, such as TOML. This is used
63/// in the CLI app to read options from a file:
64///
65/// ```
66/// # use assert_matches::assert_matches;
67/// # use term_transcript::svg::{RgbColor, TemplateOptions, WrapOptions};
68/// let options_toml = r#"
69/// width = 900
70/// window_frame = true
71/// line_numbers.scope = 'continuous'
72/// wrap.hard_break_at.chars = 100
73/// scroll = { max_height = 300, pixels_per_scroll = 18, interval = 1.5 }
74///
75/// [palette.colors]
76/// black = '#3c3836'
77/// red = '#b85651'
78/// green = '#8f9a52'
79/// yellow = '#c18f41'
80/// blue = '#68948a'
81/// magenta = '#ab6c7d'
82/// cyan = '#72966c'
83/// white = '#a89984'
84///
85/// [palette.intense_colors]
86/// black = '#5a524c'
87/// red = '#b85651'
88/// green = '#a9b665'
89/// yellow = '#d8a657'
90/// blue = '#7daea3'
91/// magenta = '#d3869b'
92/// cyan = '#89b482'
93/// white = '#ddc7a1'
94/// "#;
95///
96/// let options: TemplateOptions = toml::from_str(options_toml)?;
97/// assert_eq!(options.width.get(), 900);
98/// assert_matches!(
99///     options.wrap,
100///     Some(WrapOptions::HardBreakAt { chars, .. }) if chars.get() == 100
101/// );
102/// assert_eq!(
103///     options.palette.colors.green,
104///     RgbColor(0x8f, 0x9a, 0x52)
105/// );
106/// # anyhow::Ok(())
107/// ```
108#[derive(Debug, Serialize, Deserialize)]
109pub struct TemplateOptions {
110    /// Width of the rendered terminal window in pixels. Excludes the line numbers width if line
111    /// numbering is enabled. The default value is `720`.
112    #[serde(default = "TemplateOptions::default_width")]
113    pub width: NonZeroUsize,
114    /// Line height relative to the font size. If not specified, will be taken from font metrics (if a font is embedded),
115    /// or set to 1.2 otherwise.
116    pub line_height: Option<f64>,
117    /// Advance width of a font relative to the font size (i.e., in em units). If not specified, will be taken from font metrics (if a font is embedded),
118    /// or set to 8px (~0.57em) otherwise.
119    ///
120    /// For now, advance width is only applied to the pure SVG template.
121    pub advance_width: Option<f64>,
122    /// Palette of terminal colors. The default value of [`Palette`] is used by default.
123    #[serde(default)]
124    pub palette: Palette,
125    /// Opacity of dimmed text. The default value is 0.7.
126    #[serde(default = "TemplateOptions::default_dim_opacity")]
127    pub dim_opacity: f64,
128    /// Blink options.
129    #[serde(default)]
130    pub blink: BlinkOptions,
131    /// CSS instructions to add at the beginning of the SVG `<style>` tag. This is mostly useful
132    /// to import fonts in conjunction with `font_family`.
133    ///
134    /// The value is not validated in any way, so supplying invalid CSS instructions can lead
135    /// to broken SVG rendering.
136    #[serde(skip_serializing_if = "str::is_empty", default)]
137    pub additional_styles: String,
138    /// Font family specification in the CSS format. Should be monospace.
139    #[serde(default = "TemplateOptions::default_font_family")]
140    pub font_family: String,
141    /// Window options.
142    pub window: Option<WindowOptions>,
143    /// Options for the scroll animation. If set to `None` (which is the default),
144    /// no scrolling will be enabled, and the height of the generated image is not limited.
145    #[serde(skip_serializing_if = "Option::is_none", default)]
146    pub scroll: Option<ScrollOptions>,
147    /// Text wrapping options. The default value of [`WrapOptions`] is used by default.
148    #[serde(default = "TemplateOptions::default_wrap")]
149    pub wrap: Option<WrapOptions>,
150    /// Line numbering options.
151    pub line_numbers: Option<LineNumberingOptions>,
152    /// *Font embedder* that will embed the font into the SVG file via `@font-face` CSS.
153    /// This guarantees that the SVG will look identical on all platforms.
154    #[serde(skip)]
155    pub font_embedder: Option<Box<dyn FontEmbedder<Error = BoxedError>>>,
156}
157
158impl Default for TemplateOptions {
159    fn default() -> Self {
160        Self {
161            width: Self::default_width(),
162            line_height: None,
163            advance_width: None,
164            palette: Palette::default(),
165            dim_opacity: Self::default_dim_opacity(),
166            blink: BlinkOptions::default(),
167            additional_styles: String::new(),
168            font_family: Self::default_font_family(),
169            window: None,
170            scroll: None,
171            wrap: Self::default_wrap(),
172            line_numbers: None,
173            font_embedder: None,
174        }
175    }
176}
177
178impl TemplateOptions {
179    fn validate(&self) -> anyhow::Result<()> {
180        anyhow::ensure!(
181            self.dim_opacity > 0.0 && self.dim_opacity < 1.0,
182            "invalid dimmed text opacity ({:?}), should be in (0, 1)",
183            self.dim_opacity
184        );
185
186        if let Some(line_height) = self.line_height {
187            anyhow::ensure!(line_height > 0.0, "line_height must be positive");
188            #[cfg(feature = "tracing")]
189            if line_height > 2.0 {
190                tracing::warn!(
191                    line_height,
192                    "line_height is too large, the produced SVG may look broken"
193                );
194            }
195        }
196
197        if let Some(advance_width) = self.advance_width {
198            anyhow::ensure!(advance_width > 0.0, "advance_width must be positive");
199            #[cfg(feature = "tracing")]
200            if advance_width > 0.7 {
201                tracing::warn!(
202                    advance_width,
203                    "advance_width is too large, the produced SVG may look broken"
204                );
205            }
206            #[cfg(feature = "tracing")]
207            if advance_width < 0.5 {
208                tracing::warn!(
209                    advance_width,
210                    "advance_width is too small, the produced SVG may look broken"
211                );
212            }
213        }
214
215        if let Some(scroll_options) = &self.scroll {
216            scroll_options
217                .validate()
218                .context("invalid scroll options")?;
219        }
220
221        self.blink.validate().context("invalid blink options")?;
222
223        Ok(())
224    }
225
226    /// Sets the font embedder to be used.
227    #[must_use]
228    pub fn with_font_embedder(mut self, embedder: impl FontEmbedder) -> Self {
229        self.font_embedder = Some(Box::new(BoxedErrorEmbedder(embedder)));
230        self
231    }
232
233    /// Sets the [standard font embedder / subsetter](FontSubsetter).
234    #[cfg(feature = "font-subset")]
235    #[must_use]
236    pub fn with_font_subsetting(self, options: FontSubsetter) -> Self {
237        self.with_font_embedder(options)
238    }
239
240    const fn default_width() -> NonZeroUsize {
241        NonZeroUsize::new(720).unwrap()
242    }
243
244    const fn default_dim_opacity() -> f64 {
245        0.7
246    }
247
248    fn default_font_family() -> String {
249        "SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace".to_owned()
250    }
251
252    #[allow(clippy::unnecessary_wraps)] // required by serde
253    fn default_wrap() -> Option<WrapOptions> {
254        Some(WrapOptions::default())
255    }
256
257    /// Validates these options. This is equivalent to using [`TryInto`].
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if options are invalid.
262    pub fn validated(self) -> anyhow::Result<ValidTemplateOptions> {
263        self.try_into()
264    }
265}
266
267/// Window frame options.
268#[derive(Debug, Clone, Default, Deserialize, Serialize)]
269pub struct WindowOptions {
270    /// Window title. May be empty.
271    pub title: String,
272}
273
274/// Options that influence the scrolling animation.
275///
276/// The animation is only displayed if the console exceeds [`Self::max_height`]. In this case,
277/// the console will be scrolled vertically by [`Self::pixels_per_scroll`]
278/// with the interval of [`Self::interval`] seconds between every frame.
279#[derive(Debug, Clone, Deserialize, Serialize)]
280#[cfg_attr(test, derive(PartialEq))]
281pub struct ScrollOptions {
282    /// Maximum height of the console, in pixels. The default value allows to fit 19 lines
283    /// of text into the view with the default template (potentially, slightly less because
284    /// of vertical margins around user inputs).
285    #[serde(default = "ScrollOptions::default_max_height")]
286    pub max_height: NonZeroUsize,
287    /// Minimum scrollbar height in pixels. The default value is 14px (1em).
288    #[serde(default = "ScrollOptions::default_min_scrollbar_height")]
289    pub min_scrollbar_height: NonZeroUsize,
290    /// Number of pixels moved each scroll. Default value is 52 (~3 lines of text with the default template).
291    #[serde(default = "ScrollOptions::default_pixels_per_scroll")]
292    pub pixels_per_scroll: NonZeroUsize,
293    /// Interval between keyframes in seconds. The default value is `4`.
294    #[serde(default = "ScrollOptions::default_interval")]
295    pub interval: f64,
296    /// Threshold to elide the penultimate scroll keyframe, relative to `pixels_per_scroll`.
297    /// If the last scroll keyframe would scroll the view by less than this value (which can happen because
298    /// the last scroll always aligns the scrolled view bottom with the viewport bottom), it will be
299    /// combined with the penultimate keyframe.
300    ///
301    /// The threshold must be in [0, 1). 0 means never eliding the penultimate keyframe. The default value is 0.25.
302    #[serde(default = "ScrollOptions::default_elision_threshold")]
303    pub elision_threshold: f64,
304}
305
306impl Default for ScrollOptions {
307    fn default() -> Self {
308        Self::DEFAULT
309    }
310}
311
312impl ScrollOptions {
313    /// Default options.
314    pub const DEFAULT: Self = Self {
315        max_height: Self::default_max_height(),
316        min_scrollbar_height: Self::default_min_scrollbar_height(),
317        pixels_per_scroll: Self::default_pixels_per_scroll(),
318        interval: Self::default_interval(),
319        elision_threshold: Self::default_elision_threshold(),
320    };
321
322    const fn default_max_height() -> NonZeroUsize {
323        NonZeroUsize::new(18 * 19).unwrap()
324    }
325
326    const fn default_min_scrollbar_height() -> NonZeroUsize {
327        NonZeroUsize::new(14).unwrap()
328    }
329
330    const fn default_pixels_per_scroll() -> NonZeroUsize {
331        NonZeroUsize::new(52).unwrap()
332    }
333
334    const fn default_interval() -> f64 {
335        4.0
336    }
337
338    const fn default_elision_threshold() -> f64 {
339        0.25
340    }
341
342    fn validate(&self) -> anyhow::Result<()> {
343        anyhow::ensure!(self.interval > 0.0, "interval must be positive");
344        anyhow::ensure!(
345            self.elision_threshold >= 0.0 && self.elision_threshold < 1.0,
346            "elision_threshold must be in [0, 1)"
347        );
348
349        anyhow::ensure!(
350            self.min_scrollbar_height < self.max_height,
351            "min_scrollbar_height={} must be lesser than max_height={}",
352            self.min_scrollbar_height,
353            self.max_height
354        );
355        Ok(())
356    }
357}
358
359/// Text wrapping options.
360#[derive(Debug, Clone, Deserialize, Serialize)]
361#[non_exhaustive]
362#[serde(rename_all = "snake_case")]
363pub enum WrapOptions {
364    /// Perform a hard break at the specified width of output. The [`Default`] implementation
365    /// returns this variant with width 80.
366    HardBreakAt {
367        /// Char width of the break.
368        #[serde(default = "WrapOptions::default_width")]
369        chars: NonZeroUsize,
370        /// Marker placed after the break.
371        #[serde(default = "WrapOptions::serde_default_mark")]
372        mark: Cow<'static, str>,
373    },
374}
375
376impl Default for WrapOptions {
377    fn default() -> Self {
378        Self::HardBreakAt {
379            chars: Self::default_width(),
380            mark: Self::default_mark().into(),
381        }
382    }
383}
384
385#[doc(hidden)] // Used in CLI; not public API
386impl WrapOptions {
387    pub const fn default_width() -> NonZeroUsize {
388        NonZeroUsize::new(80).unwrap()
389    }
390
391    pub const fn default_mark() -> &'static str {
392        "ยป"
393    }
394
395    const fn serde_default_mark() -> Cow<'static, str> {
396        Cow::Borrowed(Self::default_mark())
397    }
398}
399
400/// Blink options.
401#[derive(Debug, Clone, Deserialize, Serialize)]
402pub struct BlinkOptions {
403    /// Interval between blinking animation keyframes in seconds.
404    #[serde(default = "BlinkOptions::default_interval")]
405    pub interval: f64,
406    /// Lower value of blink opacity. Must be in `[0, 1]`.
407    #[serde(default = "TemplateOptions::default_dim_opacity")]
408    pub opacity: f64,
409}
410
411impl Default for BlinkOptions {
412    fn default() -> Self {
413        Self {
414            interval: Self::default_interval(),
415            opacity: TemplateOptions::default_dim_opacity(),
416        }
417    }
418}
419
420impl BlinkOptions {
421    const fn default_interval() -> f64 {
422        1.0
423    }
424
425    fn validate(&self) -> anyhow::Result<()> {
426        anyhow::ensure!(self.interval > 0.0, "interval must be positive");
427        anyhow::ensure!(
428            self.opacity >= 0.0 && self.opacity <= 1.0,
429            "opacity must be in [0, 1]"
430        );
431        Ok(())
432    }
433}
434
435/// Valid wrapper for [`TemplateOptions`]. The only way to construct this wrapper is to convert
436/// [`TemplateOptions`] via [`validated()`](TemplateOptions::validated()) or [`TryInto`].
437#[derive(Debug, Default)]
438pub struct ValidTemplateOptions(TemplateOptions);
439
440impl ops::Deref for ValidTemplateOptions {
441    type Target = TemplateOptions;
442
443    fn deref(&self) -> &Self::Target {
444        &self.0
445    }
446}
447
448impl TryFrom<TemplateOptions> for ValidTemplateOptions {
449    type Error = anyhow::Error;
450
451    fn try_from(options: TemplateOptions) -> Result<Self, Self::Error> {
452        options.validate()?;
453        Ok(Self(options))
454    }
455}
456
457impl ValidTemplateOptions {
458    /// Generates data for rendering.
459    ///
460    /// # Errors
461    ///
462    /// Returns an error if output cannot be rendered to HTML (e.g., it contains invalid
463    /// SGR sequences).
464    pub fn render_data<'s>(
465        &'s self,
466        transcript: &'s Transcript,
467    ) -> Result<HandlebarsData<'s>, TermError> {
468        self.0.render_data(transcript)
469    }
470
471    /// Unwraps the contained options.
472    pub fn into_inner(self) -> TemplateOptions {
473        self.0
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn parsing_scroll_options() {
483        let json = serde_json::json!({});
484        let options: ScrollOptions = serde_json::from_value(json).unwrap();
485        assert_eq!(options, ScrollOptions::DEFAULT);
486
487        let json = serde_json::json!({
488            "pixels_per_scroll": 40,
489            "elision_threshold": 0.1,
490        });
491        let options: ScrollOptions = serde_json::from_value(json).unwrap();
492        assert_eq!(
493            options,
494            ScrollOptions {
495                pixels_per_scroll: NonZeroUsize::new(40).unwrap(),
496                elision_threshold: 0.1,
497                ..ScrollOptions::DEFAULT
498            }
499        );
500    }
501
502    #[test]
503    fn validating_options() {
504        // Default options must be valid.
505        TemplateOptions::default().validate().unwrap();
506
507        let bogus_options = TemplateOptions {
508            line_height: Some(-1.0),
509            ..TemplateOptions::default()
510        };
511        let err = bogus_options.validate().unwrap_err().to_string();
512        assert!(err.contains("line_height"), "{err}");
513
514        let bogus_options = TemplateOptions {
515            advance_width: Some(-1.0),
516            ..TemplateOptions::default()
517        };
518        let err = bogus_options.validate().unwrap_err().to_string();
519        assert!(err.contains("advance_width"), "{err}");
520
521        let bogus_options = TemplateOptions {
522            scroll: Some(ScrollOptions {
523                interval: -1.0,
524                ..ScrollOptions::default()
525            }),
526            ..TemplateOptions::default()
527        };
528        let err = format!("{:#}", bogus_options.validate().unwrap_err());
529        assert!(err.contains("interval"), "{err}");
530
531        for elision_threshold in [-1.0, 1.0] {
532            let bogus_options = TemplateOptions {
533                scroll: Some(ScrollOptions {
534                    elision_threshold,
535                    ..ScrollOptions::default()
536                }),
537                ..TemplateOptions::default()
538            };
539            let err = format!("{:#}", bogus_options.validate().unwrap_err());
540            assert!(err.contains("elision_threshold"), "{err}");
541        }
542    }
543}