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/// Which line a glyph sits on relative to the text around it.
164///
165/// Not a [`Role`], because a raised glyph keeps whatever it already was — the
166/// `1` of a footnote reference is still a link, an author's `^2^` inside a
167/// heading is still heading text. And not one of [`Style`]'s `bool` flags,
168/// because unlike bold-and-italic these do not compose: a glyph is raised, or
169/// lowered, or neither, and two flags would let a caller ask for both.
170///
171/// A frontend that ignores this draws every glyph on the normal baseline, which
172/// is what every frontend did before the variant existed.
173#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
174pub enum Baseline {
175 /// The ordinary text baseline.
176 #[default]
177 Normal,
178 /// Raised and typically drawn smaller — an author's `^x^`, and the label of
179 /// a footnote reference.
180 Super,
181 /// Lowered and typically drawn smaller — an author's `~x~`.
182 Sub,
183}
184
185/// A glyph's style: a typographic [`Role`] plus the compositional emphasis flags
186/// the author wrote. Deliberately *no* color — that is a frontend's call, keyed
187/// on the [`Role`]. Builder methods (`.bold`, `.italic`, …) mirror the shape of
188/// ratatui's `Style` so the WYSIWYG builder reads the same as it did before the
189/// split.
190#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
191pub struct Style {
192 pub bold: bool,
193 pub italic: bool,
194 pub underline: bool,
195 pub strikethrough: bool,
196 /// The typographic role — [`Role::Body`] for ordinary text.
197 pub role: Role,
198 /// Which line the glyph sits on — [`Baseline::Normal`] for ordinary text.
199 pub baseline: Baseline,
200}
201
202impl Style {
203 pub const fn bold(mut self) -> Self {
204 self.bold = true;
205 self
206 }
207
208 pub const fn italic(mut self) -> Self {
209 self.italic = true;
210 self
211 }
212
213 pub const fn underline(mut self) -> Self {
214 self.underline = true;
215 self
216 }
217
218 pub const fn strikethrough(mut self) -> Self {
219 self.strikethrough = true;
220 self
221 }
222
223 pub const fn role(mut self, r: Role) -> Self {
224 self.role = r;
225 self
226 }
227
228 pub const fn baseline(mut self, b: Baseline) -> Self {
229 self.baseline = b;
230 self
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 /// [`MarkColor`] carries three hand-written tables — `ALL`, `index`, and the
239 /// `name`/`from_attr` pair — and nothing but this makes them agree. The
240 /// frontends index their palettes by `index` and look colours up by `name`,
241 /// so a variant added to one table and missed in another draws the wrong
242 /// wash rather than failing to compile.
243 #[test]
244 fn the_colour_tables_agree_with_each_other() {
245 for (i, c) in MarkColor::ALL.into_iter().enumerate() {
246 assert_eq!(c.index(), i, "{} is not where ALL puts it", c.name());
247 assert_eq!(MarkColor::from_attr(c.name()), Some(c), "name round-trip");
248 }
249 assert_eq!(MarkColor::from_attr("chartreuse"), None);
250 assert_eq!(MarkColor::from_attr(""), None);
251 }
252
253 /// The attribute twig actually writes, read off the shape a `FlatNode`
254 /// hands over — a `mark` with no colour, one with the colour, and one
255 /// carrying some other attribute entirely.
256 #[test]
257 fn a_colour_is_read_out_of_the_data_color_attribute_and_nothing_else() {
258 let attr = |k: &str, v: &str| vec![(k.to_string(), Some(v.to_string()))];
259 assert_eq!(
260 MarkColor::from_attrs(&attr("data-color", "green")),
261 Some(MarkColor::Green)
262 );
263 assert_eq!(MarkColor::from_attrs(&[]), None);
264 assert_eq!(MarkColor::from_attrs(&attr("id", "red")), None);
265 // A bare attribute has no value to read a colour out of.
266 assert_eq!(
267 MarkColor::from_attrs(&[("data-color".to_string(), None)]),
268 None
269 );
270 }
271}