Skip to main content

leaf_core/
style.rs

1//! A toolkit-neutral text style — the seam that lets one document model drive
2//! any frontend.
3//!
4//! The WYSIWYG builder ([`crate::wysiwyg`]) tags each rendered glyph with one of
5//! these instead of a `ratatui::Style` or a `gpui::TextStyle`, so the caret
6//! model and the AST→glyph layout stay free of any GUI/TUI dependency.
7//!
8//! What core records is *what a glyph is*, never *what color to paint it*: a
9//! [`Role`] (heading, code, link, a list bullet, …) plus the portable emphasis
10//! the author actually wrote (`**bold**`, `*em*`, `{+ins+}`, `{-del-}`). Palette
11//! is presentation, and presentation belongs to the frontend — a terminal tells
12//! a heading from body text by color because color is all it can vary, while a
13//! GUI varies size and font instead. So each frontend maps a [`Role`] to its own
14//! look: `leaf-tui` turns it into terminal colors, `leaf-gpui` into an `Hsla`
15//! plus a font size and family. Core stays out of that argument.
16
17/// What a glyph *is*, typographically — the semantic role a frontend maps to its
18/// own presentation. Mutually exclusive per glyph (a glyph is a heading, or a
19/// link, or body text — not two at once); the compositional emphasis a run can
20/// also carry lives in [`Style`]'s `bold`/`italic`/`underline`/`strikethrough`
21/// flags alongside this.
22#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
23pub enum Role {
24    /// Ordinary prose — the surface's default text.
25    #[default]
26    Body,
27    /// A heading of the given level (1 = top). A GUI scales the font by level; a
28    /// terminal cycles a color by it.
29    Heading(u8),
30    /// Code — inline `` `verbatim` `` or a fenced block. A GUI renders it in a
31    /// monospace family; a terminal tints it.
32    Code,
33    /// A hyperlink's visible text (or bare URL/email).
34    Link,
35    /// Highlighted / marked text (`==mark==`), carrying the colour the author
36    /// named if they named one. `None` is a plain highlight — the only kind
37    /// there was before twig grew Obsidian's `==🔴 red==` spelling, and
38    /// still the only kind a format without the colour extension can produce.
39    Mark(Option<MarkColor>),
40    /// A list item's bullet or number — synthetic decoration, not authored text.
41    ListMarker,
42    /// A block quote's gutter (`│`), drawn down its left edge.
43    QuoteGutter,
44    /// A drawn rule: a thematic break (`───`) or a table's borders. A GUI that
45    /// draws its own tables ignores the border glyphs; the rule still reaches it.
46    Rule,
47    /// Raw markup a revealed line is showing: the `*` around an emphasis, the
48    /// `# ` opening a heading, a link's `](dest)`. Only ever emitted for the
49    /// caret's line under [`MarkupMode::Full`](crate::MarkupMode::Full) —
50    /// every other line resolves its markup away and has none of these.
51    ///
52    /// A role rather than a `Style` flag because it is what the glyph *is*: the
53    /// delimiter of an emphasis is not itself emphasised text. A frontend
54    /// typically dims it, so the revealed line still reads as prose with its
55    /// scaffolding visible rather than as source code. One that doesn't map it
56    /// draws it as body text, which is correct if unsubtle.
57    Delimiter,
58    /// A block-level image's placeholder text (`🖼 alt`). The glyphs are a
59    /// *default* rendering any surface can paint as-is (a terminal shows the
60    /// label); an image-capable frontend skips the placeholder row named by the
61    /// map's [`MediaInfo`](crate::wysiwyg::MediaInfo) `rows_span` and paints the
62    /// real picture in its place — the same skip-the-picture contract
63    /// [`Role::Rule`] table borders use.
64    Image,
65}
66
67/// The colour an author named on a highlight — the closed vocabulary twig
68/// records as a `mark` node's `data-color`, one variant per circle emoji the
69/// `==🔴 text==` spelling recognises.
70///
71/// A *name*, not a paint value, which is why this lives in core at all when
72/// [`Style`] otherwise holds no colour: `red` here is what the author wrote,
73/// and each frontend still decides which red draws it — a terminal picks an
74/// ANSI hue, a GUI an `Hsla`, the web a CSS custom property. The distinction is
75/// the same one [`Role::Heading`] makes by carrying a level rather than a size.
76#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum MarkColor {
78    Red,
79    Orange,
80    Yellow,
81    Green,
82    Blue,
83    Purple,
84    Brown,
85}
86
87impl MarkColor {
88    /// The colour a `mark` node's attributes name, if any — twig records it
89    /// under `data-color`, having stripped the emoji that spelled it out of the
90    /// node's content.
91    ///
92    /// Takes the attribute list rather than the node so this module stays free
93    /// of twig as well as of any toolkit; the pairs are plain `String`s, and
94    /// both the WYSIWYG and source builders hand over the same `node.attrs`.
95    pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
96        attrs
97            .iter()
98            .find(|(k, _)| k == "data-color")
99            .and_then(|(_, v)| v.as_deref())
100            .and_then(Self::from_attr)
101    }
102
103    /// Read a `data-color` attribute value. `None` for a name outside the
104    /// vocabulary, which a frontend then draws as a plain highlight rather than
105    /// guessing at a hue.
106    pub fn from_attr(value: &str) -> Option<Self> {
107        Some(match value {
108            "red" => Self::Red,
109            "orange" => Self::Orange,
110            "yellow" => Self::Yellow,
111            "green" => Self::Green,
112            "blue" => Self::Blue,
113            "purple" => Self::Purple,
114            "brown" => Self::Brown,
115            _ => return None,
116        })
117    }
118
119    /// The name twig spells it with, and what [`from_attr`](Self::from_attr)
120    /// reads back — also the suffix the web and Swift frontends build a class
121    /// id out of.
122    pub const fn name(self) -> &'static str {
123        match self {
124            Self::Red => "red",
125            Self::Orange => "orange",
126            Self::Yellow => "yellow",
127            Self::Green => "green",
128            Self::Blue => "blue",
129            Self::Purple => "purple",
130            Self::Brown => "brown",
131        }
132    }
133
134    /// This colour's position in [`ALL`](Self::ALL) — the index a frontend's
135    /// own palette array is keyed by, the way [`Role::Heading`]'s level keys a
136    /// heading ramp.
137    pub const fn index(self) -> usize {
138        match self {
139            Self::Red => 0,
140            Self::Orange => 1,
141            Self::Yellow => 2,
142            Self::Green => 3,
143            Self::Blue => 4,
144            Self::Purple => 5,
145            Self::Brown => 6,
146        }
147    }
148
149    /// Every colour, in the order twig's own enum declares them. The frontends
150    /// iterate this to build their palettes, so a colour added here is one a
151    /// palette test immediately demands an entry for.
152    pub const ALL: [Self; 7] = [
153        Self::Red,
154        Self::Orange,
155        Self::Yellow,
156        Self::Green,
157        Self::Blue,
158        Self::Purple,
159        Self::Brown,
160    ];
161}
162
163/// What a glyph in a fenced code block is *to the language it is written in*
164/// — the syntax-highlighting vocabulary, one level down from [`Role`].
165///
166/// Eight classes rather than the hundreds of scopes a Sublime grammar names,
167/// and the same eight `plates` colours its published pages with: each is the
168/// *first atom* of a TextMate scope — `keyword.control.rust` is a keyword,
169/// `string.quoted.double` a string — and a palette that tells these eight apart
170/// is a palette that reads. Finer than this is a theme's business, and a theme
171/// is exactly what core does not carry: like [`Role`], a token says what a
172/// glyph *is* and leaves what colour to paint it to the frontend, so the same
173/// document highlights in ANSI colours on a terminal and in an `Hsla` in a GUI.
174///
175/// Only a glyph with [`Role::Code`] carries one, and only in a fenced block
176/// whose info string names a language the bundled grammars know (see
177/// [`crate::syntax`]). Inline `` `code` ``, an indented block, a bare fence, and
178/// a language no grammar covers all carry `None`, and draw in the code colour
179/// exactly as they did before this existed.
180#[derive(Clone, Copy, PartialEq, Eq, Debug)]
181pub enum Token {
182    /// Brackets, operators, separators — `punctuation.*`. The quietest class:
183    /// a frontend typically draws it in the comment colour, so the `"` opening
184    /// a string reads as the string's and not as its own thing.
185    Punctuation,
186    /// A reserved word or a storage modifier — `keyword.*` and `storage.*`
187    /// (`fn`, `let`, `pub`, `const`, `if`).
188    Keyword,
189    /// A name the author defined or named — `entity.*` (a function or type at
190    /// its definition) and `variable.*` (a parameter, a field, `self`).
191    Entity,
192    /// A name the language or its library provides — `support.*` (a builtin
193    /// function, a standard type).
194    Support,
195    /// A literal that is not a string — `constant.*` (a number, `true`, an
196    /// escape sequence, a character literal).
197    Constant,
198    /// A string literal, delimiters included — `string.*`.
199    String,
200    /// A comment — `comment.*`. A frontend typically italicises it as well.
201    Comment,
202    /// What the grammar could not parse — `invalid.*`.
203    Invalid,
204}
205
206impl Token {
207    /// The class id a frontend keys a stylesheet or a palette on — the first
208    /// atom of the scope it stands for, so a rule written for `plates` output
209    /// (`plates-keyword`) and one written for leaf (`leaf-t-keyword`) name the
210    /// same thing.
211    pub const fn name(self) -> &'static str {
212        match self {
213            Self::Punctuation => "punctuation",
214            Self::Keyword => "keyword",
215            Self::Entity => "entity",
216            Self::Support => "support",
217            Self::Constant => "constant",
218            Self::String => "string",
219            Self::Comment => "comment",
220            Self::Invalid => "invalid",
221        }
222    }
223
224    /// The token a class id names, or `None` for a name outside the
225    /// vocabulary — the inverse of [`name`](Self::name).
226    pub fn from_name(name: &str) -> Option<Self> {
227        Self::ALL.into_iter().find(|t| t.name() == name)
228    }
229
230    /// This token's position in [`ALL`](Self::ALL) — the index a frontend's own
231    /// palette array is keyed by, the way [`MarkColor::index`] keys the
232    /// highlighter washes.
233    pub const fn index(self) -> usize {
234        match self {
235            Self::Punctuation => 0,
236            Self::Keyword => 1,
237            Self::Entity => 2,
238            Self::Support => 3,
239            Self::Constant => 4,
240            Self::String => 5,
241            Self::Comment => 6,
242            Self::Invalid => 7,
243        }
244    }
245
246    /// Every token, in **precedence order**: a scope whose atoms name two of
247    /// these (`punctuation.definition.string.begin` is both punctuation and
248    /// string) is the *later* one, so a string's quotes read as string and a
249    /// comment's `//` as comment. The frontends iterate this to build their
250    /// palettes, so a token added here is one a palette test immediately
251    /// demands an entry for.
252    pub const ALL: [Self; 8] = [
253        Self::Punctuation,
254        Self::Keyword,
255        Self::Entity,
256        Self::Support,
257        Self::Constant,
258        Self::String,
259        Self::Comment,
260        Self::Invalid,
261    ];
262}
263
264/// Which line a glyph sits on relative to the text around it.
265///
266/// Not a [`Role`], because a raised glyph keeps whatever it already was — the
267/// `1` of a footnote reference is still a link, an author's `^2^` inside a
268/// heading is still heading text. And not one of [`Style`]'s `bool` flags,
269/// because unlike bold-and-italic these do not compose: a glyph is raised, or
270/// lowered, or neither, and two flags would let a caller ask for both.
271///
272/// A frontend that ignores this draws every glyph on the normal baseline, which
273/// is what every frontend did before the variant existed.
274#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
275pub enum Baseline {
276    /// The ordinary text baseline.
277    #[default]
278    Normal,
279    /// Raised and typically drawn smaller — an author's `^x^`, and the label of
280    /// a footnote reference.
281    Super,
282    /// Lowered and typically drawn smaller — an author's `~x~`.
283    Sub,
284}
285
286/// A glyph's style: a typographic [`Role`] plus the compositional emphasis flags
287/// the author wrote. Deliberately *no* color — that is a frontend's call, keyed
288/// on the [`Role`]. Builder methods (`.bold`, `.italic`, …) mirror the shape of
289/// ratatui's `Style` so the WYSIWYG builder reads the same as it did before the
290/// split.
291#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
292pub struct Style {
293    pub bold: bool,
294    pub italic: bool,
295    pub underline: bool,
296    pub strikethrough: bool,
297    /// The typographic role — [`Role::Body`] for ordinary text.
298    pub role: Role,
299    /// Which line the glyph sits on — [`Baseline::Normal`] for ordinary text.
300    pub baseline: Baseline,
301    /// What a code glyph is to its language — `None` for every glyph outside a
302    /// highlighted fenced block, which is every glyph there was before syntax
303    /// highlighting. Only meaningful beside [`Role::Code`]; a frontend that
304    /// ignores it draws code in one colour, as every frontend once did.
305    pub token: Option<Token>,
306}
307
308impl Style {
309    pub const fn bold(mut self) -> Self {
310        self.bold = true;
311        self
312    }
313
314    pub const fn italic(mut self) -> Self {
315        self.italic = true;
316        self
317    }
318
319    pub const fn underline(mut self) -> Self {
320        self.underline = true;
321        self
322    }
323
324    pub const fn strikethrough(mut self) -> Self {
325        self.strikethrough = true;
326        self
327    }
328
329    pub const fn role(mut self, r: Role) -> Self {
330        self.role = r;
331        self
332    }
333
334    pub const fn baseline(mut self, b: Baseline) -> Self {
335        self.baseline = b;
336        self
337    }
338
339    pub const fn token(mut self, t: Option<Token>) -> Self {
340        self.token = t;
341        self
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    /// [`MarkColor`] carries three hand-written tables — `ALL`, `index`, and the
350    /// `name`/`from_attr` pair — and nothing but this makes them agree. The
351    /// frontends index their palettes by `index` and look colours up by `name`,
352    /// so a variant added to one table and missed in another draws the wrong
353    /// wash rather than failing to compile.
354    #[test]
355    fn the_colour_tables_agree_with_each_other() {
356        for (i, c) in MarkColor::ALL.into_iter().enumerate() {
357            assert_eq!(c.index(), i, "{} is not where ALL puts it", c.name());
358            assert_eq!(MarkColor::from_attr(c.name()), Some(c), "name round-trip");
359        }
360        assert_eq!(MarkColor::from_attr("chartreuse"), None);
361        assert_eq!(MarkColor::from_attr(""), None);
362    }
363
364    /// [`Token`] carries the same three tables [`MarkColor`] does, held to
365    /// each other for the same reason: a frontend indexes its palette by
366    /// `index` and a stylesheet keys on `name`.
367    #[test]
368    fn the_token_tables_agree_with_each_other() {
369        for (i, t) in Token::ALL.into_iter().enumerate() {
370            assert_eq!(t.index(), i, "{} is not where ALL puts it", t.name());
371            assert_eq!(Token::from_name(t.name()), Some(t), "name round-trip");
372        }
373        assert_eq!(Token::from_name("meta"), None);
374        assert_eq!(Token::from_name(""), None);
375    }
376
377    /// The attribute twig actually writes, read off the shape a `FlatNode`
378    /// hands over — a `mark` with no colour, one with the colour, and one
379    /// carrying some other attribute entirely.
380    #[test]
381    fn a_colour_is_read_out_of_the_data_color_attribute_and_nothing_else() {
382        let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
383        assert_eq!(
384            MarkColor::from_attrs(&attr("data-color", "green")),
385            Some(MarkColor::Green)
386        );
387        assert_eq!(MarkColor::from_attrs(&[]), None);
388        assert_eq!(MarkColor::from_attrs(&attr("id", "red")), None);
389        // A bare attribute has no value to read a colour out of.
390        assert_eq!(
391            MarkColor::from_attrs(&[("data-color".to_string(), None)]),
392            None
393        );
394    }
395}