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/// How a block's lines are set across the measure — the one presentation
164/// property an author reaches for before any other, and the only one of the
165/// six whose vocabulary is a *class* rather than a `data-` key.
166///
167/// A `class` is a space-separated token list in every format leaf opens, and
168/// this reads the one token it knows and leaves the rest: a paragraph that
169/// arrives as `class="lead center"` is centred and keeps `lead`. A token
170/// outside the vocabulary is not an error — it is somebody else's class, and a
171/// document from elsewhere passes through the editor unharmed.
172///
173/// There is no `Left`, because absence is left: the default alignment is the
174/// theme's, and a document that agrees with it has no reason to say so. A
175/// right-to-left default is a theme matter, not a class.
176#[derive(Clone, Copy, PartialEq, Eq, Debug)]
177pub enum Align {
178    Center,
179    Right,
180    Justify,
181}
182
183impl Align {
184    /// The alignment a block's attributes name, if any — the first token of
185    /// `class` that is one of the three, in source order.
186    ///
187    /// Takes the attribute list rather than the node for [`MarkColor`]'s
188    /// reason: this module stays free of twig as well as of any toolkit, and
189    /// both the block walker and the caret query hand over the same
190    /// `node.attrs`.
191    pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
192        attrs
193            .iter()
194            .find(|(k, _)| k == "class")
195            .and_then(|(_, v)| v.as_deref())
196            .and_then(|class| class.split_whitespace().find_map(Self::from_token))
197    }
198
199    /// Read one `class` token. `None` for a token outside the vocabulary,
200    /// which is how a foreign class is *kept* rather than misread — the
201    /// gesture that rewrites the alignment removes only the tokens this
202    /// answers `Some` for.
203    pub fn from_token(token: &str) -> Option<Self> {
204        Some(match token {
205            "center" => Self::Center,
206            "right" => Self::Right,
207            "justify" => Self::Justify,
208            _ => return None,
209        })
210    }
211
212    /// The token twig spells it with, and what [`from_token`](Self::from_token)
213    /// reads back — also the class a frontend's stylesheet selects on, which is
214    /// why it is CSS's own word for the same thing.
215    pub const fn name(self) -> &'static str {
216        match self {
217            Self::Center => "center",
218            Self::Right => "right",
219            Self::Justify => "justify",
220        }
221    }
222
223    /// This alignment's position in [`ALL`](Self::ALL) — the index a frontend's
224    /// own segmented control is keyed by, the way [`MarkColor::index`] keys a
225    /// palette.
226    pub const fn index(self) -> usize {
227        match self {
228            Self::Center => 0,
229            Self::Right => 1,
230            Self::Justify => 2,
231        }
232    }
233
234    /// Every alignment, in the order a toolbar offers them. Left is absent
235    /// because absence *is* left; a segmented control draws a fourth segment
236    /// for it and calls [`crate::Doc::set_alignment`] with `None`.
237    pub const ALL: [Self; 3] = [Self::Center, Self::Right, Self::Justify];
238}
239
240/// How far apart a block's lines are set, as a multiple of the theme's own line
241/// height — the spacing menu every word processor has, less the single spacing
242/// that is absence.
243///
244/// The tokens are the numbers rather than names (`loose`, `double`) so that a
245/// stylesheet's line is `line-height: 1.5` and a reader of the source sees the
246/// ratio. `1` is not a token, because `1` is absence and a document should not
247/// carry a key that says nothing.
248#[derive(Clone, Copy, PartialEq, Eq, Debug)]
249pub enum LineSpacing {
250    /// `1.15` — the word processor's default "a little more air".
251    OneFifteen,
252    /// `1.5`.
253    OneHalf,
254    /// `2` — double spacing.
255    Double,
256}
257
258impl LineSpacing {
259    /// The spacing a block's attributes name, if any — twig records it under
260    /// `data-line-height`.
261    pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
262        attrs
263            .iter()
264            .find(|(k, _)| k == "data-line-height")
265            .and_then(|(_, v)| v.as_deref())
266            .and_then(Self::from_attr)
267    }
268
269    /// Read a `data-line-height` value. `None` for a ratio outside the
270    /// vocabulary — a document carrying `data-line-height="1.3"` keeps the key
271    /// and draws at the theme's spacing, rather than having leaf guess a step.
272    pub fn from_attr(value: &str) -> Option<Self> {
273        Some(match value {
274            "1.15" => Self::OneFifteen,
275            "1.5" => Self::OneHalf,
276            "2" => Self::Double,
277            _ => return None,
278        })
279    }
280
281    /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
282    /// reads back.
283    pub const fn name(self) -> &'static str {
284        match self {
285            Self::OneFifteen => "1.15",
286            Self::OneHalf => "1.5",
287            Self::Double => "2",
288        }
289    }
290
291    /// The ratio itself — what a frontend multiplies the theme's line height by
292    /// to lay the row out. A *derived* number, not a carried one: the document
293    /// says `1.5`, and this is that name read as arithmetic.
294    pub const fn ratio(self) -> f32 {
295        match self {
296            Self::OneFifteen => 1.15,
297            Self::OneHalf => 1.5,
298            Self::Double => 2.0,
299        }
300    }
301
302    /// This spacing's position in [`ALL`](Self::ALL) — the index a frontend's
303    /// own menu is keyed by.
304    pub const fn index(self) -> usize {
305        match self {
306            Self::OneFifteen => 0,
307            Self::OneHalf => 1,
308            Self::Double => 2,
309        }
310    }
311
312    /// Every spacing, in the order a menu offers them. Single is absent for the
313    /// reason `left` is absent from [`Align::ALL`].
314    pub const ALL: [Self; 3] = [Self::OneFifteen, Self::OneHalf, Self::Double];
315}
316
317/// How large a run is set relative to the text around it — CSS's
318/// `<absolute-size>` keyword set with `medium` removed, because `medium` is
319/// absence.
320///
321/// A *step*, never a measurement, for the reason [`MarkColor`] is a name and
322/// not a hex triple: a run set to `14pt` in a theme whose body is 12pt is a
323/// step up, and the same run under a 16pt theme is a step *down* — the author's
324/// intent inverted by a change they never made. A run set to `Large` is a step
325/// up under every theme.
326///
327/// A heading keeps its own ramp: a `data-size` on a heading scales the
328/// heading's size, not the body's.
329#[derive(Clone, Copy, PartialEq, Eq, Debug)]
330pub enum SizeStep {
331    XxSmall,
332    XSmall,
333    Small,
334    Large,
335    XLarge,
336    XxLarge,
337    XxxLarge,
338}
339
340impl SizeStep {
341    /// The size a run's or block's attributes name, if any — twig records it
342    /// under `data-size`.
343    pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
344        attrs
345            .iter()
346            .find(|(k, _)| k == "data-size")
347            .and_then(|(_, v)| v.as_deref())
348            .and_then(Self::from_attr)
349    }
350
351    /// Read a `data-size` value. `None` for a name outside the vocabulary — a
352    /// `data-size="14pt"` from elsewhere is carried and drawn at the theme's
353    /// own size, which is the same answer the stylesheet gives it.
354    pub fn from_attr(value: &str) -> Option<Self> {
355        Some(match value {
356            "xx-small" => Self::XxSmall,
357            "x-small" => Self::XSmall,
358            "small" => Self::Small,
359            "large" => Self::Large,
360            "x-large" => Self::XLarge,
361            "xx-large" => Self::XxLarge,
362            "xxx-large" => Self::XxxLarge,
363            _ => return None,
364        })
365    }
366
367    /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
368    /// reads back — CSS's own keyword, so a stylesheet rule is
369    /// `[data-size="large"] { font-size: large }` and nothing is learned twice.
370    pub const fn name(self) -> &'static str {
371        match self {
372            Self::XxSmall => "xx-small",
373            Self::XSmall => "x-small",
374            Self::Small => "small",
375            Self::Large => "large",
376            Self::XLarge => "x-large",
377            Self::XxLarge => "xx-large",
378            Self::XxxLarge => "xxx-large",
379        }
380    }
381
382    /// The multiple of the theme's body size this step sets — the ratios CSS's
383    /// own user-agent stylesheet uses for the same seven words, so a browser
384    /// given only leaf's stylesheet and a native renderer given the theme agree
385    /// about how big `large` is.
386    ///
387    /// A *default*: a theme is free to scale its own ramp, the way a frontend
388    /// is free to pick its own red for [`MarkColor::Red`]. What the document
389    /// carries is the name.
390    pub const fn scale(self) -> f32 {
391        match self {
392            Self::XxSmall => 0.5625,
393            Self::XSmall => 0.625,
394            Self::Small => 0.8125,
395            Self::Large => 1.125,
396            Self::XLarge => 1.5,
397            Self::XxLarge => 2.0,
398            Self::XxxLarge => 3.0,
399        }
400    }
401
402    /// This step's position in [`ALL`](Self::ALL) — smallest first, so a
403    /// frontend's menu is `ALL` in order and a "larger" button is `index() + 1`.
404    pub const fn index(self) -> usize {
405        match self {
406            Self::XxSmall => 0,
407            Self::XSmall => 1,
408            Self::Small => 2,
409            Self::Large => 3,
410            Self::XLarge => 4,
411            Self::XxLarge => 5,
412            Self::XxxLarge => 6,
413        }
414    }
415
416    /// Every step, smallest first. `medium` is absent because `medium` is
417    /// absence — a menu draws it as the entry that calls
418    /// [`crate::Doc::set_font_size`] with `None`.
419    pub const ALL: [Self; 7] = [
420        Self::XxSmall,
421        Self::XSmall,
422        Self::Small,
423        Self::Large,
424        Self::XLarge,
425        Self::XxLarge,
426        Self::XxxLarge,
427    ];
428}
429
430/// The face a run is set in — CSS's generic families, less `fantasy` and
431/// `system-ui`, neither of which an author asks for.
432///
433/// A generic, never a font name, for [`SizeStep`]'s reason: a document that
434/// names `Georgia` renders in the fallback everywhere Georgia is not installed,
435/// which is every Linux terminal and most of the web. The theme names the
436/// concrete face for each — `Serif` is Georgia on a Mac and Noto Serif on a
437/// Linux box, and `Monospace` is the theme's mono face, which inline code
438/// already uses.
439///
440/// A named family is *carried* (twig will spell `data-font="Garamond"`) and is
441/// not in this vocabulary: [`from_attr`](Self::from_attr) answers `None` for
442/// it, the native renderers may resolve it through the platform's font registry,
443/// and the toolbar offers the four.
444#[derive(Clone, Copy, PartialEq, Eq, Debug)]
445pub enum FontFamily {
446    Serif,
447    SansSerif,
448    Monospace,
449    Cursive,
450}
451
452impl FontFamily {
453    /// The face a run's or block's attributes name, if any — twig records it
454    /// under `data-font`.
455    pub fn from_attrs(attrs: &[(String, Option<String>)]) -> Option<Self> {
456        attrs
457            .iter()
458            .find(|(k, _)| k == "data-font")
459            .and_then(|(_, v)| v.as_deref())
460            .and_then(Self::from_attr)
461    }
462
463    /// Read a `data-font` value. `None` for a concrete family name, which is
464    /// carried by the document and left to whatever the frontend can resolve.
465    pub fn from_attr(value: &str) -> Option<Self> {
466        Some(match value {
467            "serif" => Self::Serif,
468            "sans-serif" => Self::SansSerif,
469            "monospace" => Self::Monospace,
470            "cursive" => Self::Cursive,
471            _ => return None,
472        })
473    }
474
475    /// The token twig spells it with, and what [`from_attr`](Self::from_attr)
476    /// reads back — CSS's own generic, so the stylesheet line is
477    /// `font-family: serif`.
478    pub const fn name(self) -> &'static str {
479        match self {
480            Self::Serif => "serif",
481            Self::SansSerif => "sans-serif",
482            Self::Monospace => "monospace",
483            Self::Cursive => "cursive",
484        }
485    }
486
487    /// This face's position in [`ALL`](Self::ALL) — the index a frontend's own
488    /// font table is keyed by.
489    pub const fn index(self) -> usize {
490        match self {
491            Self::Serif => 0,
492            Self::SansSerif => 1,
493            Self::Monospace => 2,
494            Self::Cursive => 3,
495        }
496    }
497
498    /// Every face, in the order a menu offers them. The theme's own body face
499    /// is absent because it is absence — the menu entry for it calls
500    /// [`crate::Doc::set_font_family`] with `None`.
501    pub const ALL: [Self; 4] = [Self::Serif, Self::SansSerif, Self::Monospace, Self::Cursive];
502}
503
504/// What a glyph in a fenced code block is *to the language it is written in*
505/// — the syntax-highlighting vocabulary, one level down from [`Role`].
506///
507/// Eight classes rather than the hundreds of scopes a Sublime grammar names,
508/// and the same eight `plates` colours its published pages with: each is the
509/// *first atom* of a TextMate scope — `keyword.control.rust` is a keyword,
510/// `string.quoted.double` a string — and a palette that tells these eight apart
511/// is a palette that reads. Finer than this is a theme's business, and a theme
512/// is exactly what core does not carry: like [`Role`], a token says what a
513/// glyph *is* and leaves what colour to paint it to the frontend, so the same
514/// document highlights in ANSI colours on a terminal and in an `Hsla` in a GUI.
515///
516/// Only a glyph with [`Role::Code`] carries one, and only in a fenced block
517/// whose info string names a language the bundled grammars know (see
518/// [`crate::syntax`]). Inline `` `code` ``, an indented block, a bare fence, and
519/// a language no grammar covers all carry `None`, and draw in the code colour
520/// exactly as they did before this existed.
521#[derive(Clone, Copy, PartialEq, Eq, Debug)]
522pub enum Token {
523    /// Brackets, operators, separators — `punctuation.*`. The quietest class:
524    /// a frontend typically draws it in the comment colour, so the `"` opening
525    /// a string reads as the string's and not as its own thing.
526    Punctuation,
527    /// A reserved word or a storage modifier — `keyword.*` and `storage.*`
528    /// (`fn`, `let`, `pub`, `const`, `if`).
529    Keyword,
530    /// A name the author defined or named — `entity.*` (a function or type at
531    /// its definition) and `variable.*` (a parameter, a field, `self`).
532    Entity,
533    /// A name the language or its library provides — `support.*` (a builtin
534    /// function, a standard type).
535    Support,
536    /// A literal that is not a string — `constant.*` (a number, `true`, an
537    /// escape sequence, a character literal).
538    Constant,
539    /// A string literal, delimiters included — `string.*`.
540    String,
541    /// A comment — `comment.*`. A frontend typically italicises it as well.
542    Comment,
543    /// What the grammar could not parse — `invalid.*`.
544    Invalid,
545}
546
547impl Token {
548    /// The class id a frontend keys a stylesheet or a palette on — the first
549    /// atom of the scope it stands for, so a rule written for `plates` output
550    /// (`plates-keyword`) and one written for leaf (`leaf-t-keyword`) name the
551    /// same thing.
552    pub const fn name(self) -> &'static str {
553        match self {
554            Self::Punctuation => "punctuation",
555            Self::Keyword => "keyword",
556            Self::Entity => "entity",
557            Self::Support => "support",
558            Self::Constant => "constant",
559            Self::String => "string",
560            Self::Comment => "comment",
561            Self::Invalid => "invalid",
562        }
563    }
564
565    /// The token a class id names, or `None` for a name outside the
566    /// vocabulary — the inverse of [`name`](Self::name).
567    pub fn from_name(name: &str) -> Option<Self> {
568        Self::ALL.into_iter().find(|t| t.name() == name)
569    }
570
571    /// This token's position in [`ALL`](Self::ALL) — the index a frontend's own
572    /// palette array is keyed by, the way [`MarkColor::index`] keys the
573    /// highlighter washes.
574    pub const fn index(self) -> usize {
575        match self {
576            Self::Punctuation => 0,
577            Self::Keyword => 1,
578            Self::Entity => 2,
579            Self::Support => 3,
580            Self::Constant => 4,
581            Self::String => 5,
582            Self::Comment => 6,
583            Self::Invalid => 7,
584        }
585    }
586
587    /// Every token, in **precedence order**: a scope whose atoms name two of
588    /// these (`punctuation.definition.string.begin` is both punctuation and
589    /// string) is the *later* one, so a string's quotes read as string and a
590    /// comment's `//` as comment. The frontends iterate this to build their
591    /// palettes, so a token added here is one a palette test immediately
592    /// demands an entry for.
593    pub const ALL: [Self; 8] = [
594        Self::Punctuation,
595        Self::Keyword,
596        Self::Entity,
597        Self::Support,
598        Self::Constant,
599        Self::String,
600        Self::Comment,
601        Self::Invalid,
602    ];
603}
604
605/// Which line a glyph sits on relative to the text around it.
606///
607/// Not a [`Role`], because a raised glyph keeps whatever it already was — the
608/// `1` of a footnote reference is still a link, an author's `^2^` inside a
609/// heading is still heading text. And not one of [`Style`]'s `bool` flags,
610/// because unlike bold-and-italic these do not compose: a glyph is raised, or
611/// lowered, or neither, and two flags would let a caller ask for both.
612///
613/// A frontend that ignores this draws every glyph on the normal baseline, which
614/// is what every frontend did before the variant existed.
615#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
616pub enum Baseline {
617    /// The ordinary text baseline.
618    #[default]
619    Normal,
620    /// Raised and typically drawn smaller — an author's `^x^`, and the label of
621    /// a footnote reference.
622    Super,
623    /// Lowered and typically drawn smaller — an author's `~x~`.
624    Sub,
625}
626
627/// A glyph's style: a typographic [`Role`] plus the compositional emphasis flags
628/// the author wrote. Deliberately *no* color — that is a frontend's call, keyed
629/// on the [`Role`]. Builder methods (`.bold`, `.italic`, …) mirror the shape of
630/// ratatui's `Style` so the WYSIWYG builder reads the same as it did before the
631/// split.
632#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
633pub struct Style {
634    pub bold: bool,
635    pub italic: bool,
636    pub underline: bool,
637    pub strikethrough: bool,
638    /// The typographic role — [`Role::Body`] for ordinary text.
639    pub role: Role,
640    /// Which line the glyph sits on — [`Baseline::Normal`] for ordinary text.
641    pub baseline: Baseline,
642    /// What a code glyph is to its language — `None` for every glyph outside a
643    /// highlighted fenced block, which is every glyph there was before syntax
644    /// highlighting. Only meaningful beside [`Role::Code`]; a frontend that
645    /// ignores it draws code in one colour, as every frontend once did.
646    pub token: Option<Token>,
647    /// How large this run is set relative to the text around it — the author's
648    /// `data-size`, and `None` for the theme's own size, which is every glyph
649    /// there was before the presentation vocabulary.
650    ///
651    /// Read at both levels, the nearer winning: a span's `data-size` inside a
652    /// block carrying its own applies to the span. A frontend that ignores it
653    /// draws one size, as `leaf-ratatui` does — a cell has one size.
654    pub size: Option<SizeStep>,
655    /// The face this run is set in — the author's `data-font`, and `None` for
656    /// the theme's body face. Read at the same two levels [`size`](Self::size)
657    /// is.
658    pub font: Option<FontFamily>,
659    /// The run's *foreground* colour — the author's `data-color` on an
660    /// attributed span, and `None` for the theme's text colour.
661    ///
662    /// The same seven names [`Role::Mark`] carries, and deliberately the same
663    /// enum: a frontend that has a red for a highlight has a red for text, and
664    /// both should be *that* red. The two never collide, because a `mark` is a
665    /// `mark` and a span is a span — a `data-color` on a `mark` node is the
666    /// highlight's background and reaches a glyph through its role, while this
667    /// is what a `<span data-color="red">` paints the letters.
668    pub color: Option<MarkColor>,
669}
670
671impl Style {
672    pub const fn bold(mut self) -> Self {
673        self.bold = true;
674        self
675    }
676
677    pub const fn italic(mut self) -> Self {
678        self.italic = true;
679        self
680    }
681
682    pub const fn underline(mut self) -> Self {
683        self.underline = true;
684        self
685    }
686
687    pub const fn strikethrough(mut self) -> Self {
688        self.strikethrough = true;
689        self
690    }
691
692    pub const fn role(mut self, r: Role) -> Self {
693        self.role = r;
694        self
695    }
696
697    pub const fn baseline(mut self, b: Baseline) -> Self {
698        self.baseline = b;
699        self
700    }
701
702    pub const fn token(mut self, t: Option<Token>) -> Self {
703        self.token = t;
704        self
705    }
706
707    pub const fn size(mut self, s: Option<SizeStep>) -> Self {
708        self.size = s;
709        self
710    }
711
712    pub const fn font(mut self, f: Option<FontFamily>) -> Self {
713        self.font = f;
714        self
715    }
716
717    pub const fn color(mut self, c: Option<MarkColor>) -> Self {
718        self.color = c;
719        self
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726
727    /// [`MarkColor`] carries three hand-written tables — `ALL`, `index`, and the
728    /// `name`/`from_attr` pair — and nothing but this makes them agree. The
729    /// frontends index their palettes by `index` and look colours up by `name`,
730    /// so a variant added to one table and missed in another draws the wrong
731    /// wash rather than failing to compile.
732    #[test]
733    fn the_colour_tables_agree_with_each_other() {
734        for (i, c) in MarkColor::ALL.into_iter().enumerate() {
735            assert_eq!(c.index(), i, "{} is not where ALL puts it", c.name());
736            assert_eq!(MarkColor::from_attr(c.name()), Some(c), "name round-trip");
737        }
738        assert_eq!(MarkColor::from_attr("chartreuse"), None);
739        assert_eq!(MarkColor::from_attr(""), None);
740    }
741
742    /// [`Token`] carries the same three tables [`MarkColor`] does, held to
743    /// each other for the same reason: a frontend indexes its palette by
744    /// `index` and a stylesheet keys on `name`.
745    #[test]
746    fn the_token_tables_agree_with_each_other() {
747        for (i, t) in Token::ALL.into_iter().enumerate() {
748            assert_eq!(t.index(), i, "{} is not where ALL puts it", t.name());
749            assert_eq!(Token::from_name(t.name()), Some(t), "name round-trip");
750        }
751        assert_eq!(Token::from_name("meta"), None);
752        assert_eq!(Token::from_name(""), None);
753    }
754
755    /// Each presentation enum carries the same three hand-written tables
756    /// [`MarkColor`] does — `ALL`, `index`, and the `name`/`from_*` pair — and
757    /// nothing but this makes them agree. A frontend indexes its own menu by
758    /// `index` and a stylesheet keys on `name`, so a variant added to one table
759    /// and missed in another draws the wrong thing rather than failing to
760    /// compile.
761    #[test]
762    fn the_presentation_tables_agree_with_each_other() {
763        for (i, a) in Align::ALL.into_iter().enumerate() {
764            assert_eq!(a.index(), i, "{} is not where ALL puts it", a.name());
765            assert_eq!(Align::from_token(a.name()), Some(a), "name round-trip");
766        }
767        for (i, l) in LineSpacing::ALL.into_iter().enumerate() {
768            assert_eq!(l.index(), i, "{} is not where ALL puts it", l.name());
769            assert_eq!(LineSpacing::from_attr(l.name()), Some(l), "name round-trip");
770        }
771        for (i, z) in SizeStep::ALL.into_iter().enumerate() {
772            assert_eq!(z.index(), i, "{} is not where ALL puts it", z.name());
773            assert_eq!(SizeStep::from_attr(z.name()), Some(z), "name round-trip");
774        }
775        for (i, f) in FontFamily::ALL.into_iter().enumerate() {
776            assert_eq!(f.index(), i, "{} is not where ALL puts it", f.name());
777            assert_eq!(FontFamily::from_attr(f.name()), Some(f), "name round-trip");
778        }
779        // Nothing outside the vocabulary is guessed at.
780        assert_eq!(Align::from_token("left"), None);
781        assert_eq!(LineSpacing::from_attr("1"), None);
782        assert_eq!(SizeStep::from_attr("medium"), None);
783        assert_eq!(SizeStep::from_attr("14pt"), None);
784        assert_eq!(FontFamily::from_attr("Garamond"), None);
785        assert_eq!(FontFamily::from_attr("fantasy"), None);
786    }
787
788    /// The size ramp's defaults are CSS's own user-agent ratios for the same
789    /// seven words, so a browser given only leaf's stylesheet and a native
790    /// renderer given the theme agree about how big `large` is. Pinned because
791    /// they are the one place core carries a *number*.
792    #[test]
793    fn the_size_ramp_is_css_s_own() {
794        let ramp: Vec<f32> = SizeStep::ALL.into_iter().map(SizeStep::scale).collect();
795        assert_eq!(
796            ramp,
797            vec![0.5625, 0.625, 0.8125, 1.125, 1.5, 2.0, 3.0],
798            "the CSS absolute-size ratios, medium removed"
799        );
800        // Monotonic, and `medium` (1.0) is the gap absence sits in.
801        assert!(ramp.windows(2).all(|w| w[0] < w[1]));
802        assert!(SizeStep::Small.scale() < 1.0 && SizeStep::Large.scale() > 1.0);
803        let spacing: Vec<f32> = LineSpacing::ALL
804            .into_iter()
805            .map(LineSpacing::ratio)
806            .collect();
807        assert_eq!(spacing, vec![1.15, 1.5, 2.0]);
808    }
809
810    /// `class` is a token list, and leaf reads the one token it knows out of it
811    /// and leaves the rest — the rule that lets a document from elsewhere pass
812    /// through the editor unharmed.
813    #[test]
814    fn an_alignment_is_one_token_of_a_class_and_the_rest_is_somebody_else_s() {
815        let class = |v: &str| vec![("class".to_string(), Some(v.to_string()))];
816        assert_eq!(Align::from_attrs(&class("center")), Some(Align::Center));
817        assert_eq!(
818            Align::from_attrs(&class("lead center")),
819            Some(Align::Center)
820        );
821        assert_eq!(
822            Align::from_attrs(&class("center lead")),
823            Some(Align::Center)
824        );
825        assert_eq!(Align::from_attrs(&class("lead wide")), None);
826        assert_eq!(Align::from_attrs(&class("")), None);
827        assert_eq!(Align::from_attrs(&[]), None);
828        // A bare `class` has no token list to read.
829        assert_eq!(Align::from_attrs(&[("class".to_string(), None)]), None);
830        // The other three read their own key and nothing else.
831        let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
832        assert_eq!(
833            LineSpacing::from_attrs(&attr("data-line-height", "1.5")),
834            Some(LineSpacing::OneHalf)
835        );
836        assert_eq!(LineSpacing::from_attrs(&attr("class", "1.5")), None);
837        assert_eq!(
838            SizeStep::from_attrs(&attr("data-size", "large")),
839            Some(SizeStep::Large)
840        );
841        assert_eq!(
842            FontFamily::from_attrs(&attr("data-font", "monospace")),
843            Some(FontFamily::Monospace)
844        );
845        // Text colour is the highlight's own vocabulary, read off the same key.
846        assert_eq!(
847            MarkColor::from_attrs(&attr("data-color", "blue")),
848            Some(MarkColor::Blue)
849        );
850    }
851
852    /// The attribute twig actually writes, read off the shape a `FlatNode`
853    /// hands over — a `mark` with no colour, one with the colour, and one
854    /// carrying some other attribute entirely.
855    #[test]
856    fn a_colour_is_read_out_of_the_data_color_attribute_and_nothing_else() {
857        let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
858        assert_eq!(
859            MarkColor::from_attrs(&attr("data-color", "green")),
860            Some(MarkColor::Green)
861        );
862        assert_eq!(MarkColor::from_attrs(&[]), None);
863        assert_eq!(MarkColor::from_attrs(&attr("id", "red")), None);
864        // A bare attribute has no value to read a colour out of.
865        assert_eq!(
866            MarkColor::from_attrs(&[("data-color".to_string(), None)]),
867            None
868        );
869    }
870}