Skip to main content

termdoc_core/
line.rs

1//! The layout's output vocabulary: laid-out lines, ready for a backend.
2//!
3//! This lives in `core` rather than in `layout` for the same reason `Event` does: it is
4//! the interface *between* two stages. `Event` is the reader→layout contract; `Line` is
5//! the layout→backend contract. Both belong to the shared vocabulary, and putting them
6//! here is what lets `termdoc-backend` avoid depending on `termdoc-layout`
7//! (docs/DESIGN.md §3).
8
9use std::borrow::Cow;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Color {
13    /// A literal color. The backend quantizes it down to 256/16 when the terminal
14    /// cannot represent it.
15    Rgb(u8, u8, u8),
16    /// An index into the 256-color palette.
17    Indexed(u8),
18    /// One of the 16 ANSI names. The terminal picks the actual shade, so this respects
19    /// the user's own theme — which is why it is the preferred default.
20    Named(NamedColor),
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum NamedColor {
25    Black,
26    Red,
27    Green,
28    Yellow,
29    Blue,
30    Magenta,
31    Cyan,
32    White,
33    BrightBlack,
34    BrightRed,
35    BrightGreen,
36    BrightYellow,
37    BrightBlue,
38    BrightMagenta,
39    BrightCyan,
40    BrightWhite,
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub struct Style {
45    pub fg: Option<Color>,
46    pub bg: Option<Color>,
47    pub bold: bool,
48    pub dim: bool,
49    pub italic: bool,
50    pub underline: bool,
51    pub strike: bool,
52    pub reverse: bool,
53}
54
55impl Style {
56    pub const PLAIN: Style = Style {
57        fg: None,
58        bg: None,
59        bold: false,
60        dim: false,
61        italic: false,
62        underline: false,
63        strike: false,
64        reverse: false,
65    };
66
67    pub const fn fg(color: Color) -> Self {
68        Style {
69            fg: Some(color),
70            ..Style::PLAIN
71        }
72    }
73
74    pub const fn bold() -> Self {
75        Style {
76            bold: true,
77            ..Style::PLAIN
78        }
79    }
80
81    pub const fn with_bold(mut self) -> Self {
82        self.bold = true;
83        self
84    }
85
86    pub const fn with_dim(mut self) -> Self {
87        self.dim = true;
88        self
89    }
90
91    pub const fn with_italic(mut self) -> Self {
92        self.italic = true;
93        self
94    }
95
96    pub const fn with_underline(mut self) -> Self {
97        self.underline = true;
98        self
99    }
100
101    pub const fn with_strike(mut self) -> Self {
102        self.strike = true;
103        self
104    }
105
106    pub const fn with_fg(mut self, color: Color) -> Self {
107        self.fg = Some(color);
108        self
109    }
110
111    /// Layers `other` on top of `self`: boolean attributes accumulate and `other`'s
112    /// colors win when it defines them. This is what makes `**_bold italic_**` work by
113    /// stacking styles instead of replacing them.
114    pub fn over(self, other: Style) -> Style {
115        Style {
116            fg: other.fg.or(self.fg),
117            bg: other.bg.or(self.bg),
118            bold: self.bold || other.bold,
119            dim: self.dim || other.dim,
120            italic: self.italic || other.italic,
121            underline: self.underline || other.underline,
122            strike: self.strike || other.strike,
123            reverse: self.reverse || other.reverse,
124        }
125    }
126
127    pub fn is_plain(&self) -> bool {
128        *self == Style::PLAIN
129    }
130}
131
132/// A contiguous run of text sharing a single style.
133#[derive(Clone, Debug, PartialEq)]
134pub struct Segment<'a> {
135    pub text: Cow<'a, str>,
136    pub style: Style,
137    /// Hyperlink target, when this run is part of one. The backend emits it as OSC 8 or
138    /// degrades it to a numbered reference.
139    pub link: Option<Cow<'a, str>>,
140}
141
142impl<'a> Segment<'a> {
143    pub fn new(text: impl Into<Cow<'a, str>>, style: Style) -> Self {
144        Segment {
145            text: text.into(),
146            style,
147            link: None,
148        }
149    }
150
151    pub fn plain(text: impl Into<Cow<'a, str>>) -> Self {
152        Segment::new(text, Style::PLAIN)
153    }
154
155    pub fn with_link(mut self, href: impl Into<Cow<'a, str>>) -> Self {
156        self.link = Some(href.into());
157        self
158    }
159}
160
161/// One line of output. `width` is the display-cell width already computed by the layout;
162/// the backend does not measure again.
163#[derive(Clone, Debug, Default, PartialEq)]
164pub struct Line<'a> {
165    pub segments: Vec<Segment<'a>>,
166    pub width: usize,
167}
168
169impl<'a> Line<'a> {
170    pub fn empty() -> Self {
171        Line::default()
172    }
173
174    pub fn from_segments(segments: Vec<Segment<'a>>, width: usize) -> Self {
175        Line { segments, width }
176    }
177
178    /// The text with styles dropped. The basis of the plain backend and of test asserts.
179    pub fn to_plain_string(&self) -> String {
180        self.segments.iter().map(|s| s.text.as_ref()).collect()
181    }
182
183    pub fn is_blank(&self) -> bool {
184        self.segments
185            .iter()
186            .all(|s| s.text.chars().all(char::is_whitespace))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn over_accumulates_attributes_and_prefers_the_new_color() {
196        let base = Style::bold().with_fg(Color::Named(NamedColor::Red));
197        let top = Style::PLAIN
198            .with_italic()
199            .with_fg(Color::Named(NamedColor::Blue));
200        let merged = base.over(top);
201        assert!(merged.bold, "the base's bold must survive");
202        assert!(merged.italic);
203        assert_eq!(merged.fg, Some(Color::Named(NamedColor::Blue)));
204    }
205
206    #[test]
207    fn over_keeps_the_base_color_when_the_new_one_is_unset() {
208        let base = Style::fg(Color::Named(NamedColor::Red));
209        let merged = base.over(Style::bold());
210        assert_eq!(merged.fg, Some(Color::Named(NamedColor::Red)));
211        assert!(merged.bold);
212    }
213
214    #[test]
215    fn to_plain_string_concatenates() {
216        let line = Line::from_segments(
217            vec![Segment::plain("ab"), Segment::new("cd", Style::bold())],
218            4,
219        );
220        assert_eq!(line.to_plain_string(), "abcd");
221    }
222}