1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum LineNumbers {
18 EachOutput,
20 ContinuousOutputs,
22 #[default]
25 Continuous,
26}
27
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31#[non_exhaustive]
32pub enum ContinuedLineNumbers {
33 #[default]
35 Inherit,
36 Mark(Cow<'static, str>),
38}
39
40impl ContinuedLineNumbers {
41 pub const fn mark(mark: &'static str) -> Self {
43 Self::Mark(Cow::Borrowed(mark))
44 }
45}
46
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct LineNumberingOptions {
50 #[serde(default)]
52 pub scope: LineNumbers,
53 #[serde(default)]
55 pub continued: ContinuedLineNumbers,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
109pub struct TemplateOptions {
110 #[serde(default = "TemplateOptions::default_width")]
113 pub width: NonZeroUsize,
114 pub line_height: Option<f64>,
117 pub advance_width: Option<f64>,
122 #[serde(default)]
124 pub palette: Palette,
125 #[serde(default = "TemplateOptions::default_dim_opacity")]
127 pub dim_opacity: f64,
128 #[serde(default)]
130 pub blink: BlinkOptions,
131 #[serde(skip_serializing_if = "str::is_empty", default)]
137 pub additional_styles: String,
138 #[serde(default = "TemplateOptions::default_font_family")]
140 pub font_family: String,
141 pub window: Option<WindowOptions>,
143 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub scroll: Option<ScrollOptions>,
147 #[serde(default = "TemplateOptions::default_wrap")]
149 pub wrap: Option<WrapOptions>,
150 pub line_numbers: Option<LineNumberingOptions>,
152 #[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 #[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 #[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)] fn default_wrap() -> Option<WrapOptions> {
254 Some(WrapOptions::default())
255 }
256
257 pub fn validated(self) -> anyhow::Result<ValidTemplateOptions> {
263 self.try_into()
264 }
265}
266
267#[derive(Debug, Clone, Default, Deserialize, Serialize)]
269pub struct WindowOptions {
270 pub title: String,
272}
273
274#[derive(Debug, Clone, Deserialize, Serialize)]
280#[cfg_attr(test, derive(PartialEq))]
281pub struct ScrollOptions {
282 #[serde(default = "ScrollOptions::default_max_height")]
286 pub max_height: NonZeroUsize,
287 #[serde(default = "ScrollOptions::default_min_scrollbar_height")]
289 pub min_scrollbar_height: NonZeroUsize,
290 #[serde(default = "ScrollOptions::default_pixels_per_scroll")]
292 pub pixels_per_scroll: NonZeroUsize,
293 #[serde(default = "ScrollOptions::default_interval")]
295 pub interval: f64,
296 #[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 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#[derive(Debug, Clone, Deserialize, Serialize)]
361#[non_exhaustive]
362#[serde(rename_all = "snake_case")]
363pub enum WrapOptions {
364 HardBreakAt {
367 #[serde(default = "WrapOptions::default_width")]
369 chars: NonZeroUsize,
370 #[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)] impl 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#[derive(Debug, Clone, Deserialize, Serialize)]
402pub struct BlinkOptions {
403 #[serde(default = "BlinkOptions::default_interval")]
405 pub interval: f64,
406 #[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#[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 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 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 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}