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}